Compare commits

..

1 Commits

Author SHA1 Message Date
Fredrik Burmester
6c051f6f61 first commit 2024-10-09 07:49:22 +02:00
124 changed files with 3089 additions and 6681 deletions

4
.gitignore vendored
View File

@@ -9,7 +9,6 @@ npm-debug.*
*.mobileprovision
*.orig.*
web-build/
modules/vlc-player/android/build
# macOS
.DS_Store
@@ -27,11 +26,8 @@ package-lock.json
/ios
/android
modules/player/android
pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json
*.apk
*.ipa
.continuerc.json

3
.idea/.gitignore generated vendored
View File

@@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml

View File

@@ -1,329 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceStreaming">
<option name="deviceSelectionList">
<list>
<PersistentDeviceSelectionData>
<option name="api" value="27" />
<option name="brand" value="DOCOMO" />
<option name="codename" value="F01L" />
<option name="id" value="F01L" />
<option name="manufacturer" value="FUJITSU" />
<option name="name" value="F-01L" />
<option name="screenDensity" value="360" />
<option name="screenX" value="720" />
<option name="screenY" value="1280" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="28" />
<option name="brand" value="DOCOMO" />
<option name="codename" value="SH-01L" />
<option name="id" value="SH-01L" />
<option name="manufacturer" value="SHARP" />
<option name="name" value="AQUOS sense2 SH-01L" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2160" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="Lenovo" />
<option name="codename" value="TB370FU" />
<option name="id" value="TB370FU" />
<option name="manufacturer" value="Lenovo" />
<option name="name" value="Tab P12" />
<option name="screenDensity" value="340" />
<option name="screenX" value="1840" />
<option name="screenY" value="2944" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="31" />
<option name="brand" value="samsung" />
<option name="codename" value="a51" />
<option name="id" value="a51" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy A51" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="akita" />
<option name="id" value="akita" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="samsung" />
<option name="codename" value="b0q" />
<option name="id" value="b0q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S22 Ultra" />
<option name="screenDensity" value="600" />
<option name="screenX" value="1440" />
<option name="screenY" value="3088" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="32" />
<option name="brand" value="google" />
<option name="codename" value="bluejay" />
<option name="id" value="bluejay" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 6a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="caiman" />
<option name="id" value="caiman" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro" />
<option name="screenDensity" value="360" />
<option name="screenX" value="960" />
<option name="screenY" value="2142" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="comet" />
<option name="id" value="comet" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro Fold" />
<option name="screenDensity" value="390" />
<option name="screenX" value="2076" />
<option name="screenY" value="2152" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="29" />
<option name="brand" value="samsung" />
<option name="codename" value="crownqlteue" />
<option name="id" value="crownqlteue" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Note9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2220" />
<option name="screenY" value="1080" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="dm3q" />
<option name="id" value="dm3q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S23 Ultra" />
<option name="screenDensity" value="600" />
<option name="screenX" value="1440" />
<option name="screenY" value="3088" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="e1q" />
<option name="id" value="e1q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S24" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="felix" />
<option name="id" value="felix" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="felix" />
<option name="id" value="felix" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="felix_camera" />
<option name="id" value="felix_camera" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold (Camera-enabled)" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="samsung" />
<option name="codename" value="gts8uwifi" />
<option name="id" value="gts8uwifi" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Tab S8 Ultra" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1848" />
<option name="screenY" value="2960" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="husky" />
<option name="id" value="husky" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8 Pro" />
<option name="screenDensity" value="390" />
<option name="screenX" value="1008" />
<option name="screenY" value="2244" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="motorola" />
<option name="codename" value="java" />
<option name="id" value="java" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="G20" />
<option name="screenDensity" value="280" />
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="komodo" />
<option name="id" value="komodo" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro XL" />
<option name="screenDensity" value="360" />
<option name="screenX" value="1008" />
<option name="screenY" value="2244" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="lynx" />
<option name="id" value="lynx" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 7a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="31" />
<option name="brand" value="google" />
<option name="codename" value="oriole" />
<option name="id" value="oriole" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 6" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="panther" />
<option name="id" value="panther" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 7" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="q5q" />
<option name="id" value="q5q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Z Fold5" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1812" />
<option name="screenY" value="2176" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="q6q" />
<option name="id" value="q6q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Z Fold6" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1856" />
<option name="screenY" value="2160" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="google" />
<option name="codename" value="r11" />
<option name="id" value="r11" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Watch" />
<option name="screenDensity" value="320" />
<option name="screenX" value="384" />
<option name="screenY" value="384" />
<option name="type" value="WEAR_OS" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="google" />
<option name="codename" value="redfin" />
<option name="id" value="redfin" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 5" />
<option name="screenDensity" value="440" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="shiba" />
<option name="id" value="shiba" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="tangorpro" />
<option name="id" value="tangorpro" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Tablet" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1600" />
<option name="screenY" value="2560" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="tokay" />
<option name="id" value="tokay" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2424" />
</PersistentDeviceSelectionData>
</list>
</option>
</component>
</project>

6
.idea/misc.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/streamyfin.iml" filepath="$PROJECT_DIR$/.idea/streamyfin.iml" />
</modules>
</component>
</project>

9
.idea/streamyfin.iml generated
View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -8,10 +8,5 @@
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[swift]": {
"editor.defaultFormatter": "sswg.swift-lang"
},
"java.configuration.updateBuildConfiguration": "interactive",
"java.compile.nullAnalysis.mode": "automatic"
}
}

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.21.0",
"version": "0.17.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -33,7 +33,7 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 46,
"versionCode": 43,
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive_icon.png"
},
@@ -66,6 +66,13 @@
}
}
],
[
"./plugins/withAndroidMainActivityAttributes",
{
"com.reactnative.googlecast.RNGCExpandedControllerActivity": true
}
],
["./plugins/withExpandedController.js"],
[
"expo-build-properties",
{
@@ -99,14 +106,7 @@
{
"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

View File

@@ -27,6 +27,7 @@ export default function IndexLayout() {
onPress={() => {
router.push("/(auth)/settings");
}}
className="p-2 "
>
<Feather name="settings" color={"white"} size={22} />
</TouchableOpacity>
@@ -41,11 +42,17 @@ export default function IndexLayout() {
}}
/>
<Stack.Screen
name="settings"
name="settings/index"
options={{
title: "Settings",
}}
/>
<Stack.Screen
name="settings/audio-language"
options={{
title: "Audio Language",
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}

View File

@@ -2,46 +2,36 @@ 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 { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function page() {
const downloads: React.FC = () => {
const [queue, setQueue] = useAtom(queueAtom);
const { removeProcess, downloadedFiles } = useDownload();
const router = useRouter();
const [settings] = useSettings();
const movies = useMemo(() => {
try {
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
} catch {
migration_20241124();
return [];
}
}, [downloadedFiles]);
const movies = useMemo(
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
[downloadedFiles]
);
const groupedBySeries = useMemo(() => {
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 [];
}
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);
}, [downloadedFiles]);
const insets = useSafeAreaInsets();
@@ -108,20 +98,17 @@ export default function page() {
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{movies?.map((item) => (
<View className="mb-2 last:mb-0" key={item.item.Id}>
<MovieCard item={item.item} />
{movies?.map((item: BaseItemDto) => (
<View className="mb-2 last:mb-0" key={item.Id}>
<MovieCard item={item} />
</View>
))}
</View>
</ScrollView>
</View>
)}
{groupedBySeries?.map((items, index) => (
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
<SeriesCard items={items} key={items[0].SeriesId} />
))}
{downloadedFiles?.length === 0 && (
<View className="flex px-4">
@@ -131,24 +118,6 @@ export default function page() {
</View>
</ScrollView>
);
}
};
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(),
},
]
);
}
export default downloads;

View File

@@ -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 { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { TAB_HEIGHT } from "@/constants/Values";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
@@ -25,10 +25,11 @@ import {
import NetInfo from "@react-native-community/netinfo";
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigation, useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Platform,
RefreshControl,
ScrollView,
TouchableOpacity,
@@ -53,6 +54,7 @@ type MediaListSection = {
type Section = ScrollingCollectionListSection | MediaListSection;
export default function index() {
const queryClient = useQueryClient();
const router = useRouter();
const api = useAtomValue(apiAtom);
@@ -67,8 +69,6 @@ export default function index() {
const { downloadedFiles } = useDownload();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
useEffect(() => {
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
navigation.setOptions({
@@ -165,13 +165,28 @@ export default function index() {
);
}, [userViews]);
const invalidateCache = useInvalidatePlaybackProgressCache();
const refetch = useCallback(async () => {
setLoading(true);
await invalidateCache();
await queryClient.invalidateQueries({
queryKey: ["home"],
refetchType: "all",
type: "all",
exact: false,
});
await queryClient.invalidateQueries({
queryKey: ["home"],
refetchType: "all",
type: "all",
exact: false,
});
await queryClient.invalidateQueries({
queryKey: ["item"],
refetchType: "all",
type: "all",
exact: false,
});
setLoading(false);
}, []);
}, [queryClient]);
const createCollectionConfig = useCallback(
(
@@ -227,7 +242,7 @@ export default function index() {
const ss: Section[] = [
{
title: "Continue Watching",
queryKey: ["home", "resumeItems"],
queryKey: ["home", "resumeItems", user.Id],
queryFn: async () =>
(
await getItemsApi(api).getResumeItems({
@@ -241,7 +256,7 @@ export default function index() {
},
{
title: "Next Up",
queryKey: ["home", "nextUp-all"],
queryKey: ["home", "nextUp-all", user?.Id],
queryFn: async () =>
(
await getTvShowsApi(api).getNextUp({
@@ -347,6 +362,8 @@ export default function index() {
);
}
const insets = useSafeAreaInsets();
if (e1 || e2)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
@@ -371,11 +388,15 @@ export default function index() {
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
key={"home"}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
style={{
marginBottom: TAB_HEIGHT,
}}
>
<View className="flex flex-col space-y-4">
<LargeMovieCarousel />

View File

@@ -0,0 +1,61 @@
import { ScrollView, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { LANGUAGES } from "@/constants/Languages";
import { ListItem } from "@/components/list/ListItem";
import { ListSection } from "@/components/list/ListSection";
import { TAB_HEIGHT } from "@/constants/Values";
import { DefaultLanguageOption, useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { Colors } from "@/constants/Colors";
interface Props extends ViewProps {}
export default function page() {
const insets = useSafeAreaInsets();
const [settings, updateSettings] = useSettings();
return (
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
style={{
marginBottom: TAB_HEIGHT,
}}
>
<View className="py-4 px-4">
<ListSection title="LANGUAGES">
{LANGUAGES.sort(sortByName).map((l) => (
<ListItem
key={l.value}
title={l.label}
onPress={() => {
updateSettings({
...settings,
defaultAudioLanguage: l,
});
}}
iconAfter={
settings?.defaultAudioLanguage?.value === l.value ? (
<Ionicons name="checkmark" size={24} color={Colors.primary} />
) : null
}
/>
))}
</ListSection>
</View>
</ScrollView>
);
}
const sortByName = (a: DefaultLanguageOption, b: DefaultLanguageOption) => {
if (a.label < b.label) {
return -1;
}
if (a.label > b.label) {
return 1;
}
return 0;
};

View File

@@ -1,13 +1,18 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { ListItem } from "@/components/ListItem";
import { ListInputItem } from "@/components/list/ListInputItem";
import { ListItem } from "@/components/list/ListItem";
import { ListSection } from "@/components/list/ListSection";
import { SettingToggles } from "@/components/settings/SettingToggles";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { clearLogs, readFromLog } from "@/utils/log";
import { Ionicons } from "@expo/vector-icons";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { Alert, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -16,6 +21,7 @@ import { toast } from "sonner-native";
export default function settings() {
const { logout } = useJellyfin();
const { deleteAllFiles } = useDownload();
const [settings, updateSettings] = useSettings();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -57,6 +63,8 @@ export default function settings() {
);
};
const router = useRouter();
return (
<ScrollView
contentContainerStyle={{
@@ -73,15 +81,46 @@ export default function settings() {
>
registerBackgroundFetchAsync
</Button> */}
<View>
<Text className="font-bold text-lg mb-2">User Info</Text>
<ListSection title="USER INFO">
<ListItem title="User" text={user?.Name} />
<ListItem title="Server" text={api?.basePath} />
<ListItem title="Token" text={api?.accessToken} />
</ListSection>
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
<ListItem title="User" subTitle={user?.Name} />
<ListItem title="Server" subTitle={api?.basePath} />
<ListItem title="Token" subTitle={api?.accessToken} />
</View>
</View>
<ListSection title="MEDIA">
<ListItem
title="Audio language"
iconAfter={
<Ionicons name="chevron-forward" size={20} color="white" />
}
onPress={() => router.push("/settings/audio-language")}
/>
<ListItem
title="Subtitle language"
iconAfter={
<Ionicons name="chevron-forward" size={20} color="white" />
}
onPress={() => router.push("/settings/subtitle-language")}
/>
<ListInputItem
textInputProps={{
placeholder: "30",
clearButtonMode: "never",
returnKeyType: "done",
}}
defaultValue={(settings?.forwardSkipTime || "").toString()}
title={"Forward skip"}
onChange={(val) => {
// 1. validate positive number
// 2. save settings
if (val.length === 0) return;
if (val.match(/^\d+$/)) {
} else {
toast.error("Invalid number");
}
}}
/>
</ListSection>
<View>
<Text className="font-bold text-lg mb-2">Quick connect</Text>

View File

@@ -0,0 +1,61 @@
import { ScrollView, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { LANGUAGES } from "@/constants/Languages";
import { ListItem } from "@/components/list/ListItem";
import { ListSection } from "@/components/list/ListSection";
import { TAB_HEIGHT } from "@/constants/Values";
import { DefaultLanguageOption, useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { Colors } from "@/constants/Colors";
interface Props extends ViewProps {}
export default function page() {
const insets = useSafeAreaInsets();
const [settings, updateSettings] = useSettings();
return (
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
style={{
marginBottom: TAB_HEIGHT,
}}
>
<View className="py-4 px-4">
<ListSection title="LANGUAGES">
{LANGUAGES.sort(sortByName).map((l) => (
<ListItem
key={l.value}
title={l.label}
onPress={() => {
updateSettings({
...settings,
defaultSubtitleLanguage: l,
});
}}
iconAfter={
settings?.defaultSubtitleLanguage?.value === l.value ? (
<Ionicons name="checkmark" size={24} color={Colors.primary} />
) : null
}
/>
))}
</ListSection>
</View>
</ScrollView>
);
}
const sortByName = (a: DefaultLanguageOption, b: DefaultLanguageOption) => {
if (a.label < b.label) {
return -1;
}
if (a.label > b.label) {
return 1;
}
return 0;
};

View File

@@ -2,10 +2,6 @@ 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";
@@ -26,18 +22,16 @@ const Page: React.FC = () => {
const { data: item, isError } = useQuery({
queryKey: ["item", id],
queryFn: async () => {
if (!api || !user || !id) return;
const res = await getUserLibraryApi(api).getItem({
itemId: id,
const res = await getUserItemData({
api,
userId: user?.Id,
itemId: id,
});
return res.data;
return res;
},
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
enabled: !!id && !!api,
staleTime: 60 * 1000 * 5, // 5 minutes
});
const opacity = useSharedValue(1);

View File

@@ -1,8 +1,12 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router";
import {
useFocusEffect,
useLocalSearchParams,
useNavigation,
} from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react";
import React, { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
import { FlatList, useWindowDimensions, View } from "react-native";
import { Text } from "@/components/common/Text";
@@ -12,7 +16,6 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
genreFilterAtom,
@@ -29,6 +32,7 @@ import {
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import { orientationAtom } from "@/utils/atoms/orientation";
import {
BaseItemDto,
BaseItemDtoQueryResult,
@@ -56,13 +60,12 @@ const Page = () => {
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [sortBy, _setSortBy] = useAtom(sortByAtom);
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
const [orientation] = useAtom(orientationAtom);
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
const [sortOrderPreference, setOderByPreference] = useAtom(
sortOrderPreferenceAtom
);
const { orientation } = useOrientation();
useEffect(() => {
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
if (sop) {
@@ -103,12 +106,11 @@ const Page = () => {
[libraryId, sortOrderPreference]
);
const nrOfCols = useMemo(() => {
if (screenWidth < 300) return 2;
if (screenWidth < 500) return 3;
if (screenWidth < 800) return 5;
if (screenWidth < 1000) return 6;
if (screenWidth < 1500) return 7;
const getNumberOfColumns = useCallback(() => {
if (orientation === ScreenOrientation.Orientation.PORTRAIT_UP) return 3;
if (screenWidth < 600) return 5;
if (screenWidth < 960) return 6;
if (screenWidth < 1280) return 7;
return 6;
}, [screenWidth, orientation]);
@@ -217,7 +219,7 @@ const Page = () => {
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<TouchableItemRouter
<MemoizedTouchableItemRouter
key={item.Id}
style={{
width: "100%",
@@ -228,10 +230,10 @@ const Page = () => {
<View
style={{
alignSelf:
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP
? index % nrOfCols === 0
orientation === ScreenOrientation.Orientation.PORTRAIT_UP
? index % 3 === 0
? "flex-end"
: (index + 1) % nrOfCols === 0
: (index + 1) % 3 === 0
? "flex-start"
: "center"
: "center",
@@ -242,7 +244,7 @@ const Page = () => {
<ItemPoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
</MemoizedTouchableItemRouter>
),
[orientation]
);
@@ -427,7 +429,6 @@ const Page = () => {
return (
<FlashList
key={orientation}
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">No results</Text>
@@ -436,10 +437,10 @@ const Page = () => {
contentInsetAdjustmentBehavior="automatic"
data={flatData}
renderItem={renderItem}
extraData={[orientation, nrOfCols]}
extraData={orientation}
keyExtractor={keyExtractor}
estimatedItemSize={244}
numColumns={nrOfCols}
numColumns={getNumberOfColumns()}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();

View File

@@ -1,3 +1,4 @@
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
@@ -7,6 +8,7 @@ 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";
@@ -70,43 +72,37 @@ export default function search() {
types: BaseItemKind[];
query: string;
}): Promise<BaseItemDto[]> => {
if (!api || !query) return [];
if (!api) return [];
try {
if (searchEngine === "Jellyfin") {
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: query,
limit: 10,
includeItemTypes: types,
});
if (searchEngine === "Jellyfin") {
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: query,
limit: 10,
includeItemTypes: types,
});
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
} else {
if (!settings?.marlinServerUrl) return [];
const url = `${
settings.marlinServerUrl
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
.map((type) => encodeURIComponent(type))
.join("&includeItemTypes=")}`;
return searchApi.data.SearchHints as BaseItemDto[];
} else {
const url = `${settings?.marlinServerUrl}/search?q=${encodeURIComponent(
query
)}&includeItemTypes=${types
.map((type) => encodeURIComponent(type))
.join("&includeItemTypes=")}`;
const response1 = await axios.get(url);
const ids = response1.data.ids;
const response1 = await axios.get(url);
const ids = response1.data.ids;
if (!ids || !ids.length) return [];
if (!ids || !ids.length) return [];
const response2 = await getItemsApi(api).getItems({
ids,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
});
const response2 = await getItemsApi(api).getItems({
ids,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
});
return (response2.data.Items as BaseItemDto[]) || [];
}
} catch (error) {
console.error("Error during search:", error);
return []; // Ensure an empty array is returned in case of an error
return response2.data.Items as BaseItemDto[];
}
},
[api, searchEngine, settings]
[api, settings]
);
const navigation = useNavigation();
@@ -230,6 +226,10 @@ export default function search() {
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
style={{
marginBottom: TAB_HEIGHT,
}}
>
<View className="flex flex-col pt-2">

View File

@@ -1,77 +1,87 @@
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 { TabBarIcon } from "@/components/navigation/TabBarIcon";
import { Colors } from "@/constants/Colors";
import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { SystemBars } from "react-native-edge-to-edge";
export const NativeTabs = withLayoutContext<
BottomTabNavigationOptions,
typeof Navigator,
TabNavigationState<ParamListBase>,
NativeBottomTabNavigationEventMap
>(Navigator);
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";
export default function TabLayout() {
useEffect(() => {
if (Platform.OS === "android") {
NavigationBar.setBackgroundColorAsync("#121212");
NavigationBar.setBorderColorAsync("#121212");
}
}, []);
return (
<>
<SystemBars hidden={false} style="light" />
<NativeTabs
sidebarAdaptable
ignoresTopSafeArea
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
tabBarActiveTintColor={Colors.primary}
scrollEdgeAppearance="default"
>
<NativeTabs.Screen redirect name="index" />
<NativeTabs.Screen
name="(home)"
options={{
title: "Home",
tabBarIcon:
Platform.OS == "android"
? ({ color, focused, size }) =>
require("@/assets/icons/house.fill.png")
: () => ({ sfSymbol: "house" }),
}}
/>
<NativeTabs.Screen
name="(search)"
options={{
title: "Search",
tabBarIcon:
Platform.OS == "android"
? ({ color, focused, size }) =>
require("@/assets/icons/magnifyingglass.png")
: () => ({ sfSymbol: "magnifyingglass" }),
}}
/>
<NativeTabs.Screen
name="(libraries)"
options={{
title: "Library",
tabBarIcon:
Platform.OS == "android"
? ({ color, focused, size }) =>
require("@/assets/icons/server.rack.png")
: () => ({ sfSymbol: "rectangle.stack" }),
}}
/>
</NativeTabs>
</>
<Tabs
initialRouteName="home"
screenOptions={{
tabBarActiveTintColor: Colors.tabIconSelected,
headerShown: false,
tabBarStyle: {
position: "absolute",
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
borderTopWidth: 0,
paddingTop: 8,
paddingBottom: Platform.OS === "android" ? 8 : 26,
height: Platform.OS === "android" ? 58 : 74,
},
tabBarBackground: () =>
Platform.OS === "ios" ? (
<BlurView
experimentalBlurMethod="dimezisBlurView"
intensity={95}
style={{
...StyleSheet.absoluteFillObject,
overflow: "hidden",
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
backgroundColor: "black",
}}
/>
) : undefined,
}}
>
<Tabs.Screen redirect name="index" />
<Tabs.Screen
name="(home)"
options={{
headerShown: false,
title: "Home",
tabBarIcon: ({ color, focused }) => (
<TabBarIcon
name={focused ? "home" : "home-outline"}
color={color}
/>
),
}}
/>
<Tabs.Screen
name="(search)"
options={{
headerShown: false,
title: "Search",
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name={focused ? "search" : "search"} color={color} />
),
}}
/>
<Tabs.Screen
name="(libraries)"
options={{
headerShown: false,
title: "Library",
tabBarIcon: ({ color, focused }) => (
<TabBarIcon
name={focused ? "apps" : "apps-outline"}
color={color}
/>
),
}}
/>
</Tabs>
);
}

308
app/(auth)/play-music.tsx Normal file
View File

@@ -0,0 +1,308 @@
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<VideoRef | null>(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 (
<View
style={{
width: screenDimensions.width,
height: screenDimensions.height,
position: "relative",
}}
className="flex flex-col items-center justify-center"
>
<StatusBar hidden />
<View className="h-screen w-screen top-0 left-0 flex flex-col items-center justify-center p-4 absolute z-0">
<Image
source={poster}
style={{ width: "100%", height: "100%", resizeMode: "contain" }}
/>
</View>
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
className="absolute z-0 h-full w-full opacity-0"
>
<Video
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={() => {}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
progressUpdateInterval={500}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
setIsPlaying(state.isPlaying);
}}
/>
</Pressable>
<Controls
item={playSettings.item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
enableTrickplay={false}
/>
</View>
);
}
export function usePoster(
playSettings: PlaybackType | null,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!playSettings?.item || !api) return undefined;
return playSettings.item.Type === "Audio"
? `${api.basePath}/Items/${playSettings.item.AlbumId}/Images/Primary?tag=${playSettings.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: playSettings.item,
quality: 70,
width: 200,
});
}, [playSettings?.item, api]);
return poster ?? undefined;
}
export function useVideoSource(
playSettings: PlaybackType | null,
api: Api | null,
poster: string | undefined,
playUrl?: string | null
) {
const videoSource = useMemo(() => {
if (!playSettings || !api || !playUrl) {
return null;
}
const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks
? Math.round(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;
}

View File

@@ -0,0 +1,180 @@
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 { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { Api } from "@jellyfin/sdk";
import * as Haptics from "expo-haptics";
import * as NavigationBar from "expo-navigation-bar";
import { useFocusEffect } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtomValue } from "jotai";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { Dimensions, Platform, 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 } = usePlaySettings();
const api = useAtomValue(apiAtom);
const videoRef = useRef<VideoRef | null>(null);
const videoSource = useVideoSource(playSettings, api, playUrl);
const firstTime = useRef(true);
const screenDimensions = Dimensions.get("screen");
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 { orientation } = useOrientation();
useOrientationSettings();
useAndroidNavigationBar();
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 (
<View
style={{
width: screenDimensions.width,
height: screenDimensions.height,
position: "relative",
}}
className="flex flex-col items-center justify-center"
>
<StatusBar hidden />
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
className="absolute z-0 h-full w-full"
>
<Video
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={() => {}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
}}
/>
</Pressable>
<Controls
item={playSettings.item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
/>
</View>
);
}
export function 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;
}

332
app/(auth)/play-video.tsx Normal file
View File

@@ -0,0 +1,332 @@
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 { Dimensions, Pressable, StatusBar, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import Video, {
OnProgressData,
VideoRef,
SelectedTrack,
SelectedTrackType,
} from "react-native-video";
import { WithDefault } from "react-native/Libraries/Types/CodegenTypes";
export default function page() {
const { playSettings, playUrl, playSessionId } = usePlaySettings();
const api = useAtomValue(apiAtom);
const [settings] = useSettings();
const videoRef = useRef<VideoRef | null>(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) => {
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])
);
const { orientation } = useOrientation();
useOrientationSettings();
useAndroidNavigationBar();
useWebSocket({
isPlaying: isPlaying,
pauseVideo: pause,
playVideo: play,
stopPlayback: stop,
});
const selectedSubtitleTrack = useMemo(() => {
const a = playSettings?.mediaSource?.MediaStreams?.find(
(s) => s.Index === playSettings.subtitleIndex
);
console.log(a);
return a;
}, [playSettings]);
const [hlsSubTracks, setHlsSubTracks] = useState<
{
index: number;
language?: string | undefined;
selected?: boolean | undefined;
title?: string | undefined;
type: any;
}[]
>([]);
const selectedTextTrack = useMemo(() => {
for (let st of hlsSubTracks) {
if (st.title === selectedSubtitleTrack?.DisplayTitle) {
return {
type: SelectedTrackType.TITLE,
value: selectedSubtitleTrack?.DisplayTitle ?? "",
};
}
}
return undefined;
}, [hlsSubTracks]);
return (
<View
style={{
width: screenDimensions.width,
height: screenDimensions.height,
position: "relative",
}}
className="flex flex-col items-center justify-center"
>
<StatusBar hidden />
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
className="absolute z-0 h-full w-full"
>
<Video
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={() => {}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
progressUpdateInterval={500}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
}}
onTextTracks={(data) => {
console.log("onTextTracks ~", data);
setHlsSubTracks(data.textTracks as any);
}}
selectedTextTrack={selectedTextTrack}
/>
</Pressable>
<Controls
item={playSettings.item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
/>
</View>
);
}
export function usePoster(
playSettings: PlaybackType | null,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!playSettings?.item || !api) return undefined;
return playSettings.item.Type === "Audio"
? `${api.basePath}/Items/${playSettings.item.AlbumId}/Images/Primary?tag=${playSettings.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: playSettings.item,
quality: 70,
width: 200,
});
}, [playSettings?.item, api]);
return poster ?? undefined;
}
export function useVideoSource(
playSettings: PlaybackType | null,
api: Api | null,
poster: string | undefined,
playUrl?: string | null
) {
const videoSource = useMemo(() => {
if (!playSettings || !api || !playUrl) {
return null;
}
const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks
? Math.round(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;
}

View File

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

View File

@@ -1,525 +0,0 @@
import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useInvalidatePlaybackProgressCache } 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 } 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, BackHandler, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(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 = useInvalidatePlaybackProgressCache();
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]);
const backAction = () => {
videoRef.current?.stop();
return false;
};
useFocusEffect(
React.useCallback(() => {
const onBackPress = () => {
return backAction();
};
BackHandler.addEventListener("hardwareBackPress", onBackPress);
return async () => {
videoRef.current?.stop();
BackHandler.removeEventListener("hardwareBackPress", onBackPress);
};
}, [])
);
// 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 (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader />
</View>
);
if (isErrorItem || isErrorStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">Error</Text>
</View>
);
return (
<View style={{ flex: 1, backgroundColor: "black" }}>
<View
style={{
display: "flex",
width: "100%",
height: "100%",
position: "relative",
flexDirection: "column",
justifyContent: "center",
opacity: showControls ? 0.5 : 1,
}}
>
<VlcPlayerView
ref={videoRef}
source={{
uri: stream.url,
autoplay: true,
isNetwork: true,
startPosition,
externalTrack,
initOptions,
}}
style={{ width: "100%", height: "100%" }}
onVideoProgress={onProgress}
progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged}
onVideoLoadStart={() => {}}
onVideoLoadEnd={() => {
setIsVideoLoaded(true);
}}
onVideoError={(e) => {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
"Error",
"An error occurred while playing the video. Check logs in settings."
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
/>
<View
style={{
position: "absolute",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
opacity: isBuffering ? 1 : 0,
width: "100%",
height: "100%",
}}
pointerEvents="none"
>
<Loader />
</View>
</View>
{videoRef.current && (
<Controls
mediaSource={stream?.mediaSource}
item={item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded}
play={videoRef.current?.play}
pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo}
enableTrickplay={true}
getAudioTracks={videoRef.current?.getAudioTracks}
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
offline={false}
setSubtitleTrack={videoRef.current.setSubtitleTrack}
setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack}
stop={stop}
isVlc
/>
)}
</View>
);
}
export function usePoster(
item: BaseItemDto,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!item || !api) return undefined;
return item.Type === "Audio"
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: item,
quality: 70,
width: 200,
});
}, [item, api]);
return poster ?? undefined;
}

View File

@@ -1,420 +0,0 @@
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<VideoRef | null>(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 (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader />
</View>
);
if (isErrorItem || isErrorStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">Error</Text>
</View>
);
if (!item || !stream)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">Error</Text>
</View>
);
return (
<View
style={{
width: windowDimensions.width,
height: windowDimensions.height,
position: "relative",
}}
className="flex flex-col items-center justify-center"
>
<View className="h-screen w-screen top-0 left-0 flex flex-col items-center justify-center p-4 absolute z-0">
<Image
source={poster}
style={{ width: "100%", height: "100%", resizeMode: "contain" }}
/>
</View>
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
className="absolute z-0 h-full w-full opacity-0"
>
{videoSource && (
<Video
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={() => {}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
progressUpdateInterval={500}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
setIsPlaying(state.isPlaying);
}}
/>
)}
</Pressable>
<Controls
item={item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
enableTrickplay={false}
pause={pause}
play={play}
seek={seek}
isVlc={false}
stop={stop}
/>
</View>
);
}
export function usePoster(
item: BaseItemDto | null | undefined,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!item || !api) return undefined;
return item.Type === "Audio"
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: item,
quality: 70,
width: 200,
});
}, [item, api]);
return poster ?? undefined;
}
export function useVideoSource(
item: BaseItemDto | null | undefined,
api: Api | null,
poster: string | undefined,
url?: string | null
) {
const videoSource = useMemo(() => {
if (!item || !api || !url) {
return null;
}
const startPosition = item?.UserData?.PlaybackPositionTicks
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: item?.AlbumArtist ?? undefined,
title: item?.Name || "Unknown",
description: item?.Overview ?? undefined,
imageUri: poster,
subtitle: item?.Album ?? undefined,
},
};
}, [item, api, poster]);
return videoSource;
}

View File

@@ -1,590 +0,0 @@
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 { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
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 transcoding from "@/utils/profiles/transcoding";
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 { BackHandler, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import Video, {
OnProgressData,
SelectedTrack,
SelectedTrackType,
VideoRef,
} from "react-native-video";
const Player = () => {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [settings] = useSettings();
const videoRef = useRef<VideoRef | null>(null);
const firstTime = useRef(true);
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
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<TrackInfo[]>([]);
const [selectedAudioTrack, setSelectedAudioTrack] = useState<
SelectedTrack | undefined
>(undefined);
// Set intial Subtitle Track.
// We will only select external tracks if they are are text based. Else it should be burned in already.
const textSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle" && sub.IsTextSubtitleStream
) || [];
const 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,
}));
};
const backAction = () => {
videoRef.current?.pause();
return false;
};
useFocusEffect(
React.useCallback(() => {
const onBackPress = () => {
return backAction();
};
BackHandler.addEventListener("hardwareBackPress", onBackPress);
play();
return async () => {
videoRef.current?.pause();
BackHandler.removeEventListener("hardwareBackPress", onBackPress);
};
}, [])
);
if (isLoadingItem || isLoadingStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader />
</View>
);
if (isErrorItem || isErrorStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">Error</Text>
</View>
);
return (
<View style={{ flex: 1, backgroundColor: "black" }}>
<View
style={{
display: "flex",
width: "100%",
height: "100%",
position: "relative",
flexDirection: "column",
justifyContent: "center",
opacity: showControls ? 0.5 : 1,
}}
>
{videoSource ? (
<>
<Video
ref={videoRef}
source={videoSource}
style={{
height: "100%",
width: "100%",
}}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={(e) => {
console.error("Error playing video", e);
}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
progressUpdateInterval={500}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
}}
onTextTracks={(data) => {
setEmbededTextTracks(data.textTracks as any);
}}
onBuffer={(e) => {
setIsBuffering(e.isBuffering);
}}
onAudioTracks={(e) => {
console.log("onAudioTracks: ", e.audioTracks);
setAudioTracks(
e.audioTracks.map((t) => ({
index: t.index,
name: t.title ?? "",
language: t.language,
}))
);
}}
selectedTextTrack={selectedTextTrack}
selectedAudioTrack={selectedAudioTrack}
/>
<View
style={{
position: "absolute",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
opacity: isBuffering ? 1 : 0,
width: "100%",
height: "100%",
}}
pointerEvents="none"
>
<Loader />
</View>
</>
) : (
<Text>No video source...</Text>
)}
</View>
{item && (
<Controls
mediaSource={stream?.mediaSource}
videoRef={videoRef}
enableTrickplay={true}
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
seek={seek}
play={play}
pause={pause}
stop={stop}
getSubtitleTracks={getSubtitleTracks}
setSubtitleTrack={(i) => {
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,
});
}}
/>
)}
</View>
);
};
export function usePoster(
item: BaseItemDto | null | undefined,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!item || !api) return undefined;
return item.Type === "Audio"
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: item,
quality: 70,
width: 200,
});
}, [item, api]);
return poster ?? undefined;
}
export function useVideoSource(
item: BaseItemDto | null | undefined,
api: Api | null,
poster: string | undefined,
url?: string | null
) {
const videoSource = useMemo(() => {
if (!item || !api || !url) {
return null;
}
const startPosition = item?.UserData?.PlaybackPositionTicks
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: item?.AlbumArtist ?? undefined,
title: item?.Name || "Unknown",
description: item?.Overview ?? undefined,
imageUri: poster,
subtitle: item?.Album ?? undefined,
},
};
}, [item, api, poster, url]);
return videoSource;
}
export default Player;

View File

@@ -1,7 +1,7 @@
import { DownloadProvider } from "@/providers/DownloadProvider";
import {
getOrSetDeviceId,
getTokenFromStorage,
getTokenFromStoraage,
JellyfinProvider,
} from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
@@ -10,7 +10,6 @@ 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";
@@ -20,6 +19,7 @@ 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 { Appearance, AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { AppState } from "react-native";
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 = storage.getString("settings");
const settingsData = await AsyncStorage.getItem("settings");
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
@@ -96,13 +96,19 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
if (!settings?.autoDownload || !url)
return BackgroundFetch.BackgroundFetchResult.NoData;
const token = getTokenFromStorage();
const deviceId = getOrSetDeviceId();
const token = await getTokenFromStoraage();
const deviceId = await 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,
@@ -114,6 +120,14 @@ 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)) {
@@ -123,7 +137,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
download({
id: job.id,
url: downloadUrl,
url: url + "download/" + job.id,
destination: `${baseDirectory}${job.item.Id}.mp4`,
headers: {
Authorization: token,
@@ -177,7 +191,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
const checkAndRequestPermissions = async () => {
try {
const hasAskedBefore = storage.getString(
const hasAskedBefore = await AsyncStorage.getItem(
"hasAskedForNotificationPermission"
);
@@ -192,7 +206,7 @@ const checkAndRequestPermissions = async () => {
console.log("Notification permissions denied.");
}
storage.set("hasAskedForNotificationPermission", "true");
await AsyncStorage.setItem("hasAskedForNotificationPermission", "true");
} else {
console.log("Already asked for notification permissions before.");
}
@@ -217,8 +231,6 @@ export default function RootLayout() {
}
}, [loaded]);
Appearance.setColorScheme("dark");
if (!loaded) {
return null;
}
@@ -230,18 +242,6 @@ export default function RootLayout() {
);
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 0,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retryOnMount: true,
},
},
});
function Layout() {
const [settings, updateSettings] = useSettings();
const [orientation, setOrientation] = useAtom(orientationAtom);
@@ -249,6 +249,20 @@ function Layout() {
useKeepAwake();
useNotificationObserver();
const queryClientRef = useRef<QueryClient>(
new QueryClient({
defaultOptions: {
queries: {
staleTime: 0,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retryOnMount: true,
},
},
})
);
useEffect(() => {
checkAndRequestPermissions();
}, []);
@@ -305,14 +319,14 @@ function Layout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClient}>
<QueryClientProvider client={queryClientRef.current}>
<ActionSheetProvider>
<JobQueueProvider>
<JellyfinProvider>
<PlaySettingsProvider>
<DownloadProvider>
<BottomSheetModalProvider>
<SystemBars style="light" hidden={false} />
<StatusBar style="light" backgroundColor="#000" />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack.Screen
@@ -320,15 +334,33 @@ function Layout() {
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="(auth)/player"
name="(auth)/play-video"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
header: () => null,
animation: "fade",
}}
/>
<Stack.Screen
name="(auth)/play-offline-video"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="(auth)/play-music"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
@@ -363,9 +395,9 @@ function Layout() {
);
}
function saveDownloadedItemInfo(item: BaseItemDto) {
async function saveDownloadedItemInfo(item: BaseItemDto) {
try {
const downloadedItems = storage.getString("downloadedItems");
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
let items: BaseItemDto[] = downloadedItems
? JSON.parse(downloadedItems)
: [];
@@ -377,7 +409,7 @@ function saveDownloadedItemInfo(item: BaseItemDto) {
items.push(item);
}
storage.set("downloadedItems", JSON.stringify(items));
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
} catch (error) {
writeToLog("ERROR", "Failed to save downloaded item information:", error);
console.error("Failed to save downloaded item information:", error);

View File

@@ -128,9 +128,9 @@ const Login: React.FC = () => {
} catch (e) {
const error = e as Error;
if (error.name === "AbortError") {
console.error(`Request to ${protocol}${url} timed out`);
console.log(`Request to ${protocol}${url} timed out`);
} else {
console.error(`Error checking ${protocol}${url}:`, error);
console.log(`Error checking ${protocol}${url}:`, error);
}
}
}
@@ -286,7 +286,7 @@ const Login: React.FC = () => {
<SafeAreaView style={{ flex: 1 }}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1, height: "100%" }}
style={{ flex: 1 }}
>
<View className="flex flex-col h-full relative items-center justify-center w-full">
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

BIN
bun.lockb

Binary file not shown.

View File

@@ -5,9 +5,9 @@ import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo;
source: MediaSourceInfo;
onChange: (value: number) => void;
selected?: number | undefined;
selected?: number | null;
}
export const AudioTrackSelector: React.FC<Props> = ({
@@ -17,7 +17,7 @@ export const AudioTrackSelector: React.FC<Props> = ({
...props
}) => {
const audioStreams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
() => source.MediaStreams?.filter((x) => x.Type === "Audio"),
[source]
);

View File

@@ -6,6 +6,7 @@ import { useMemo } from "react";
export type Bitrate = {
key: string;
value: number | undefined;
height?: number;
};
export const BITRATES: Bitrate[] = [
@@ -26,14 +27,17 @@ 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));

View File

@@ -1,9 +1,8 @@
import { Feather } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import React, { useCallback, useEffect } from "react";
import React, { useEffect } from "react";
import { Platform, TouchableOpacity, ViewProps } from "react-native";
import GoogleCast, {
CastButton,
CastContext,
useCastDevice,
useDevices,
@@ -40,32 +39,18 @@ export const Chromecast: React.FC<Props> = ({
})();
}, [client, devices, castDevice, sessionManager, discoveryManager]);
// Android requires the cast button to be present for startDiscovery to work
const AndroidCastButton = useCallback(
() =>
Platform.OS === "android" ? (
<CastButton tintColor="transparent" />
) : (
<></>
),
[Platform.OS]
);
if (background === "transparent")
return (
<>
<TouchableOpacity
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
className="rounded-full h-10 w-10 flex items-center justify-center b"
{...props}
>
<Feather name="cast" size={22} color={"white"} />
</TouchableOpacity>
<AndroidCastButton />
</>
<TouchableOpacity
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
className="rounded-full h-10 w-10 flex items-center justify-center b"
{...props}
>
<Feather name="cast" size={22} color={"white"} />
</TouchableOpacity>
);
if (Platform.OS === "android")
@@ -97,7 +82,6 @@ export const Chromecast: React.FC<Props> = ({
>
<Feather name="cast" size={22} color={"white"} />
</BlurView>
<AndroidCastButton />
</TouchableOpacity>
);
};

View File

@@ -3,11 +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 { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import download from "@/utils/profiles/download";
import old from "@/utils/profiles/old";
import Ionicons from "@expo/vector-icons/Ionicons";
import {
BottomSheetBackdrop,
@@ -23,15 +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 { toast } from "sonner-native";
import { AudioTrackSelector } from "./AudioTrackSelector";
import { Bitrate, BitrateSelector } from "./BitrateSelector";
import { Bitrate, BITRATES, 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";
interface DownloadProps extends ViewProps {
item: BaseItemDto;
@@ -43,10 +43,10 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings();
const { processes, startBackgroundDownload } = useDownload();
const { startRemuxing } = useRemuxHlsToMp4();
const { startRemuxing } = useRemuxHlsToMp4(item);
const [selectedMediaSource, setSelectedMediaSource] = useState<
MediaSourceInfo | undefined | null
MediaSourceInfo | undefined
>(undefined);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
@@ -63,7 +63,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
getDefaultPlaySettings(item, settings);
// 4. Set states
setSelectedMediaSource(mediaSource ?? undefined);
setSelectedMediaSource(mediaSource);
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
@@ -99,36 +99,81 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
);
}
const res = await getStreamUrl({
api,
item,
startTimeTicks: 0,
userId: user?.Id,
audioStreamIndex: selectedAudioStream,
maxStreamingBitrate: maxBitrate.value,
mediaSourceId: selectedMediaSource.Id,
subtitleStreamIndex: selectedSubtitleStream,
deviceProfile: download,
});
let deviceProfile: any = iosFmp4;
if (!res) {
Alert.alert(
"Something went wrong",
"Could not get stream url from Jellyfin"
);
return;
if (settings?.deviceProfile === "Native") {
deviceProfile = native;
} else if (settings?.deviceProfile === "Old") {
deviceProfile = old;
}
const { mediaSource, url } = res;
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}"`,
},
}
);
if (!url || !mediaSource) throw new Error("No url");
let url: string | undefined = undefined;
let fileExtension: string | undefined | null = "mp4";
saveDownloadItemInfoToDiskTmp(item, mediaSource, 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");
if (settings?.downloadMethod === "optimized") {
return await startBackgroundDownload(url, item, mediaSource);
return await startBackgroundDownload(url, item, fileExtension);
} else {
return await startRemuxing(item, url, mediaSource);
return await startRemuxing(url);
}
}, [
api,
@@ -150,7 +195,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
const isDownloaded = useMemo(() => {
if (!downloadedFiles) return false;
return downloadedFiles.some((file) => file.item.Id === item.Id);
return downloadedFiles.some((file) => file.Id === item.Id);
}, [downloadedFiles, item.Id]);
const renderBackdrop = useCallback(

View File

@@ -12,8 +12,11 @@ export const GenreTags: React.FC<GenreTagsProps> = ({ genres }) => {
return (
<View className="flex flex-row flex-wrap mt-2">
{genres.map((genre, idx) => (
<View key={idx} className="bg-neutral-800 rounded-full px-2 py-1 mr-1">
{genres.map((genre) => (
<View
key={genre}
className="bg-neutral-800 rounded-full px-2 py-1 mr-1"
>
<Text className="text-xs">{genre}</Text>
</View>
))}

View File

@@ -11,21 +11,21 @@ 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 { useNavigation } from "expo-router";
import { useFocusEffect, useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Chromecast } from "./Chromecast";
@@ -33,51 +33,99 @@ 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,
});
}, [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);
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,
]);
useImageColors({ item });
useEffect(() => {
navigation.setOptions({
@@ -97,9 +145,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
}, [item]);
useEffect(() => {
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
// If landscape
if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
setHeaderHeight(230);
else if (item.Type === "Movie") setHeaderHeight(500);
return;
}
if (item.Type === "Movie") setHeaderHeight(500);
else setHeaderHeight(350);
}, [item.Type, orientation]);
@@ -109,7 +161,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
if (!selectedOptions) return null;
const insets = useSafeAreaInsets();
return (
<View
@@ -162,63 +214,34 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
<View className="flex flex-row items-center justify-start w-full h-16">
<BitrateSelector
className="mr-1"
onChange={(val) =>
setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val }
)
}
selected={selectedOptions.bitrate}
onChange={(val) => setMaxBitrate(val)}
selected={maxBitrate}
/>
<MediaSourceSelector
className="mr-1"
item={item}
onChange={(val) =>
setSelectedOptions(
(prev) =>
prev && {
...prev,
mediaSource: val,
}
)
}
selected={selectedOptions.mediaSource}
/>
<AudioTrackSelector
className="mr-1"
source={selectedOptions.mediaSource}
onChange={(val) => {
console.log(val);
setSelectedOptions(
(prev) =>
prev && {
...prev,
audioIndex: val,
}
);
}}
selected={selectedOptions.audioIndex}
/>
<SubtitleTrackSelector
source={selectedOptions.mediaSource}
onChange={(val) =>
setSelectedOptions(
(prev) =>
prev && {
...prev,
subtitleIndex: val,
}
)
}
selected={selectedOptions.subtitleIndex}
onChange={setSelectedMediaSource}
selected={selectedMediaSource}
/>
{selectedMediaSource && (
<>
<AudioTrackSelector
className="mr-1"
source={selectedMediaSource}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/>
<SubtitleTrackSelector
source={selectedMediaSource}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</>
)}
</View>
)}
<PlayButton
className="grow"
selectedOptions={selectedOptions}
item={item}
/>
<PlayButton item={item} url={playUrl} className="grow" />
</View>
{item.Type === "Episode" && (
@@ -232,10 +255,10 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
{item.People && item.People.length > 0 && (
<View className="mb-4">
{item.People.slice(0, 3).map((person, idx) => (
{item.People.slice(0, 3).map((person) => (
<MoreMoviesWithActor
currentItem={item}
key={idx}
key={person.Id}
actorId={person.Id!}
className="mb-4"
/>

View File

@@ -1,35 +0,0 @@
import { PropsWithChildren, ReactNode } from "react";
import { View, ViewProps } from "react-native";
import { Text } from "./common/Text";
interface Props extends ViewProps {
title?: string | null | undefined;
subTitle?: string | null | undefined;
children?: ReactNode;
iconAfter?: ReactNode;
}
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
title,
subTitle,
iconAfter,
children,
...props
}) => {
return (
<View
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
{...props}
>
<View className="flex flex-col overflow-visible">
<Text className="font-bold ">{title}</Text>
{subTitle && (
<Text uiTextView selectable className="text-xs">
{subTitle}
</Text>
)}
</View>
{iconAfter}
</View>
);
};

View File

@@ -26,9 +26,23 @@ export const MediaSourceSelector: React.FC<Props> = ({
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
(x) => x.Type === "Video"
)?.DisplayTitle || "",
[item, selected]
[item.MediaSources, 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 (
<View
className="flex shrink"
@@ -74,9 +88,3 @@ export const MediaSourceSelector: React.FC<Props> = ({
</View>
);
};
const name = (name?: string | null) => {
if (name && name.length > 40)
return name.substring(0, 20) + " [...] " + name.substring(name.length - 20);
return name;
};

View File

@@ -1,20 +1,15 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { apiAtom } 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";
import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react";
import { Alert, TouchableOpacity, View } from "react-native";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { Linking, TouchableOpacity, View } from "react-native";
import CastContext, {
CastButton,
PlayServicesState,
useMediaStatus,
useRemoteMediaClient,
@@ -30,65 +25,61 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent";
import { Text } from "./common/Text";
import { useRouter } from "expo-router";
import { useSettings } from "@/utils/atoms/settings";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
selectedOptions: SelectedOptions;
item?: BaseItemDto | null;
url?: string | null;
}
const ANIMATION_DURATION = 500;
const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({
item,
selectedOptions,
...props
}: Props) => {
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const mediaStatus = useMediaStatus();
const [colorAtom] = useAtom(itemThemeColorAtom);
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [api] = useAtom(apiAtom);
const router = useRouter();
const memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item
const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color
const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0);
const endColor = useSharedValue(colorAtom);
const startColor = useSharedValue(colorAtom);
const endColor = useSharedValue(memoizedColor);
const startColor = useSharedValue(memoizedColor);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const [settings] = useSettings();
const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => {
if (!bitrateValue) {
router.push(`/player/direct-player?${q}`);
return;
}
router.push(`/player/transcoding-player?${q}`);
},
[router]
);
const directStream = useMemo(() => {
return !url?.includes("m3u8");
}, [url]);
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();
const onPress = async () => {
if (!url || !item) {
console.warn(
"No URL or item provided to PlayButton",
url?.slice(0, 100),
item?.Id
);
return;
}
if (!client) {
goToPlayer(queryString, selectedOptions.bitrate?.value);
const vlcLink = "vlc://" + url;
if (vlcLink && settings?.openInVLC) {
Linking.openURL(vlcLink);
return;
}
router.push("/play-video");
return;
}
@@ -107,7 +98,7 @@ export const PlayButton: React.FC<Props> = ({
switch (selectedIndex) {
case 0:
await CastContext.getPlayServicesState().then(async (state) => {
await CastContext.getPlayServicesState().then((state) => {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
@@ -117,33 +108,10 @@ export const PlayButton: React.FC<Props> = ({
CastContext.showExpandedControls();
return;
}
// Get a new URL with the Chromecast device profile:
const data = await getStreamUrl({
api,
item,
deviceProfile: ios,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
"Client error",
"Could not create stream for Chromecast"
);
return;
}
client
.loadMedia({
mediaInfo: {
contentUrl: data?.url,
contentUrl: url,
contentType: "video/mp4",
metadata:
item.Type === "Episode"
@@ -209,38 +177,28 @@ export const PlayButton: React.FC<Props> = ({
});
break;
case 1:
goToPlayer(queryString, selectedOptions.bitrate?.value);
router.push("/play-video");
break;
case cancelButtonIndex:
break;
}
}
);
}, [
item,
client,
settings,
api,
user,
router,
showActionSheetWithOptions,
mediaStatus,
selectedOptions,
]);
};
const derivedTargetWidth = useDerivedValue(() => {
if (!item || !item.RunTimeTicks) return 0;
const userData = item.UserData;
if (!memoizedItem || !memoizedItem.RunTimeTicks) return 0;
const userData = memoizedItem.UserData;
if (userData && userData.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0
? Math.max(
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
(userData.PlaybackPositionTicks / memoizedItem.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH
)
: 0;
}
return 0;
}, [item]);
}, [memoizedItem]);
useAnimatedReaction(
() => derivedTargetWidth.value,
@@ -256,7 +214,7 @@ export const PlayButton: React.FC<Props> = ({
);
useAnimatedReaction(
() => colorAtom,
() => memoizedColor,
(newColor) => {
endColor.value = newColor;
colorChangeProgress.value = 0;
@@ -265,19 +223,19 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
[colorAtom]
[memoizedColor]
);
useEffect(() => {
const timeout_2 = setTimeout(() => {
startColor.value = colorAtom;
startColor.value = memoizedColor;
startWidth.value = targetWidth.value;
}, ANIMATION_DURATION);
return () => {
clearTimeout(timeout_2);
};
}, [colorAtom, item]);
}, [memoizedColor, memoizedItem]);
/**
* ANIMATED STYLES
@@ -320,11 +278,10 @@ export const PlayButton: React.FC<Props> = ({
return (
<View>
<TouchableOpacity
disabled={!item}
accessibilityLabel="Play button"
accessibilityHint="Tap to play the media"
onPress={onPress}
className={`relative`}
className="relative"
{...props}
>
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
@@ -361,7 +318,6 @@ export const PlayButton: React.FC<Props> = ({
{client && (
<Animated.Text style={animatedTextStyle}>
<Feather name="cast" size={22} />
<CastButton tintColor="transparent" />
</Animated.Text>
)}
{!client && settings?.openInVLC && (
@@ -376,7 +332,7 @@ export const PlayButton: React.FC<Props> = ({
</View>
</View>
</TouchableOpacity>
{/* <View className="mt-2 flex flex-row items-center">
<View className="mt-2 flex flex-row items-center">
<Ionicons
name="information-circle"
size={12}
@@ -386,7 +342,7 @@ export const PlayButton: React.FC<Props> = ({
<Text className="text-neutral-500 ml-1">
{directStream ? "Direct stream" : "Transcoded stream"}
</Text>
</View> */}
</View>
</View>
);
};

View File

@@ -41,6 +41,9 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
queryClient.invalidateQueries({
queryKey: ["seasons"],
});
queryClient.invalidateQueries({
queryKey: ["nextUp-all"],
});
queryClient.invalidateQueries({
queryKey: ["home"],
});

View File

@@ -6,9 +6,9 @@ import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo;
source: MediaSourceInfo;
onChange: (value: number) => void;
selected?: number | undefined;
selected?: number | null;
}
export const SubtitleTrackSelector: React.FC<Props> = ({
@@ -18,7 +18,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
...props
}) => {
const subtitleStreams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
[source]
);

View File

@@ -0,0 +1,22 @@
import { ScrollView, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { TAB_HEIGHT } from "@/constants/Values";
interface Props extends ViewProps {}
export default function page() {
const insets = useSafeAreaInsets();
return (
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
style={{
marginBottom: TAB_HEIGHT,
}}
></ScrollView>
);
}

View File

@@ -16,7 +16,6 @@ interface HorizontalScrollProps<T>
> {
data?: T[] | null;
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor?: (item: T, index: number) => string;
containerStyle?: ViewStyle;
contentContainerStyle?: ViewStyle;
loadingContainerStyle?: ViewStyle;
@@ -33,7 +32,6 @@ export const HorizontalScroll = forwardRef<
<T,>(
{
data = [],
keyExtractor,
renderItem,
containerStyle,
contentContainerStyle,
@@ -93,7 +91,6 @@ export const HorizontalScroll = forwardRef<
paddingHorizontal: 16,
...contentContainerStyle,
}}
keyExtractor={keyExtractor}
ListEmptyComponent={() => (
<View className="flex-1 justify-center items-center">
<Text className="text-center text-gray-500">
@@ -101,7 +98,6 @@ export const HorizontalScroll = forwardRef<
</Text>
</View>
)}
{...props}
/>
);
}

View File

@@ -26,7 +26,7 @@ import { storage } from "@/utils/mmkv";
interface Props extends ViewProps {}
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
const { processes } = useDownload();
const { processes, startDownload } = useDownload();
if (processes?.length === 0)
return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
@@ -85,7 +85,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
toast.success("Download canceled");
},
onError: (e) => {
console.error(e);
console.log(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, "tick");
return formatTimeString(timeLeft, true);
};
const base64Image = useMemo(() => {

View File

@@ -7,7 +7,7 @@ import {
useActionSheet,
} from "@expo/react-native-action-sheet";
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import { useFileOpener } 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<EpisodeCardProps> = ({ item }) => {
const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { openFile } = useFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
const base64Image = useMemo(() => {

View File

@@ -1,17 +1,20 @@
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 {
ActionSheetProvider,
useActionSheet,
} from "@expo/react-native-action-sheet";
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import { runtimeTicksToMinutes } from "@/utils/time";
import { Text } from "../common/Text";
import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { Ionicons } from "@expo/vector-icons";
import { ItemCardText } from "../ItemCardText";
interface MovieCardProps {
@@ -25,7 +28,7 @@ interface MovieCardProps {
*/
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { openFile } = useFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
const handleOpenFile = useCallback(() => {

View File

@@ -28,15 +28,11 @@ export const ScrollingCollectionList: React.FC<Props> = ({
queryKey,
...props
}) => {
// console.log(queryKey);
const { data, isLoading } = useQuery({
queryKey: queryKey,
queryKey,
queryFn,
enabled: !disabled,
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
});
if (disabled || !title) return null;

View File

@@ -11,9 +11,12 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { TouchableOpacityProps, View } from "react-native";
import { getColors } from "react-native-image-colors";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { useImageColors } from "@/hooks/useImageColors";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
interface Props extends TouchableOpacityProps {
library: BaseItemDto;
@@ -50,6 +53,10 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
[library]
);
// If we want to use image colors for library cards
// const [color] = useAtom(itemThemeColorAtom)
// useImageColors({ url });
const { data: itemsCount } = useQuery({
queryKey: ["library-count", library.Id],
queryFn: async () => {
@@ -61,7 +68,6 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
});
return response.data.TotalRecordCount;
},
staleTime: 1000 * 60 * 60,
});
if (!url) return null;

View File

@@ -0,0 +1,64 @@
import { PropsWithChildren, ReactNode, useEffect, useState } from "react";
import {
Pressable,
TextInput,
TextInputProps,
TouchableOpacity,
TouchableOpacityProps,
View,
ViewProps,
} from "react-native";
import { Text } from "../common/Text";
interface Props extends ViewProps {
title?: string | null | undefined;
text?: string | null | undefined;
children?: ReactNode;
iconAfter?: ReactNode;
iconBefore?: ReactNode;
textInputProps?: TextInputProps;
defaultValue?: string;
onChange: (text: string) => void;
}
export const ListInputItem: React.FC<PropsWithChildren<Props>> = ({
title,
text,
iconAfter,
iconBefore,
children,
onChange,
textInputProps,
defaultValue,
...props
}) => {
const [value, setValue] = useState<string>(defaultValue || "");
useEffect(() => {
onChange(value);
}, [value]);
return (
<View
className={`flex flex-row items-center justify-between px-4 h-12 bg-neutral-900`}
{...props}
>
{iconBefore && <View className="mr-2">{iconBefore}</View>}
<View>
<Text className="">{title}</Text>
</View>
<View className="ml-auto">
<TextInput
inputMode="numeric"
keyboardType="decimal-pad"
style={{ color: "white" }}
value={value}
onChangeText={setValue}
className=""
{...textInputProps}
/>
</View>
{iconAfter && <View className="ml-2">{iconAfter}</View>}
</View>
);
};

View File

@@ -0,0 +1,44 @@
import { PropsWithChildren, ReactNode, useState } from "react";
import {
Pressable,
TouchableOpacity,
TouchableOpacityProps,
View,
ViewProps,
} from "react-native";
import { Text } from "../common/Text";
interface Props extends TouchableOpacityProps {
title?: string | null | undefined;
text?: string | null | undefined;
children?: ReactNode;
iconAfter?: ReactNode;
iconBefore?: ReactNode;
}
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
title,
text,
iconAfter,
iconBefore,
children,
...props
}) => {
return (
<TouchableOpacity
className={`flex flex-row items-center justify-between px-4 h-12 bg-neutral-900`}
{...props}
>
{iconBefore && <View className="mr-2">{iconBefore}</View>}
<View>
<Text className="">{title}</Text>
</View>
<View className="ml-auto">
<Text selectable className="">
{text}
</Text>
</View>
{iconAfter && <View className="ml-2">{iconAfter}</View>}
</TouchableOpacity>
);
};

View File

@@ -0,0 +1,24 @@
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { Children, PropsWithChildren } from "react";
interface Props extends ViewProps {
title: string;
}
export const ListSection: React.FC<PropsWithChildren<Props>> = ({
children,
title,
...props
}) => {
return (
<View {...props}>
<Text className="ml-4 mb-1 text-xs text-neutral-500 uppercase">
{title}
</Text>
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800">
{children}
</View>
</View>
);
};

View File

@@ -103,7 +103,7 @@ export const SongsListItem: React.FC<Props> = ({
});
} else {
console.log("Playing on device", data.url, item.Id);
router.push("/music-player");
router.push("/play-music");
}
}, []);

View File

@@ -6,7 +6,7 @@ import {
} from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { useAtom } from "jotai";
import React, { useMemo } from "react";
import React from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
@@ -20,37 +20,24 @@ interface Props extends ViewProps {
export const CastAndCrew: React.FC<Props> = ({ 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 (
<View {...props} className="flex flex-col">
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
<HorizontalScroll
loading={loading}
keyExtractor={(i, idx) => i.Id.toString()}
height={247}
data={destinctPeople}
renderItem={(i) => (
data={item?.People || []}
renderItem={(item, index) => (
<TouchableOpacity
onPress={() => {
router.push(`/actors/${i.Id}`);
router.push(`/actors/${item.Id}`);
}}
key={item.Id}
className="flex flex-col w-28"
>
<Poster item={i} url={getPrimaryImageUrl({ api, item: i })} />
<Text className="mt-2">{i.Name}</Text>
<Text className="text-xs opacity-50">{i.Role}</Text>
<Poster item={item} url={getPrimaryImageUrl({ api, item })} />
<Text className="mt-2">{item.Name}</Text>
<Text className="text-xs opacity-50">{item.Role}</Text>
</TouchableOpacity>
)}
/>

View File

@@ -62,7 +62,7 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
if (settings?.autoDownload === true && !registered) {
registerBackgroundFetchAsync();
toast.success("Background downloads enabled");
toast.success("Background downlodas enabled");
} else if (settings?.autoDownload === false && registered) {
unregisterBackgroundFetchAsync();
toast.info("Background downloads disabled");
@@ -246,6 +246,22 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
</DropdownMenu.Root>
</View>
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col shrink">
<Text className="font-semibold">Use external player (VLC)</Text>
<Text className="text-xs opacity-50 shrink">
Open all videos in VLC instead of the default player. This
requries VLC to be installed on the phone.
</Text>
</View>
<Switch
value={settings.openInVLC}
onValueChange={(value) => {
updateSettings({ openInVLC: value, forceDirectPlay: value });
}}
/>
</View>
<View className="flex flex-col">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col">
@@ -316,6 +332,79 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
)}
</View>
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col shrink">
<Text className="font-semibold">Force direct play</Text>
<Text className="text-xs opacity-50 shrink">
This will always request direct play. This is good if you want
to try to stream movies you think the device supports.
</Text>
</View>
<Switch
value={settings.forceDirectPlay}
onValueChange={(value) =>
updateSettings({ forceDirectPlay: value })
}
/>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
${settings.forceDirectPlay ? "opacity-50 select-none" : ""}
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Device profile</Text>
<Text className="text-xs opacity-50">
A profile used for deciding what audio and video codecs the
device supports.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>{settings.deviceProfile}</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({ deviceProfile: "Expo" });
}}
>
<DropdownMenu.ItemTitle>Expo</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ deviceProfile: "Native" });
}}
>
<DropdownMenu.ItemTitle>Native</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="3"
onSelect={() => {
updateSettings({ deviceProfile: "Old" });
}}
>
<DropdownMenu.ItemTitle>Old</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="flex flex-col">
<View
className={`

View File

@@ -0,0 +1,529 @@
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { writeToLog } from "@/utils/log";
import { formatTimeString, ticksToSeconds } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Dimensions,
Platform,
Pressable,
TouchableOpacity,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import Animated, {
runOnJS,
SharedValue,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { VideoRef } from "react-native-video";
import { Text } from "../common/Text";
import { Loader } from "../Loader";
interface Props {
item: BaseItemDto;
videoRef: React.MutableRefObject<VideoRef | null>;
isPlaying: boolean;
isSeeking: SharedValue<boolean>;
cacheProgress: SharedValue<number>;
progress: SharedValue<number>;
isBuffering: boolean;
showControls: boolean;
ignoreSafeAreas?: boolean;
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
enableTrickplay?: boolean;
togglePlay: (ticks: number) => void;
setShowControls: (shown: boolean) => void;
}
export const Controls: React.FC<Props> = ({
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 op = useSharedValue<number>(1);
const tr = useSharedValue<number>(10);
const animatedStyles = useAnimatedStyle(() => {
return {
opacity: op.value,
};
});
const animatedTopStyles = useAnimatedStyle(() => {
return {
opacity: op.value,
transform: [
{
translateY: -tr.value,
},
],
};
});
const animatedBottomStyles = useAnimatedStyle(() => {
return {
opacity: op.value,
transform: [
{
translateY: tr.value,
},
],
};
});
useEffect(() => {
if (showControls || isBuffering) {
op.value = withTiming(1, { duration: 200 });
tr.value = withTiming(0, { duration: 200 });
} else {
op.value = withTiming(0, { duration: 200 });
tr.value = withTiming(10, { duration: 200 });
}
}, [showControls, isBuffering]);
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 updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
const current = ticksToSeconds(currentProgress);
const remaining = ticksToSeconds(maxValue - currentProgress);
setCurrentTime(current);
setRemainingTime(remaining);
},
[]
);
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]);
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 (
<View
style={[
{
position: "absolute",
top: 0,
left: 0,
width: windowDimensions.width,
height: windowDimensions.height,
},
]}
>
<View
style={[
{
position: "absolute",
bottom: insets.bottom + 97,
right: insets.right,
},
]}
className={`z-10 p-4
${showSkipButton ? "opacity-100" : "opacity-0"}
`}
>
<TouchableOpacity
onPress={skipIntro}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Intro</Text>
</TouchableOpacity>
</View>
<View
style={{
position: "absolute",
bottom: insets.bottom + 94,
right: insets.right,
height: 70,
}}
pointerEvents={showSkipCreditButton ? "auto" : "none"}
className={`z-10 p-4 ${
showSkipCreditButton ? "opacity-100" : "opacity-0"
}`}
>
<TouchableOpacity
onPress={skipCredit}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Credits</Text>
</TouchableOpacity>
</View>
<Pressable
onPress={() => {
toggleControls();
}}
>
<Animated.View
style={[
{
position: "absolute",
top: 0,
left: 0,
width: windowDimensions.width + 100,
height: windowDimensions.height + 100,
},
animatedStyles,
]}
className={`bg-black/50 z-0`}
></Animated.View>
</Pressable>
<View
style={{
position: "absolute",
top: 0,
left: 0,
width: windowDimensions.width,
height: windowDimensions.height,
}}
pointerEvents="none"
className={`flex flex-col items-center justify-center
${isBuffering ? "opacity-100" : "opacity-0"}
`}
>
<Loader />
</View>
<Animated.View
style={[
{
position: "absolute",
top: insets.top,
right: insets.right,
},
animatedTopStyles,
]}
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-row items-center space-x-2 z-10 p-4`}
>
<TouchableOpacity
onPress={toggleIgnoreSafeAreas}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeAreas ? "contract-outline" : "expand"}
size={24}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</Animated.View>
<Animated.View
style={[
{
position: "absolute",
width: windowDimensions.width - insets.left - insets.right,
maxHeight: windowDimensions.height,
left: insets.left,
bottom: Platform.OS === "ios" ? insets.bottom : insets.bottom,
},
animatedBottomStyles,
]}
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-col p-4 `}
>
<View className="shrink flex flex-col justify-center h-full mb-2">
<Text className="font-bold">{item?.Name}</Text>
{item?.Type === "Episode" && (
<Text className="opacity-50">{item.SeriesName}</Text>
)}
{item?.Type === "Movie" && (
<Text className="text-xs opacity-50">{item?.ProductionYear}</Text>
)}
{item?.Type === "Audio" && (
<Text className="text-xs opacity-50">{item?.Album}</Text>
)}
</View>
<View
className={`flex flex-col-reverse py-4 px-4 rounded-2xl items-center bg-neutral-800/90`}
>
<View className="flex flex-row items-center space-x-4">
<TouchableOpacity
style={{
opacity: !previousItem ? 0.5 : 1,
}}
onPress={goToPreviousItem}
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipBackward}>
<Ionicons
name="refresh-outline"
size={26}
color="white"
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
togglePlay(progress.value);
}}
>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={30}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipForward}>
<Ionicons name="refresh-outline" size={26} color="white" />
</TouchableOpacity>
<TouchableOpacity
style={{
opacity: !nextItem ? 0.5 : 1,
}}
onPress={goToNextItem}
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
</View>
<View className={`flex flex-col w-full shrink`}>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#000",
heartbeatColor: "#999",
}}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
return (
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
width: tileWidth,
height: tileHeight,
marginLeft: -tileWidth / 4,
marginTop: -tileHeight / 4 - 60,
zIndex: 10,
}}
className=" bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
}}
source={{ uri: url }}
contentFit="cover"
/>
</View>
);
}}
sliderHeight={10}
thumbWidth={0}
progress={progress}
minimumValue={min}
maximumValue={max}
/>
<View className="flex flex-row items-center justify-between mt-0.5">
<Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime)}
</Text>
<Text className="text-[12px] text-neutral-400">
-{formatTimeString(remainingTime)}
</Text>
</View>
</View>
</View>
</Animated.View>
</View>
);
};

View File

@@ -1,599 +0,0 @@
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 * as Haptics from "expo-haptics";
import DropdownViewDirect from "./dropdown/DropdownViewDirect";
import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
interface Props {
item: BaseItemDto;
videoRef: React.MutableRefObject<VlcPlayerViewRef | VideoRef | null>;
isPlaying: boolean;
isSeeking: SharedValue<boolean>;
cacheProgress: SharedValue<number>;
progress: SharedValue<number>;
isBuffering: boolean;
showControls: boolean;
ignoreSafeAreas?: boolean;
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
enableTrickplay?: boolean;
togglePlay: (ticks: number) => void;
setShowControls: (shown: boolean) => void;
offline?: boolean;
isVideoLoaded?: boolean;
mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void;
play: (() => Promise<void>) | (() => void);
pause: () => void;
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
getSubtitleTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
setSubtitleURL?: (url: string, customName: string) => void;
setSubtitleTrack?: (index: number) => void;
setAudioTrack?: (index: number) => void;
stop?: (() => Promise<void>) | (() => void);
isVlc?: boolean;
}
export const Controls: React.FC<Props> = ({
item,
seek,
play,
pause,
togglePlay,
isPlaying,
isSeeking,
progress,
isBuffering,
cacheProgress,
showControls,
setShowControls,
ignoreSafeAreas,
setIgnoreSafeAreas,
mediaSource,
isVideoLoaded,
getAudioTracks,
getSubtitleTracks,
setSubtitleURL,
setSubtitleTrack,
setAudioTrack,
stop,
offline = false,
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<number>(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 (
<ControlProvider
item={item}
mediaSource={mediaSource}
isVideoLoaded={isVideoLoaded}
>
<SafeAreaView
style={{
flex: 1,
position: "absolute",
top: insets.top,
left: insets.left,
right: insets.right,
bottom: insets.bottom,
}}
>
<VideoProvider
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
setAudioTrack={setAudioTrack}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
>
{!mediaSource?.TranscodingUrl ? (
<DropdownViewDirect showControls={showControls} />
) : (
<DropdownViewTranscoding showControls={showControls} />
)}
</VideoProvider>
<View
style={[
{
position: "absolute",
bottom: 97,
},
]}
className={`z-10 p-4
${showSkipButton ? "opacity-100" : "opacity-0"}
`}
>
<TouchableOpacity
onPress={skipIntro}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Intro</Text>
</TouchableOpacity>
</View>
<View
style={{
position: "absolute",
bottom: 94,
height: 70,
}}
pointerEvents={showSkipCreditButton ? "auto" : "none"}
className={`z-10 p-4 ${
showSkipCreditButton ? "opacity-100" : "opacity-0"
}`}
>
<TouchableOpacity
onPress={skipCredit}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Credits</Text>
</TouchableOpacity>
</View>
<Pressable
onPressIn={() => {
toggleControls();
}}
style={{
position: "absolute",
width: Dimensions.get("window").width,
height: Dimensions.get("window").height,
}}
></Pressable>
<View
style={[
{
position: "absolute",
top: 0,
right: 0,
opacity: showControls ? 1 : 0,
},
]}
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
>
{Platform.OS !== "ios" && (
<TouchableOpacity
onPress={toggleIgnoreSafeAreas}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeAreas ? "contract-outline" : "expand"}
size={24}
color="white"
/>
</TouchableOpacity>
)}
<TouchableOpacity
onPress={async () => {
if (stop) await stop();
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<View
style={[
{
position: "absolute",
right: 0,
left: 0,
bottom: 0,
opacity: showControls ? 1 : 0,
},
]}
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-col p-4`}
>
<View className="shrink flex flex-col justify-center h-full mb-2">
<Text className="font-bold">{item?.Name}</Text>
{item?.Type === "Episode" && (
<Text className="opacity-50">{item.SeriesName}</Text>
)}
{item?.Type === "Movie" && (
<Text className="text-xs opacity-50">{item?.ProductionYear}</Text>
)}
{item?.Type === "Audio" && (
<Text className="text-xs opacity-50">{item?.Album}</Text>
)}
</View>
<View
className={`flex flex-col-reverse py-4 px-4 rounded-2xl items-center bg-neutral-800`}
>
<View className="flex flex-row items-center space-x-4">
<TouchableOpacity
style={{
opacity: !previousItem ? 0.5 : 1,
}}
onPress={goToPreviousItem}
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipBackward}>
<Ionicons
name="refresh-outline"
size={26}
color="white"
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
togglePlay(progress.value);
}}
>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={30}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipForward}>
<Ionicons name="refresh-outline" size={26} color="white" />
</TouchableOpacity>
<TouchableOpacity
style={{
opacity: !nextItem ? 0.5 : 1,
}}
onPress={goToNextItem}
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
</View>
<View className={`flex flex-col w-full shrink`}>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#000",
heartbeatColor: "#999",
}}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
return (
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
width: tileWidth,
height: tileHeight,
marginLeft: -tileWidth / 4,
marginTop: -tileHeight / 4 - 60,
zIndex: 10,
}}
className=" bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
}}
source={{ uri: url }}
contentFit="cover"
/>
<Text
style={{
position: "absolute",
bottom: 5,
left: 5,
color: "white",
backgroundColor: "rgba(0, 0, 0, 0.5)",
padding: 5,
borderRadius: 5,
}}
>
{`${time.hours > 0 ? `${time.hours}:` : ""}${
time.minutes < 10 ? `0${time.minutes}` : time.minutes
}:${
time.seconds < 10 ? `0${time.seconds}` : time.seconds
}`}
</Text>
</View>
);
}}
sliderHeight={10}
thumbWidth={0}
progress={progress}
minimumValue={min}
maximumValue={max}
/>
<View className="flex flex-row items-center justify-between mt-0.5">
<Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
</Text>
<Text className="text-[12px] text-neutral-400">
-{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
</Text>
</View>
</View>
</View>
</View>
</SafeAreaView>
</ControlProvider>
);
};

View File

@@ -1,144 +0,0 @@
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<number>;
handleSliderStart: () => void;
handleSliderComplete: (value: number) => void;
progress: SharedValue<number>;
min: SharedValue<number>;
max: SharedValue<number>;
currentTime: number;
remainingTime: number;
item: BaseItemDto;
}
const SliderScrubber: React.FC<SliderScrubberProps> = ({
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 (
<View className={`flex flex-col w-full shrink`}>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#000",
heartbeatColor: "#999",
}}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
return (
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
width: tileWidth,
height: tileHeight,
marginLeft: -tileWidth / 4,
marginTop: -tileHeight / 4 - 60,
zIndex: 10,
}}
className=" bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
}}
source={{ uri: url }}
contentFit="cover"
/>
<Text
style={{
position: "absolute",
bottom: 5,
left: 5,
color: "white",
backgroundColor: "rgba(0, 0, 0, 0.5)",
padding: 5,
borderRadius: 5,
}}
>
{`${time.hours > 0 ? `${time.hours}:` : ""}${
time.minutes < 10 ? `0${time.minutes}` : time.minutes
}:${
time.seconds < 10 ? `0${time.seconds}` : time.seconds
}`}
</Text>
</View>
);
}}
sliderHeight={10}
thumbWidth={0}
progress={progress}
minimumValue={min}
maximumValue={max}
/>
<View className="flex flex-row items-center justify-between mt-0.5">
<Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime, "ms")}
</Text>
<Text className="text-[12px] text-neutral-400">
-{formatTimeString(remainingTime, "ms")}
</Text>
</View>
</View>
);
};
export default SliderScrubber;

View File

@@ -1,34 +0,0 @@
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<ControlContextProps | undefined>(undefined);
interface ControlProviderProps {
children: ReactNode;
item: BaseItemDto;
mediaSource: MediaSourceInfo | null | undefined;
isVideoLoaded: boolean | undefined;
}
export const ControlProvider: React.FC<ControlProviderProps> = ({ children, item, mediaSource, isVideoLoaded }) => {
return (
<ControlContext.Provider value={{ item, mediaSource, isVideoLoaded }}>
{children}
</ControlContext.Provider>
);
};
export const useControlContext = () => {
const context = useContext(ControlContext);
if (context === undefined) {
throw new Error('useControlContext must be used within a ControlProvider');
}
return context;
};

View File

@@ -1,98 +0,0 @@
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<VideoContextProps | undefined>(undefined);
interface VideoProviderProps {
children: ReactNode;
getAudioTracks:
| (() => Promise<TrackInfo[] | null>)
| (() => TrackInfo[])
| undefined;
getSubtitleTracks:
| (() => Promise<TrackInfo[] | null>)
| (() => TrackInfo[])
| undefined;
setAudioTrack: ((index: number) => void) | undefined;
setSubtitleTrack: ((index: number) => void) | undefined;
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
}
export const VideoProvider: React.FC<VideoProviderProps> = ({
children,
getSubtitleTracks,
getAudioTracks,
setSubtitleTrack,
setSubtitleURL,
setAudioTrack,
}) => {
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
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 (
<VideoContext.Provider
value={{
audioTracks,
subtitleTracks,
setSubtitleTrack,
setSubtitleURL,
setAudioTrack,
}}
>
{children}
</VideoContext.Provider>
);
};
export const useVideoContext = () => {
const context = useContext(VideoContext);
if (context === undefined) {
throw new Error("useVideoContext must be used within a VideoProvider");
}
return context;
};

View File

@@ -1,181 +0,0 @@
import React, { 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 } from "../types";
import { useAtomValue } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useLocalSearchParams } from "expo-router";
interface DropdownViewDirectProps {
showControls: boolean;
offline?: boolean; // used to disable external subs for downloads
}
const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
showControls,
offline = false,
}) => {
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 subs with external subs only if not offline
if (!offline) {
return [...embeddedSubs, ...externalSubs] as (
| EmbeddedSubtitle
| ExternalSubtitle
)[];
}
return embeddedSubs as EmbeddedSubtitle[];
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams, offline]);
const { subtitleIndex, audioIndex } = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
const [selectedSubtitleIndex, setSelectedSubtitleIndex] = useState<Number>(
parseInt(subtitleIndex)
);
const [selectedAudioIndex, setSelectedAudioIndex] = useState<Number>(
parseInt(audioIndex)
);
return (
<View
style={{
position: "absolute",
zIndex: 1000,
opacity: showControls ? 1 : 0,
}}
className="p-4"
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="subtitle-trigger">
Subtitle
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
<DropdownMenu.CheckboxItem
key={`subtitle-item-${idx}`}
value={selectedSubtitleIndex === sub.index}
onValueChange={() => {
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);
}}
>
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
{sub.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="audio-trigger">
Audio
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{audioTracks?.map((track, idx: number) => (
<DropdownMenu.CheckboxItem
key={`audio-item-${idx}`}
value={selectedAudioIndex === track.index}
onValueChange={() => {
setSelectedAudioIndex(track.index);
setAudioTrack && setAudioTrack(track.index);
}}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
{track.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};
export default DropdownViewDirect;

View File

@@ -1,272 +0,0 @@
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 { TranscodedSubtitle } from "../types";
import { useAtomValue } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useLocalSearchParams, useRouter } from "expo-router";
interface DropdownViewProps {
showControls: boolean;
offline?: boolean; // used to disable external subs for downloads
}
const DropdownView: React.FC<DropdownViewProps> = ({
showControls,
offline = false,
}) => {
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, setSubtitleTrack } = videoContext;
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)
);
let initialSubtitleIndex = -1;
if (!isOnTextSubtitle) {
initialSubtitleIndex = parseInt(subtitleIndex);
} else if (chosenSubtitle) {
initialSubtitleIndex = textBasedSubs.indexOf(chosenSubtitle);
}
const [selectedSubtitleIndex, setSelectedSubtitleIndex] =
useState<number>(initialSubtitleIndex);
const [selectedAudioIndex, setSelectedAudioIndex] = useState<number>(
parseInt(audioIndex)
);
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, currentSelectedSubtitleIndex: number) => {
let newSubtitleIndex: number;
if (!isOnTextSubtitle) {
newSubtitleIndex = parseInt(subtitleIndex);
} else if (
currentSelectedSubtitleIndex >= 0 &&
currentSelectedSubtitleIndex < textBasedSubs.length
) {
console.log("setHere SubtitleIndex", currentSelectedSubtitleIndex);
newSubtitleIndex = textBasedSubs[currentSelectedSubtitleIndex].Index!;
console.log("newSubtitleIndex", newSubtitleIndex);
} else {
newSubtitleIndex = -1;
}
const queryParams = new URLSearchParams({
itemId: item.Id ?? "", // Ensure itemId is a string
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: newSubtitleIndex?.toString() ?? "",
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue,
}).toString();
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
},
[mediaSource]
);
return (
<View
style={{
position: "absolute",
zIndex: 1000,
opacity: showControls ? 1 : 0,
}}
className="p-4"
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="subtitle-trigger">
Subtitle
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{allSubtitleTracksForTranscodingStream?.map(
(sub, idx: number) => (
<DropdownMenu.CheckboxItem
value={selectedSubtitleIndex === sub.index}
key={`subtitle-item-${idx}`}
onValueChange={() => {
console.log("sub", sub);
if (selectedSubtitleIndex === sub?.index) return;
setSelectedSubtitleIndex(sub.index);
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
setSubtitleTrack && setSubtitleTrack(sub.index);
return;
}
ChangeTranscodingSubtitle(sub.index);
}}
>
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
{sub.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
)
)}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="audio-trigger">
Audio
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{allAudio?.map((track, idx: number) => (
<DropdownMenu.CheckboxItem
key={`audio-item-${idx}`}
value={selectedAudioIndex === track.index}
onValueChange={() => {
if (selectedAudioIndex === track.index) return;
setSelectedAudioIndex(track.index);
ChangeTranscodingAudio(track.index, selectedSubtitleIndex);
}}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
{track.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};
export default DropdownView;

View File

@@ -1,19 +0,0 @@
type EmbeddedSubtitle = {
name: string;
index: number;
};
type ExternalSubtitle = {
name: string;
index: number;
isExternal: boolean;
deliveryUrl: string;
};
type TranscodedSubtitle = {
name: string;
index: number;
IsTextSubtitleStream: boolean;
};
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle };

View File

@@ -1,73 +0,0 @@
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<VlcPlayerViewRef>;
}
export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
null
);
useEffect(() => {
const fetchTracks = async () => {
if (playerRef.current) {
const audio = await playerRef.current.getAudioTracks();
const subtitles = await playerRef.current.getSubtitleTracks();
setAudioTracks(audio);
setSubtitleTracks(subtitles);
}
};
fetchTracks();
}, [playerRef]);
const insets = useSafeAreaInsets();
return (
<View
style={{
position: "absolute",
top: insets.top,
left: insets.left + 8,
zIndex: 100,
}}
{...props}
>
<Text className="font-bold">Playback State:</Text>
<Text className="font-bold mt-2.5">Audio Tracks:</Text>
{audioTracks &&
audioTracks.map((track, index) => (
<Text key={index}>
{track.name} (Index: {track.index})
</Text>
))}
<Text className="font-bold mt-2.5">Subtitle Tracks:</Text>
{subtitleTracks &&
subtitleTracks.map((track, index) => (
<Text key={index}>
{track.name} (Index: {track.index})
</Text>
))}
<TouchableOpacity
className="mt-2.5 bg-blue-500 p-2 rounded"
onPress={() => {
if (playerRef.current) {
playerRef.current.getAudioTracks().then(setAudioTracks);
playerRef.current.getSubtitleTracks().then(setSubtitleTracks);
}
}}
>
<Text className="text-white text-center">Refresh Tracks</Text>
</TouchableOpacity>
</View>
);
};

View File

@@ -22,13 +22,13 @@
}
},
"production": {
"channel": "0.21.0",
"channel": "0.17.0",
"android": {
"image": "latest"
}
},
"production-apk": {
"channel": "0.21.0",
"channel": "0.17.0",
"android": {
"buildType": "apk",
"image": "latest"

View File

@@ -1,15 +0,0 @@
--- 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

View File

@@ -18,6 +18,7 @@ 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 ||
@@ -25,6 +26,12 @@ 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;
}

View File

@@ -0,0 +1,17 @@
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");
};
}
}, []);
};

View File

@@ -4,7 +4,6 @@ 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: {
@@ -22,29 +21,16 @@ interface CreditTimestamps {
export const useCreditSkipper = (
itemId: string | undefined,
currentTime: number,
seek: (time: number) => void,
play: () => void,
isVlc: boolean = false
videoRef: React.RefObject<any>
) => {
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<CreditTimestamps | null>({
queryKey: ["creditTimestamps", itemId],
queryFn: async () => {
if (!itemId) {
console.log("No item id");
return null;
}
@@ -75,17 +61,17 @@ export const useCreditSkipper = (
}, [creditTimestamps, currentTime]);
const skipCredit = useCallback(() => {
if (!creditTimestamps) return;
console.log(`Skipping credits to ${creditTimestamps.Credits.End}`);
console.log("skipCredits");
if (!creditTimestamps || !videoRef.current) return;
try {
wrappedSeek(creditTimestamps.Credits.End);
videoRef.current.seek(creditTimestamps.Credits.End);
setTimeout(() => {
play();
videoRef.current?.resume();
}, 200);
} catch (error) {
writeToLog("ERROR", "Error skipping intro", error);
}
}, [creditTimestamps]);
}, [creditTimestamps, videoRef]);
return { showSkipCreditButton, skipCredit };
};

View File

@@ -1,54 +0,0 @@
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;

View File

@@ -1,3 +1,5 @@
// hooks/useFileOpener.ts
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { writeToLog } from "@/utils/log";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
@@ -5,44 +7,46 @@ import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router";
import { useCallback } from "react";
export const getDownloadedFileUrl = async (itemId: string): Promise<string> => {
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 = () => {
export const useFileOpener = () => {
const router = useRouter();
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
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 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);
}
},
[setOfflineSettings, setPlayUrl, router]
);
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);
}
}, []);
return { openFile };
};

View File

@@ -54,6 +54,7 @@ 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,

View File

@@ -1,11 +1,14 @@
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 storage
// Save the base64 string to AsyncStorage
storage.set(key, base64);
console.log("Image saved successfully");
} catch (error) {
console.error("Error saving image:", error);
throw error;
@@ -67,7 +70,7 @@ const useImageStorage = () => {
const loadImage = useCallback(async (key: string) => {
try {
// Retrieve the base64 string from storage
// Retrieve the base64 string from AsyncStorage
const base64Image = storage.getString(key);
if (base64Image !== null) {
// Set the loaded image state

View File

@@ -4,7 +4,6 @@ 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;
@@ -15,36 +14,19 @@ 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,
seek: (ticks: number) => void,
play: () => void,
isVlc: boolean = false
videoRef: React.RefObject<any>
) => {
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<IntroTimestamps | null>({
queryKey: ["introTimestamps", itemId],
queryFn: async () => {
if (!itemId) {
console.log("No item id");
return null;
}
@@ -76,16 +58,16 @@ export const useIntroSkipper = (
const skipIntro = useCallback(() => {
console.log("skipIntro");
if (!introTimestamps) return;
if (!introTimestamps || !videoRef.current) return;
try {
wrappedSeek(introTimestamps.IntroEnd);
videoRef.current.seek(introTimestamps.IntroEnd);
setTimeout(() => {
play();
videoRef.current?.resume();
}, 200);
} catch (error) {
writeToLog("ERROR", "Error skipping intro", error);
}
}, [introTimestamps]);
}, [introTimestamps, videoRef]);
return { showSkipButton, skipIntro };
};

View File

@@ -0,0 +1,27 @@
// 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]);
};

View File

@@ -24,5 +24,5 @@ export const useOrientation = () => {
};
}, []);
return { orientation, setOrientation };
return { orientation };
};

View File

@@ -1,19 +1,18 @@
import { useDownload } from "@/providers/DownloadProvider";
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 { 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";
/**
* Custom hook for remuxing HLS to MP4 using FFmpeg.
@@ -22,16 +21,23 @@ import useImageStorage from "./useImageStorage";
* @param item - The BaseItemDto object representing the media item
* @returns An object with remuxing-related functions
*/
export const useRemuxHlsToMp4 = () => {
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
const api = useAtomValue(apiAtom);
const queryClient = useQueryClient();
const { saveDownloadedItemInfo, setProcesses } = useDownload();
const router = useRouter();
const { saveImage } = useImageStorage();
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 startRemuxing = useCallback(
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
async (url: string) => {
if (!api) throw new Error("API is not defined");
if (!item.Id) throw new Error("Item must have an Id");
@@ -55,7 +61,7 @@ export const useRemuxHlsToMp4 = () => {
},
});
const command = `-y -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -map 0:v -map 0:a -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
writeToLog(
"INFO",
@@ -69,13 +75,13 @@ export const useRemuxHlsToMp4 = () => {
id: "",
deviceId: "",
inputUrl: "",
item: item,
itemId: item.Id!,
item,
itemId: item.Id,
outputPath: "",
progress: 0,
status: "downloading",
timestamp: new Date(),
},
} as JobStatus,
]);
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
@@ -111,47 +117,29 @@ export const useRemuxHlsToMp4 = () => {
FFmpegKit.executeAsync(command, async (session) => {
try {
const returnCode = await session.getReturnCode();
const startTime = new Date();
let endTime;
if (returnCode.isValueSuccess()) {
endTime = new Date();
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${
item.Name
}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${
(endTime.getTime() - startTime.getTime()) / 1000
}s`
);
if (!item) throw new Error("Item is undefined");
await saveDownloadedItemInfo(item);
toast.success("Download completed");
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`
);
await queryClient.invalidateQueries({
queryKey: ["downloadedItems"],
});
resolve();
} else if (returnCode.isValueError()) {
endTime = new Date();
const allLogs = session.getAllLogsAsString();
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${
item.Name
}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${
(endTime.getTime() - startTime.getTime()) / 1000
}s. All logs: ${allLogs}`
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
);
reject(new Error("Remuxing failed"));
reject(new Error("Remuxing failed")); // Reject the promise on error
} else if (returnCode.isValueCancel()) {
endTime = new Date();
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${
item.Name
}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${
(endTime.getTime() - startTime.getTime()) / 1000
}s`
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`
);
resolve();
}
@@ -159,24 +147,16 @@ export const useRemuxHlsToMp4 = () => {
setProcesses((prev) => {
return prev.filter((process) => process.itemId !== item.Id);
});
} catch (e) {
const error = e as Error;
const errorLog = `Error: ${error.message}, Stack: ${error.stack}`;
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ Exception during remuxing for item: ${item.Name}, ${errorLog}`
);
} catch (error) {
reject(error);
}
});
});
} catch (e) {
const error = e as Error;
const errorLog = `Error: ${error.message}, Stack: ${error.stack}`;
} catch (error) {
console.error("Failed to remux:", error);
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}, ${errorLog}`
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
);
setProcesses((prev) => {
return prev.filter((process) => process.itemId !== item.Id);
@@ -184,13 +164,15 @@ export const useRemuxHlsToMp4 = () => {
throw error; // Re-throw the error to propagate it to the caller
}
},
[]
[output, item]
);
const cancelRemuxing = useCallback(() => {
FFmpegKit.cancel();
setProcesses([]);
}, []);
setProcesses((prev) => {
return prev.filter((process) => process.itemId !== item.Id);
});
}, [item.Name]);
return { startRemuxing, cancelRemuxing };
};

View File

@@ -1,29 +0,0 @@
import { useQueryClient } from "@tanstack/react-query";
/**
* useRevalidatePlaybackProgressCache invalidates queries related to playback progress.
*/
export function useInvalidatePlaybackProgressCache() {
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;
}

View File

@@ -1,7 +1,6 @@
// 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";
@@ -58,7 +57,6 @@ export const useTrickplay = (item: BaseItemDto, enabled = true) => {
: null;
}, [item, enabled]);
// Takes in ticks.
const calculateTrickplayUrl = useCallback(
(progress: number) => {
if (!enabled) {
@@ -76,33 +74,28 @@ export const useTrickplay = (item: BaseItemDto, enabled = true) => {
}
const { data, resolution } = trickplayInfo;
const { Interval, TileWidth, TileHeight, Width, Height } = data;
const { Interval, TileWidth, TileHeight } = data;
if (
!Interval ||
!TileWidth ||
!TileHeight ||
!resolution ||
!Width ||
!Height
) {
if (!Interval || !TileWidth || !TileHeight || !resolution) {
throw new Error("Invalid trickplay data");
}
const currentTimeMs = Math.max(0, ticksToMs(progress));
const currentTile = Math.floor(currentTimeMs / Interval);
const currentSecond = Math.max(0, Math.floor(progress / 10000000));
const tileSize = TileWidth * TileHeight;
const tileOffset = currentTile % tileSize;
const index = Math.floor(currentTile / tileSize);
const cols = TileWidth;
const rows = TileHeight;
const imagesPerTile = cols * rows;
const imageIndex = Math.floor(currentSecond / (Interval / 1000));
const tileIndex = Math.floor(imageIndex / imagesPerTile);
const tileOffsetX = tileOffset % TileWidth;
const tileOffsetY = Math.floor(tileOffset / TileWidth);
const positionInTile = imageIndex % imagesPerTile;
const rowInTile = Math.floor(positionInTile / cols);
const colInTile = positionInTile % cols;
const newTrickPlayUrl = {
x: tileOffsetX,
y: tileOffsetY,
url: `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${index}.jpg?api_key=${api.accessToken}`,
x: rowInTile,
y: colInTile,
url: `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${tileIndex}.jpg?api_key=${api.accessToken}`,
};
setTrickPlayUrl(newTrickPlayUrl);

View File

@@ -15,7 +15,6 @@ interface UseWebSocketProps {
pauseVideo: () => void;
playVideo: () => void;
stopPlayback: () => void;
offline?: boolean;
}
export const useWebSocket = ({
@@ -23,7 +22,6 @@ export const useWebSocket = ({
pauseVideo,
playVideo,
stopPlayback,
offline = false,
}: UseWebSocketProps) => {
const router = useRouter();
const user = useAtomValue(userAtom);
@@ -40,7 +38,7 @@ export const useWebSocket = ({
});
useEffect(() => {
if (offline || !deviceId || !api?.accessToken) return;
if (!deviceId || !api?.accessToken) return;
const protocol = api?.basePath.includes("https") ? "wss" : "ws";
@@ -82,10 +80,10 @@ export const useWebSocket = ({
}
newWebSocket.close();
};
}, [api, deviceId, user, offline]);
}, [api, deviceId, user]);
useEffect(() => {
if (offline || !ws) return;
if (!ws) return;
ws.onmessage = (e) => {
const json = JSON.parse(e.data);
@@ -108,7 +106,7 @@ export const useWebSocket = ({
Alert.alert("Message from server: " + title, body);
}
};
}, [ws, stopPlayback, playVideo, pauseVideo, isPlaying, router, offline]);
}, [ws, stopPlayback, playVideo, pauseVideo, isPlaying, router]);
return { isConnected };
};

View File

@@ -1,2 +0,0 @@
#Sun Nov 17 18:25:45 AEDT 2024
gradle.version=8.9

View File

@@ -1,70 +0,0 @@
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"
}
}

View File

@@ -1,2 +0,0 @@
<manifest>
</manifest>

View File

@@ -1,69 +0,0 @@
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<String, Any> ->
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)
}
}
}
}

View File

@@ -1,263 +0,0 @@
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<String, String>? = 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<String, Any>) {
val mediaOptions = source["mediaOptions"] as? Map<String, Any> ?: emptyMap()
val autoplay = source["autoplay"] as? Boolean ?: false
val isNetwork = source["isNetwork"] as? Boolean ?: false
externalTrack = source["externalTrack"] as? Map<String, String>
startPosition = (source["startPosition"] as? Double)?.toInt() ?: 0
val initOptions = source["initOptions"] as? MutableList<String> ?: 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<Map<String, Any>>? {
println("getAudioTracks")
println(mediaPlayer?.getAudioTracks())
val trackDescriptions = mediaPlayer?.audioTracks ?: return null
return trackDescriptions.map { trackDescription ->
mapOf("name" to trackDescription.name, "index" to trackDescription.id)
}
}
fun setSubtitleTrack(trackIndex: Int) {
mediaPlayer?.setSpuTrack(trackIndex)
}
// fun getSubtitleTracks(): List<Map<String, Any>>? {
// return mediaPlayer?.getSpuTracks()?.map { trackDescription ->
// mapOf("name" to trackDescription.name, "index" to trackDescription.id)
// }
// }
fun getSubtitleTracks(): List<Map<String, Any>>? {
val subtitleTracks = mediaPlayer?.spuTracks?.map { trackDescription ->
mapOf("name" to trackDescription.name, "index" to trackDescription.id)
}
// Debug statement to print the result
Log.d("VlcPlayerView", "Subtitle Tracks: $subtitleTracks")
return subtitleTracks
}
fun setSubtitleURL(subtitleURL: String, name: String) {
println("Setting subtitle URL: $subtitleURL, name: $name")
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
}
override fun onDetachedFromWindow() {
println("onDetachedFromWindow")
super.onDetachedFromWindow()
mediaPlayer?.stop()
media?.release()
mediaPlayer?.release()
libVLC?.release()
mediaPlayer = null
media = null
libVLC = null
}
override fun onEvent(event: MediaPlayer.Event) {
when (event.type) {
MediaPlayer.Event.Playing,
MediaPlayer.Event.Paused,
MediaPlayer.Event.Stopped,
MediaPlayer.Event.Buffering,
MediaPlayer.Event.EndReached,
MediaPlayer.Event.EncounteredError -> updatePlayerState(event)
MediaPlayer.Event.TimeChanged -> updateVideoProgress()
}
}
private fun updatePlayerState(event: MediaPlayer.Event) {
val player = mediaPlayer ?: return
val currentState = event.type
val stateInfo = mutableMapOf<String, Any>(
"target" to "null", // Replace with actual target if needed
"currentTime" to player.time.toInt(),
"duration" to (player.media?.duration?.toInt() ?: 0),
"error" to false
)
when (currentState) {
MediaPlayer.Event.Playing -> {
stateInfo["isPlaying"] = true
stateInfo["isBuffering"] = false
stateInfo["state"] = "Playing"
}
MediaPlayer.Event.Paused -> {
stateInfo["isPlaying"] = false
stateInfo["state"] = "Paused"
}
MediaPlayer.Event.Buffering -> {
stateInfo["isBuffering"] = true
stateInfo["state"] = "Buffering"
}
MediaPlayer.Event.EncounteredError -> {
Log.e("VlcPlayerView", "player.state ~ error")
stateInfo["state"] = "Error"
onVideoLoadEnd(stateInfo);
}
MediaPlayer.Event.Opening -> {
Log.d("VlcPlayerView", "player.state ~ opening")
stateInfo["state"] = "Opening"
}
}
if (lastReportedState != currentState || lastReportedIsPlaying != player.isPlaying) {
lastReportedState = currentState
lastReportedIsPlaying = player.isPlaying
onVideoStateChange(stateInfo)
}
}
private fun updateVideoProgress() {
val player = mediaPlayer ?: return
val currentTimeMs = player.time.toInt()
val durationMs = player.media?.duration?.toInt() ?: 0
if (currentTimeMs >= 0 && currentTimeMs < durationMs) {
// Set subtitle URL if available
if (player.isPlaying && !isMediaReady) {
isMediaReady = true
externalTrack?.let {
val name = it["name"]
val deliveryUrl = it["DeliveryUrl"] ?: ""
if (!name.isNullOrEmpty() && !deliveryUrl.isNullOrEmpty()) {
setSubtitleURL(deliveryUrl, name)
}
}
}
onVideoProgress(mapOf(
"currentTime" to currentTimeMs,
"duration" to durationMs
));
}
}
}

View File

@@ -1,9 +0,0 @@
{
"platforms": ["ios", "tvos", "android", "web"],
"ios": {
"modules": ["VlcPlayerModule"]
},
"android": {
"modules": ["expo.modules.vlcplayer.VlcPlayerModule"]
}
}

View File

@@ -1,71 +0,0 @@
import {
NativeModulesProxy,
EventEmitter,
Subscription,
} from "expo-modules-core";
import VlcPlayerModule from "./src/VlcPlayerModule";
import VlcPlayerView from "./src/VlcPlayerView";
import {
PlaybackStatePayload,
ProgressUpdatePayload,
VideoLoadStartPayload,
VideoStateChangePayload,
VideoProgressPayload,
VlcPlayerSource,
TrackInfo,
ChapterInfo,
VlcPlayerViewProps,
VlcPlayerViewRef,
} from "./src/VlcPlayer.types";
const emitter = new EventEmitter(
VlcPlayerModule ?? NativeModulesProxy.VlcPlayer
);
export function addPlaybackStateListener(
listener: (event: PlaybackStatePayload) => void
): Subscription {
return emitter.addListener<PlaybackStatePayload>(
"onPlaybackStateChanged",
listener
);
}
export function addVideoLoadStartListener(
listener: (event: VideoLoadStartPayload) => void
): Subscription {
return emitter.addListener<VideoLoadStartPayload>(
"onVideoLoadStart",
listener
);
}
export function addVideoStateChangeListener(
listener: (event: VideoStateChangePayload) => void
): Subscription {
return emitter.addListener<VideoStateChangePayload>(
"onVideoStateChange",
listener
);
}
export function addVideoProgressListener(
listener: (event: VideoProgressPayload) => void
): Subscription {
return emitter.addListener<VideoProgressPayload>("onVideoProgress", listener);
}
export {
VlcPlayerView,
VlcPlayerViewProps,
VlcPlayerViewRef,
PlaybackStatePayload,
ProgressUpdatePayload,
VideoLoadStartPayload,
VideoStateChangePayload,
VideoProgressPayload,
VlcPlayerSource,
TrackInfo,
ChapterInfo,
};

View File

@@ -1,22 +0,0 @@
Pod::Spec.new do |s|
s.name = 'VlcPlayer'
s.version = '1.0.0'
s.summary = 'A sample project summary'
s.description = 'A sample project description'
s.author = ''
s.homepage = 'https://docs.expo.dev/modules/'
s.platforms = { :ios => '13.4', :tvos => '13.4' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.dependency 'MobileVLCKit', '~> 3.6.1b1'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end

View File

@@ -1,86 +0,0 @@
import ExpoModulesCore
public class VlcPlayerModule: Module {
public func definition() -> ModuleDefinition {
Name("VlcPlayer")
View(VlcPlayerView.self) {
Prop("source") { (view: VlcPlayerView, source: [String: Any]) in
view.setSource(source)
}
Prop("paused") { (view: VlcPlayerView, paused: Bool) in
if paused {
view.pause()
} else {
view.play()
}
}
// Prop("muted") { (view: VlcPlayerView, muted: Bool) in
// view.setMuted(muted)
// }
// Prop("volume") { (view: VlcPlayerView, volume: Int) in
// view.setVolume(volume)
// }
// Prop("videoAspectRatio") { (view: VlcPlayerView, ratio: String) in
// view.setVideoAspectRatio(ratio)
// }
Events(
"onPlaybackStateChanged",
"onVideoStateChange",
"onVideoLoadStart",
"onVideoLoadEnd",
"onVideoProgress",
"onVideoError"
)
AsyncFunction("play") { (view: VlcPlayerView) in
view.play()
}
AsyncFunction("pause") { (view: VlcPlayerView) in
view.pause()
}
AsyncFunction("stop") { (view: VlcPlayerView) in
view.stop()
}
AsyncFunction("seekTo") { (view: VlcPlayerView, time: Int32) in
view.seekTo(time)
}
AsyncFunction("setAudioTrack") { (view: VlcPlayerView, trackIndex: Int) in
view.setAudioTrack(trackIndex)
}
AsyncFunction("getAudioTracks") { (view: VlcPlayerView) -> [[String: Any]]? in
return view.getAudioTracks()
}
AsyncFunction("setSubtitleTrack") { (view: VlcPlayerView, trackIndex: Int) in
view.setSubtitleTrack(trackIndex)
}
AsyncFunction("getSubtitleTracks") { (view: VlcPlayerView) -> [[String: Any]]? in
return view.getSubtitleTracks()
}
// AsyncFunction("setVideoCropGeometry") { (view: VlcPlayerView, geometry: String?) in
// view.setVideoCropGeometry(geometry)
// }
// AsyncFunction("getVideoCropGeometry") { (view: VlcPlayerView) -> String? in
// return view.getVideoCropGeometry()
// }
AsyncFunction("setSubtitleURL") {
(view: VlcPlayerView, url: String, name: String) in
view.setSubtitleURL(url, name: name)
}
}
}
}

View File

@@ -1,691 +0,0 @@
import ExpoModulesCore
import MobileVLCKit
import UIKit
class VlcPlayerView: ExpoView {
private var mediaPlayer: VLCMediaPlayer?
private var videoView: UIView?
private var progressUpdateInterval: TimeInterval = 0.5
private var isPaused: Bool = false
private var currentGeometryCString: [CChar]?
private var lastReportedState: VLCMediaPlayerState?
private var lastReportedIsPlaying: Bool?
private var customSubtitles: [(internalName: String, originalName: String)] = []
private var startPosition: Int32 = 0
private var isMediaReady: Bool = false
private var externalTrack: [String: String]?
// MARK: - Initialization
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
setupView()
// setupNotifications()
}
// MARK: - Setup
private func setupView() {
DispatchQueue.main.async {
self.backgroundColor = .black
self.videoView = UIView()
self.videoView?.translatesAutoresizingMaskIntoConstraints = false
if let videoView = self.videoView {
self.addSubview(videoView)
NSLayoutConstraint.activate([
videoView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
videoView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
videoView.topAnchor.constraint(equalTo: self.topAnchor),
videoView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
])
}
}
}
private func setupNotifications() {
NotificationCenter.default.addObserver(
self, selector: #selector(applicationWillResignActive),
name: UIApplication.willResignActiveNotification, object: nil)
NotificationCenter.default.addObserver(
self, selector: #selector(applicationWillEnterForeground),
name: UIApplication.willEnterForegroundNotification, object: nil)
}
// MARK: - Public Methods
@objc func play() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.mediaPlayer?.play()
self.isPaused = false
print("Play")
}
}
@objc func pause() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.mediaPlayer?.pause()
self.isPaused = true
}
}
@objc func seekTo(_ time: Int32) {
DispatchQueue.main.async { [weak self] in
guard let self = self, let player = self.mediaPlayer else { return }
let wasPlaying = player.isPlaying
if wasPlaying {
player.pause()
}
if let duration = player.media?.length.intValue {
print("Seeking to time: \(time) Video Duration \(duration)")
// If the specified time is greater than the duration, seek to the end
let seekTime = time > duration ? duration - 1000 : time
player.time = VLCTime(int: seekTime)
// Wait for a short moment to ensure the seek has been processed
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if wasPlaying {
player.play()
}
self.updatePlayerState()
}
} else {
print("Error: Unable to retrieve video duration")
}
}
}
@objc func setSource(_ source: [String: Any]) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
self.externalTrack = source["externalTrack"] as? [String: String]
var initOptions = source["initOptions"] as? [Any] ?? []
startPosition = source["startPosition"] as? Int32 ?? 0
initOptions.append("--start-time=\(startPosition)")
let uri = source["uri"] as? String
let autoplay = source["autoplay"] as? Bool ?? false
let isNetwork = source["isNetwork"] as? Bool ?? false
guard let uri = uri, !uri.isEmpty else {
print("Error: Invalid or empty URI")
self.onVideoError?(["error": "Invalid or empty URI"])
return
}
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
self.mediaPlayer = VLCMediaPlayer(options: initOptions)
self.mediaPlayer?.delegate = self
self.mediaPlayer?.drawable = self.videoView
self.mediaPlayer?.scaleFactor = 0
let media: VLCMedia
if isNetwork {
print("Loading network file: \(uri)")
media = VLCMedia(url: URL(string: uri)!)
} else {
print("Loading local file: \(uri)")
if uri.starts(with: "file://") {
if let url = URL(string: uri) {
media = VLCMedia(url: url)
} else {
print("Error: Invalid local file URL")
self.onVideoError?(["error": "Invalid local file URL"])
return
}
} else {
media = VLCMedia(path: uri)
}
}
print("Debug: Media options: \(mediaOptions)")
media.addOptions(mediaOptions)
// Apply subtitle options
let subtitleTrackIndex = source["subtitleTrackIndex"] as? Int ?? -1
print("Debug: Subtitle track index from source: \(subtitleTrackIndex)")
if subtitleTrackIndex >= -1 {
self.setSubtitleTrack(subtitleTrackIndex)
print("Debug: Set subtitle track to index: \(subtitleTrackIndex)")
} else {
print("Debug: Subtitle track index is less than -1, not setting")
}
self.mediaPlayer?.media = media
if autoplay {
print("Playing...")
self.play()
}
}
}
// TODO
// @objc func setMuted(_ muted: Bool) {
// DispatchQueue.main.async {
// self.mediaPlayer?.audio?.isMuted = muted
// }
// }
// TODO
// @objc func setVolume(_ volume: Int) {
// DispatchQueue.main.async {
// self.mediaPlayer?.audio?.volume = Int32(volume)
// }
// }
// TODO
// @objc func setVideoAspectRatio(_ ratio: String) {
// DispatchQueue.main.async {
// ratio.withCString { cString in
// self.mediaPlayer?.videoAspectRatio = UnsafeMutablePointer(mutating: cString)
// }
// }
// }
@objc func setAudioTrack(_ trackIndex: Int) {
DispatchQueue.main.async {
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
}
}
@objc func getAudioTracks() -> [[String: Any]]? {
guard let trackNames = mediaPlayer?.audioTrackNames,
let trackIndexes = mediaPlayer?.audioTrackIndexes
else {
return nil
}
return zip(trackNames, trackIndexes).map { name, index in
return ["name": name, "index": index]
}
}
// @objc func getAudioTracks(
// _ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock
// ) {
// DispatchQueue.global(qos: .userInitiated).async { [weak self] in
// guard let self = self, let mediaPlayer = self.mediaPlayer else {
// DispatchQueue.main.async {
// reject("ERROR", "Media player not available", nil)
// }
// return
// }
// guard let trackNames = mediaPlayer.audioTrackNames,
// let trackIndexes = mediaPlayer.audioTrackIndexes
// else {
// DispatchQueue.main.async {
// reject("ERROR", "No audio tracks available", nil)
// }
// return
// }
// let tracks = zip(trackNames, trackIndexes).map { name, index in
// return ["name": name, "index": index]
// }
// DispatchQueue.main.async {
// resolve(tracks)
// }
// }
// }
@objc func setSubtitleTrack(_ trackIndex: Int) {
print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
DispatchQueue.main.async {
if trackIndex == -1 {
print("Debug: Disabling subtitles")
// Disable subtitles
self.mediaPlayer?.currentVideoSubTitleIndex = -1
} else {
print("Debug: Setting subtitle track to index: \(trackIndex)")
// Set the subtitle track
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
}
// Print the result
if let currentIndex = self.mediaPlayer?.currentVideoSubTitleIndex {
print("Debug: Current subtitle track index after setting: \(currentIndex)")
} else {
print("Debug: Unable to retrieve current subtitle track index")
}
}
}
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
DispatchQueue.main.async { [weak self] in
guard let self = self, let url = URL(string: subtitleURL) else {
print("Error: Invalid subtitle URL")
return
}
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true)
if let result = result {
let internalName = "Track \(self.customSubtitles.count + 1)"
print("Subtitle added with result: \(result) \(internalName)")
self.customSubtitles.append((internalName: internalName, originalName: name))
} else {
print("Failed to add subtitle")
}
}
}
@objc func getSubtitleTracks() -> [[String: Any]]? {
guard let mediaPlayer = self.mediaPlayer else {
return nil
}
let count = mediaPlayer.numberOfSubtitlesTracks
print("Debug: Number of subtitle tracks: \(count)")
guard count > 0 else {
return nil
}
var tracks: [[String: Any]] = []
if let names = mediaPlayer.videoSubTitlesNames as? [String],
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
{
for (index, name) in zip(indexes, names) {
if let customSubtitle = customSubtitles.first(where: { $0.internalName == name }) {
tracks.append(["name": customSubtitle.originalName, "index": index.intValue])
} else {
tracks.append(["name": name, "index": index.intValue])
}
}
}
print("Debug: Subtitle tracks: \(tracks)")
return tracks
}
private func setSubtitleTrackByName(_ trackName: String) {
guard let mediaPlayer = self.mediaPlayer else { return }
// Get the subtitle tracks and their indexes
if let names = mediaPlayer.videoSubTitlesNames as? [String],
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
{
for (index, name) in zip(indexes, names) {
if name.starts(with: trackName) {
let trackIndex = index.intValue
print("Track Index setting to: \(trackIndex)")
setSubtitleTrack(trackIndex)
return
}
}
}
print("Track not found for name: \(trackName)")
}
// @objc func getSubtitleTracks(
// _ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock
// ) {
// DispatchQueue.global(qos: .userInitiated).async { [weak self] in
// guard let self = self, let mediaPlayer = self.mediaPlayer else {
// DispatchQueue.main.async {
// reject("ERROR", "Media player not available", nil)
// }
// return
// }
// let count = mediaPlayer.numberOfSubtitlesTracks
// guard count > 0 else {
// DispatchQueue.main.async {
// reject("ERROR", "No subtitle tracks available", nil)
// }
// return
// }
// var tracks: [[String: Any]] = [["name": "Disabled", "index": -1]]
// if let names = mediaPlayer.videoSubTitlesNames as? [String],
// let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
// {
// for (index, name) in zip(indexes, names) {
// tracks.append(["name": name, "index": index.intValue])
// }
// }
// DispatchQueue.main.async {
// resolve(tracks)
// }
// }
// }
// TODO
// @objc func setSubtitleDelay(_ delay: Int) {
// DispatchQueue.main.async {
// self.mediaPlayer?.currentVideoSubTitleDelay = NSInteger(delay)
// }
// }
// TODO
// @objc func setAudioDelay(_ delay: Int) {
// DispatchQueue.main.async {
// self.mediaPlayer?.currentAudioPlaybackDelay = NSInteger(delay)
// }
// }
// TODO
// @objc func takeSnapshot(_ path: String, width: Int, height: Int) {
// DispatchQueue.main.async { [weak self] in
// guard let self = self else { return }
// self.mediaPlayer?.saveVideoSnapshot(
// at: path, withWidth: Int32(width), andHeight: Int32(height))
// }
// }
// TODO
// @objc func setVideoCropGeometry(_ geometry: String?) {
// DispatchQueue.main.async {
// if let geometry = geometry, !geometry.isEmpty {
// self.currentGeometryCString = geometry.cString(using: .utf8)
// self.currentGeometryCString?.withUnsafeMutableBufferPointer { buffer in
// self.mediaPlayer?.videoCropGeometry = buffer.baseAddress
// }
// } else {
// self.currentGeometryCString = nil
// self.mediaPlayer?.videoCropGeometry = nil
// }
// }
// }
// TODO
// @objc func getVideoCropGeometry() -> String? {
// guard let cString = mediaPlayer?.videoCropGeometry else {
// return nil
// }
// return String(cString: cString)
// }
// TODO
// @objc func setRate(_ rate: Float) {
// DispatchQueue.main.async {
// self.mediaPlayer?.rate = rate
// }
// }
// TODO
// @objc func nextChapter() {
// DispatchQueue.main.async {
// self.mediaPlayer?.nextChapter()
// }
// }
// TODO
// @objc func previousChapter() {
// DispatchQueue.main.async {
// self.mediaPlayer?.previousChapter()
// }
// }
// TODO
// @objc func getChapters() -> [[String: Any]]? {
// guard let currentTitleIndex = mediaPlayer?.currentTitleIndex,
// let chapters = mediaPlayer?.chapterDescriptions(ofTitle: currentTitleIndex)
// as? [[String: Any]]
// else {
// return nil
// }
// return chapters.compactMap { chapter in
// guard let name = chapter[VLCChapterDescriptionName] as? String,
// let timeOffset = chapter[VLCChapterDescriptionTimeOffset] as? NSNumber,
// let duration = chapter[VLCChapterDescriptionDuration] as? NSNumber
// else {
// return nil
// }
// return [
// "name": name,
// "timeOffset": timeOffset.doubleValue,
// "duration": duration.doubleValue,
// ]
// }
// }
private var isStopping: Bool = false
@objc func stop(completion: (() -> Void)? = nil) {
guard !isStopping else {
completion?()
return
}
isStopping = true
// If we're not on the main thread, dispatch to main thread
if !Thread.isMainThread {
DispatchQueue.main.async { [weak self] in
self?.performStop(completion: completion)
}
} else {
performStop(completion: completion)
}
}
// MARK: - Private Methods
@objc private func applicationWillResignActive() {
if !isPaused {
pause()
}
}
@objc private func applicationWillEnterForeground() {
if !isPaused {
play()
}
}
private func performStop(completion: (() -> Void)? = nil) {
// Stop the media player
mediaPlayer?.stop()
// Remove observer
NotificationCenter.default.removeObserver(self)
// Clear the video view
videoView?.removeFromSuperview()
videoView = nil
// Release the media player
mediaPlayer?.delegate = nil
mediaPlayer = nil
isStopping = false
completion?()
}
private func getSubtitleOptions() -> [String: Any] {
return [
// Text scaling (100 is default, increase for larger text)
"sub-text-scale": "105",
// Text color (RRGGBB format, 16777215 is white)
"freetype-color": "16777215",
// Outline thickness (reduced from 2 to 1 for less border)
"freetype-outline-thickness": "1",
// Outline color (RRGGBB format, 0 is black)
"freetype-outline-color": "0",
// Text opacity (0-255, 255 is fully opaque)
"freetype-opacity": "255",
// Shadow opacity (increased from 128 to 180 for more shadow)
"freetype-shadow-opacity": "180",
// Shadow offset (increased from 2 to 3 for more pronounced shadow)
"freetype-shadow-offset": "3",
// Text alignment (0: center, 1: left, 2: right)
"sub-text-alignment": "0",
// Vertical margin (from bottom of the screen, in pixels)
"sub-margin-bottom": "50",
// Background opacity (0-255, 0 for no background)
"freetype-background-opacity": "64",
// Background color (RRGGBB format)
"freetype-background-color": "0",
]
}
// MARK: - Expo Events
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
@objc var onVideoLoadStart: RCTDirectEventBlock?
@objc var onVideoStateChange: RCTDirectEventBlock?
@objc var onVideoProgress: RCTDirectEventBlock?
@objc var onVideoLoadEnd: RCTDirectEventBlock?
@objc var onVideoError: RCTDirectEventBlock?
// MARK: - Deinitialization
deinit {
performStop()
}
}
extension VlcPlayerView: VLCMediaPlayerDelegate {
func mediaPlayerStateChanged(_ aNotification: Notification) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.updatePlayerState()
}
}
private func updatePlayerState() {
DispatchQueue.main.async { [weak self] in
guard let self = self, let player = self.mediaPlayer else { return }
let currentState = player.state
var stateInfo: [String: Any] = [
"target": self.reactTag ?? NSNull(),
"currentTime": player.time.intValue,
"duration": player.media?.length.intValue ?? 0,
"error": false,
]
if player.isPlaying {
stateInfo["isPlaying"] = true
stateInfo["isBuffering"] = false
stateInfo["state"] = "Playing"
} else {
stateInfo["isPlaying"] = false
stateInfo["state"] = "Paused"
}
if player.state == VLCMediaPlayerState.buffering {
stateInfo["isBuffering"] = true
stateInfo["state"] = "Buffering"
} else if player.state == VLCMediaPlayerState.error {
print("player.state ~ error")
stateInfo["state"] = "Error"
self.onVideoLoadEnd?(stateInfo)
} else if player.state == VLCMediaPlayerState.opening {
print("player.state ~ opening")
stateInfo["state"] = "Opening"
}
if self.lastReportedState != currentState
|| self.lastReportedIsPlaying != player.isPlaying
{
self.lastReportedState = currentState
self.lastReportedIsPlaying = player.isPlaying
self.onVideoStateChange?(stateInfo)
}
}
}
// func seekToStartTime() {
// DispatchQueue.main.async { [weak self] in
// guard let self = self, let player = self.mediaPlayer else { return }
// if let startPosition = self.startPosition, startPosition > 0 {
// print("Debug: Seeking to start position: \(startPosition)")
// player.time = VLCTime(int: Int32(startPosition))
// // Ensure the player continues playing after seeking
// if !player.isPlaying {
// player.play()
// }
// }
// }
// }
func mediaPlayerTimeChanged(_ aNotification: Notification) {
DispatchQueue.main.async { [weak self] in
self?.updateVideoProgress()
}
}
private func updateVideoProgress() {
DispatchQueue.main.async {
guard let player = self.mediaPlayer else { return }
let currentTimeMs = player.time.intValue
let durationMs = player.media?.length.intValue ?? 0
if currentTimeMs >= 0 && currentTimeMs < durationMs {
if player.isPlaying && !self.isMediaReady {
self.isMediaReady = true
// Set external track subtitle when starting.
if let externalTrack = self.externalTrack {
if let name = externalTrack["name"] as? String, !name.isEmpty {
let deliveryUrl = externalTrack["DeliveryUrl"] as? String ?? ""
self.setSubtitleURL(deliveryUrl, name: name)
}
}
}
self.onVideoProgress?([
"currentTime": currentTimeMs,
"duration": durationMs,
])
}
}
}
}
extension VlcPlayerView: VLCMediaDelegate {
// func mediaMetaDataDidChange(_ aMedia: VLCMedia) {
// // Implement if needed
// }
// func mediaDidFinishParsing(_ aMedia: VLCMedia) {
// DispatchQueue.main.async {
// let duration = aMedia.length.intValue
// self.onVideoStateChange?(["type": "MediaParsed", "duration": duration])
// }
// }
}
extension VLCMediaPlayerState {
var description: String {
switch self {
case .opening: return "Opening"
case .buffering: return "Buffering"
case .playing: return "Playing"
case .paused: return "Paused"
case .stopped: return "Stopped"
case .ended: return "Ended"
case .error: return "Error"
case .esAdded: return "ESAdded"
@unknown default: return "Unknown"
}
}
}

View File

@@ -1,88 +0,0 @@
export type PlaybackStatePayload = {
nativeEvent: {
target: number;
state: "Opening" | "Buffering" | "Playing" | "Paused" | "Error";
currentTime: number;
duration: number;
isBuffering: boolean;
isPlaying: boolean;
};
};
export type ProgressUpdatePayload = {
nativeEvent: {
currentTime: number;
duration: number;
isPlaying: boolean;
isBuffering: boolean;
};
};
export type VideoLoadStartPayload = {
nativeEvent: {
target: number;
};
};
export type VideoStateChangePayload = PlaybackStatePayload;
export type VideoProgressPayload = ProgressUpdatePayload;
export type VlcPlayerSource = {
uri: string;
type?: string;
isNetwork?: boolean;
autoplay?: boolean;
externalTrack?: { name: string, DeliveryUrl: string };
initOptions?: any[];
mediaOptions?: { [key: string]: any };
startPosition?: number;
};
export type TrackInfo = {
name: string;
index: number;
language?: string;
};
export type ChapterInfo = {
name: string;
timeOffset: number;
duration: number;
};
export type VlcPlayerViewProps = {
source: VlcPlayerSource;
style?: Object;
progressUpdateInterval?: number;
paused?: boolean;
muted?: boolean;
volume?: number;
videoAspectRatio?: string;
onVideoProgress?: (event: ProgressUpdatePayload) => void;
onVideoStateChange?: (event: PlaybackStatePayload) => void;
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
onVideoLoadEnd?: (event: VideoLoadStartPayload) => void;
onVideoError?: (event: PlaybackStatePayload) => void;
};
export interface VlcPlayerViewRef {
play: () => Promise<void>;
pause: () => Promise<void>;
stop: () => Promise<void>;
seekTo: (time: number) => Promise<void>;
setAudioTrack: (trackIndex: number) => Promise<void>;
getAudioTracks: () => Promise<TrackInfo[] | null>;
setSubtitleTrack: (trackIndex: number) => Promise<void>;
getSubtitleTracks: () => Promise<TrackInfo[] | null>;
setSubtitleDelay: (delay: number) => Promise<void>;
setAudioDelay: (delay: number) => Promise<void>;
takeSnapshot: (path: string, width: number, height: number) => Promise<void>;
setRate: (rate: number) => Promise<void>;
nextChapter: () => Promise<void>;
previousChapter: () => Promise<void>;
getChapters: () => Promise<ChapterInfo[] | null>;
setVideoCropGeometry: (geometry: string | null) => Promise<void>;
getVideoCropGeometry: () => Promise<string | null>;
setSubtitleURL: (url: string, name: string) => Promise<void>;
}

View File

@@ -1,5 +0,0 @@
import { requireNativeModule } from 'expo-modules-core';
// It loads the native module object from the JSI or falls back to
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
export default requireNativeModule('VlcPlayer');

Some files were not shown because too many files have changed in this diff Show More