forked from Ninjalama/streamyfin_mirror
Compare commits
1 Commits
feat/nativ
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c051f6f61 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -26,8 +26,6 @@ package-lock.json
|
|||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
|
|
||||||
modules/vlc-player/android
|
|
||||||
|
|
||||||
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||||
credentials.json
|
credentials.json
|
||||||
*.apk
|
*.apk
|
||||||
|
|||||||
18
app.json
18
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.18.0",
|
"version": "0.17.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 46,
|
"versionCode": 43,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||||
},
|
},
|
||||||
@@ -66,6 +66,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"./plugins/withAndroidMainActivityAttributes",
|
||||||
|
{
|
||||||
|
"com.reactnative.googlecast.RNGCExpandedControllerActivity": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
["./plugins/withExpandedController.js"],
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
@@ -99,12 +106,7 @@
|
|||||||
{
|
{
|
||||||
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
|
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
[
|
|
||||||
"react-native-edge-to-edge",
|
|
||||||
{ "android": { "parentTheme": "Material3" } }
|
|
||||||
],
|
|
||||||
["react-native-bottom-tabs"]
|
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|||||||
@@ -42,11 +42,17 @@ export default function IndexLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings"
|
name="settings/index"
|
||||||
options={{
|
options={{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings/audio-language"
|
||||||
|
options={{
|
||||||
|
title: "Audio Language",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionLi
|
|||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -24,10 +25,11 @@ import {
|
|||||||
import NetInfo from "@react-native-community/netinfo";
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Platform,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -392,6 +394,9 @@ export default function index() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
}}
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: TAB_HEIGHT,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col space-y-4">
|
<View className="flex flex-col space-y-4">
|
||||||
<LargeMovieCarousel />
|
<LargeMovieCarousel />
|
||||||
|
|||||||
61
app/(auth)/(tabs)/(home)/settings/audio-language.tsx
Normal file
61
app/(auth)/(tabs)/(home)/settings/audio-language.tsx
Normal 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;
|
||||||
|
};
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
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 { SettingToggles } from "@/components/settings/SettingToggles";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { clearLogs, readFromLog } from "@/utils/log";
|
import { clearLogs, readFromLog } from "@/utils/log";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Alert, ScrollView, View } from "react-native";
|
import { Alert, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
@@ -16,6 +21,7 @@ import { toast } from "sonner-native";
|
|||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const { deleteAllFiles } = useDownload();
|
const { deleteAllFiles } = useDownload();
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
@@ -57,6 +63,8 @@ export default function settings() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
@@ -73,15 +81,46 @@ export default function settings() {
|
|||||||
>
|
>
|
||||||
registerBackgroundFetchAsync
|
registerBackgroundFetchAsync
|
||||||
</Button> */}
|
</Button> */}
|
||||||
<View>
|
<ListSection title="USER INFO">
|
||||||
<Text className="font-bold text-lg mb-2">User Info</Text>
|
<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 ">
|
<ListSection title="MEDIA">
|
||||||
<ListItem title="User" subTitle={user?.Name} />
|
<ListItem
|
||||||
<ListItem title="Server" subTitle={api?.basePath} />
|
title="Audio language"
|
||||||
<ListItem title="Token" subTitle={api?.accessToken} />
|
iconAfter={
|
||||||
</View>
|
<Ionicons name="chevron-forward" size={20} color="white" />
|
||||||
</View>
|
}
|
||||||
|
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>
|
<View>
|
||||||
<Text className="font-bold text-lg mb-2">Quick connect</Text>
|
<Text className="font-bold text-lg mb-2">Quick connect</Text>
|
||||||
61
app/(auth)/(tabs)/(home)/settings/subtitle-language.tsx
Normal file
61
app/(auth)/(tabs)/(home)/settings/subtitle-language.tsx
Normal 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;
|
||||||
|
};
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
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 * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import { useAtom } from "jotai";
|
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 { FlatList, useWindowDimensions, View } from "react-native";
|
||||||
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -12,7 +16,6 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
|||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
genreFilterAtom,
|
genreFilterAtom,
|
||||||
@@ -29,6 +32,7 @@ import {
|
|||||||
tagsFilterAtom,
|
tagsFilterAtom,
|
||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
|
import { orientationAtom } from "@/utils/atoms/orientation";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
@@ -56,13 +60,12 @@ const Page = () => {
|
|||||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||||
const [sortBy, _setSortBy] = useAtom(sortByAtom);
|
const [sortBy, _setSortBy] = useAtom(sortByAtom);
|
||||||
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
||||||
|
const [orientation] = useAtom(orientationAtom);
|
||||||
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
||||||
const [sortOrderPreference, setOderByPreference] = useAtom(
|
const [sortOrderPreference, setOderByPreference] = useAtom(
|
||||||
sortOrderPreferenceAtom
|
sortOrderPreferenceAtom
|
||||||
);
|
);
|
||||||
|
|
||||||
const { orientation } = useOrientation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||||
if (sop) {
|
if (sop) {
|
||||||
@@ -103,12 +106,11 @@ const Page = () => {
|
|||||||
[libraryId, sortOrderPreference]
|
[libraryId, sortOrderPreference]
|
||||||
);
|
);
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const getNumberOfColumns = useCallback(() => {
|
||||||
if (screenWidth < 300) return 2;
|
if (orientation === ScreenOrientation.Orientation.PORTRAIT_UP) return 3;
|
||||||
if (screenWidth < 500) return 3;
|
if (screenWidth < 600) return 5;
|
||||||
if (screenWidth < 800) return 5;
|
if (screenWidth < 960) return 6;
|
||||||
if (screenWidth < 1000) return 6;
|
if (screenWidth < 1280) return 7;
|
||||||
if (screenWidth < 1500) return 7;
|
|
||||||
return 6;
|
return 6;
|
||||||
}, [screenWidth, orientation]);
|
}, [screenWidth, orientation]);
|
||||||
|
|
||||||
@@ -217,7 +219,7 @@ const Page = () => {
|
|||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
<TouchableItemRouter
|
<MemoizedTouchableItemRouter
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -228,10 +230,10 @@ const Page = () => {
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
alignSelf:
|
alignSelf:
|
||||||
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP
|
orientation === ScreenOrientation.Orientation.PORTRAIT_UP
|
||||||
? index % nrOfCols === 0
|
? index % 3 === 0
|
||||||
? "flex-end"
|
? "flex-end"
|
||||||
: (index + 1) % nrOfCols === 0
|
: (index + 1) % 3 === 0
|
||||||
? "flex-start"
|
? "flex-start"
|
||||||
: "center"
|
: "center"
|
||||||
: "center",
|
: "center",
|
||||||
@@ -242,7 +244,7 @@ const Page = () => {
|
|||||||
<ItemPoster item={item} />
|
<ItemPoster item={item} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</MemoizedTouchableItemRouter>
|
||||||
),
|
),
|
||||||
[orientation]
|
[orientation]
|
||||||
);
|
);
|
||||||
@@ -427,7 +429,6 @@ const Page = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
key={orientation}
|
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className="flex flex-col items-center justify-center h-full">
|
<View className="flex flex-col items-center justify-center h-full">
|
||||||
<Text className="font-bold text-xl text-neutral-500">No results</Text>
|
<Text className="font-bold text-xl text-neutral-500">No results</Text>
|
||||||
@@ -436,10 +437,10 @@ const Page = () => {
|
|||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
data={flatData}
|
data={flatData}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
extraData={[orientation, nrOfCols]}
|
extraData={orientation}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
estimatedItemSize={244}
|
estimatedItemSize={244}
|
||||||
numColumns={nrOfCols}
|
numColumns={getNumberOfColumns()}
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (hasNextPage) {
|
if (hasNextPage) {
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
@@ -7,6 +8,7 @@ import { Loader } from "@/components/Loader";
|
|||||||
import AlbumCover from "@/components/posters/AlbumCover";
|
import AlbumCover from "@/components/posters/AlbumCover";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||||
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
@@ -224,6 +226,10 @@ export default function search() {
|
|||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: TAB_HEIGHT,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col pt-2">
|
<View className="flex flex-col pt-2">
|
||||||
|
|||||||
@@ -1,77 +1,87 @@
|
|||||||
import React from "react";
|
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
|
||||||
import { Platform } from "react-native";
|
|
||||||
|
|
||||||
import { withLayoutContext } from "expo-router";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createNativeBottomTabNavigator,
|
|
||||||
NativeBottomTabNavigationEventMap,
|
|
||||||
} from "react-native-bottom-tabs/react-navigation";
|
|
||||||
|
|
||||||
const { Navigator } = createNativeBottomTabNavigator();
|
|
||||||
|
|
||||||
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
|
||||||
|
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import type {
|
import { BlurView } from "expo-blur";
|
||||||
ParamListBase,
|
import * as NavigationBar from "expo-navigation-bar";
|
||||||
TabNavigationState,
|
import { Tabs } from "expo-router";
|
||||||
} from "@react-navigation/native";
|
import React, { useEffect } from "react";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { Platform, StyleSheet } from "react-native";
|
||||||
|
|
||||||
export const NativeTabs = withLayoutContext<
|
|
||||||
BottomTabNavigationOptions,
|
|
||||||
typeof Navigator,
|
|
||||||
TabNavigationState<ParamListBase>,
|
|
||||||
NativeBottomTabNavigationEventMap
|
|
||||||
>(Navigator);
|
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
NavigationBar.setBackgroundColorAsync("#121212");
|
||||||
|
NavigationBar.setBorderColorAsync("#121212");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Tabs
|
||||||
<SystemBars hidden={false} style="light" />
|
initialRouteName="home"
|
||||||
<NativeTabs
|
screenOptions={{
|
||||||
sidebarAdaptable
|
tabBarActiveTintColor: Colors.tabIconSelected,
|
||||||
ignoresTopSafeArea
|
headerShown: false,
|
||||||
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
|
tabBarStyle: {
|
||||||
tabBarActiveTintColor={Colors.primary}
|
position: "absolute",
|
||||||
scrollEdgeAppearance="default"
|
borderTopLeftRadius: 0,
|
||||||
>
|
borderTopRightRadius: 0,
|
||||||
<NativeTabs.Screen redirect name="index" />
|
borderTopWidth: 0,
|
||||||
<NativeTabs.Screen
|
paddingTop: 8,
|
||||||
name="(home)"
|
paddingBottom: Platform.OS === "android" ? 8 : 26,
|
||||||
options={{
|
height: Platform.OS === "android" ? 58 : 74,
|
||||||
title: "Home",
|
},
|
||||||
tabBarIcon:
|
tabBarBackground: () =>
|
||||||
Platform.OS == "android"
|
Platform.OS === "ios" ? (
|
||||||
? ({ color, focused, size }) =>
|
<BlurView
|
||||||
require("@/assets/icons/house.fill.png")
|
experimentalBlurMethod="dimezisBlurView"
|
||||||
: () => ({ sfSymbol: "house" }),
|
intensity={95}
|
||||||
}}
|
style={{
|
||||||
/>
|
...StyleSheet.absoluteFillObject,
|
||||||
<NativeTabs.Screen
|
overflow: "hidden",
|
||||||
name="(search)"
|
borderTopLeftRadius: 0,
|
||||||
options={{
|
borderTopRightRadius: 0,
|
||||||
title: "Search",
|
backgroundColor: "black",
|
||||||
tabBarIcon:
|
}}
|
||||||
Platform.OS == "android"
|
/>
|
||||||
? ({ color, focused, size }) =>
|
) : undefined,
|
||||||
require("@/assets/icons/magnifyingglass.png")
|
}}
|
||||||
: () => ({ sfSymbol: "magnifyingglass" }),
|
>
|
||||||
}}
|
<Tabs.Screen redirect name="index" />
|
||||||
/>
|
<Tabs.Screen
|
||||||
<NativeTabs.Screen
|
name="(home)"
|
||||||
name="(libraries)"
|
options={{
|
||||||
options={{
|
headerShown: false,
|
||||||
title: "Library",
|
title: "Home",
|
||||||
tabBarIcon:
|
tabBarIcon: ({ color, focused }) => (
|
||||||
Platform.OS == "android"
|
<TabBarIcon
|
||||||
? ({ color, focused, size }) =>
|
name={focused ? "home" : "home-outline"}
|
||||||
require("@/assets/icons/server.rack.png")
|
color={color}
|
||||||
: () => ({ sfSymbol: "rectangle.stack" }),
|
/>
|
||||||
}}
|
),
|
||||||
/>
|
}}
|
||||||
</NativeTabs>
|
/>
|
||||||
</>
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import AlbumCover from "@/components/posters/AlbumCover";
|
||||||
import { Controls } from "@/components/video-player/Controls";
|
import { Controls } from "@/components/video-player/Controls";
|
||||||
|
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
@@ -17,9 +20,9 @@ import * as Haptics from "expo-haptics";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useFocusEffect } from "expo-router";
|
import { useFocusEffect } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
|
import { debounce } from "lodash";
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { Dimensions, Pressable, View } from "react-native";
|
import { Dimensions, Pressable, StatusBar, View } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||||
|
|
||||||
@@ -173,6 +176,7 @@ export default function page() {
|
|||||||
|
|
||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
useOrientationSettings();
|
useOrientationSettings();
|
||||||
|
useAndroidNavigationBar();
|
||||||
|
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
isPlaying: isPlaying,
|
isPlaying: isPlaying,
|
||||||
@@ -190,7 +194,7 @@ export default function page() {
|
|||||||
}}
|
}}
|
||||||
className="flex flex-col items-center justify-center"
|
className="flex flex-col items-center justify-center"
|
||||||
>
|
>
|
||||||
<SystemBars hidden />
|
<StatusBar hidden />
|
||||||
|
|
||||||
<View className="h-screen w-screen top-0 left-0 flex flex-col items-center justify-center p-4 absolute z-0">
|
<View className="h-screen w-screen top-0 left-0 flex flex-col items-center justify-center p-4 absolute z-0">
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Controls } from "@/components/video-player/Controls";
|
import { Controls } from "@/components/video-player/Controls";
|
||||||
|
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
@@ -6,14 +7,25 @@ import {
|
|||||||
PlaybackType,
|
PlaybackType,
|
||||||
usePlaySettings,
|
usePlaySettings,
|
||||||
} from "@/providers/PlaySettingsProvider";
|
} 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 { secondsToTicks } from "@/utils/secondsToTicks";
|
||||||
import { Api } from "@jellyfin/sdk";
|
import { Api } from "@jellyfin/sdk";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
|
import * as NavigationBar from "expo-navigation-bar";
|
||||||
import { useFocusEffect } from "expo-router";
|
import { useFocusEffect } from "expo-router";
|
||||||
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
import React, {
|
||||||
import { Pressable, useWindowDimensions, View } from "react-native";
|
useCallback,
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Dimensions, Platform, Pressable, StatusBar, View } from "react-native";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||||
|
|
||||||
@@ -25,9 +37,7 @@ export default function page() {
|
|||||||
const videoSource = useVideoSource(playSettings, api, playUrl);
|
const videoSource = useVideoSource(playSettings, api, playUrl);
|
||||||
const firstTime = useRef(true);
|
const firstTime = useRef(true);
|
||||||
|
|
||||||
const dimensions = useWindowDimensions();
|
const screenDimensions = Dimensions.get("screen");
|
||||||
useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
|
|
||||||
const [showControls, setShowControls] = useState(true);
|
const [showControls, setShowControls] = useState(true);
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||||
@@ -67,6 +77,10 @@ export default function page() {
|
|||||||
}, [play, stop])
|
}, [play, stop])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { orientation } = useOrientation();
|
||||||
|
useOrientationSettings();
|
||||||
|
useAndroidNavigationBar();
|
||||||
|
|
||||||
const onProgress = useCallback(async (data: OnProgressData) => {
|
const onProgress = useCallback(async (data: OnProgressData) => {
|
||||||
if (isSeeking.value === true) return;
|
if (isSeeking.value === true) return;
|
||||||
progress.value = secondsToTicks(data.currentTime);
|
progress.value = secondsToTicks(data.currentTime);
|
||||||
@@ -80,13 +94,13 @@ export default function page() {
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: dimensions.width,
|
width: screenDimensions.width,
|
||||||
height: dimensions.height,
|
height: screenDimensions.height,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
className="flex flex-col items-center justify-center"
|
className="flex flex-col items-center justify-center"
|
||||||
>
|
>
|
||||||
<SystemBars hidden />
|
<StatusBar hidden />
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setShowControls(!showControls);
|
setShowControls(!showControls);
|
||||||
@@ -118,6 +132,7 @@ export default function page() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<Controls
|
<Controls
|
||||||
item={playSettings.item}
|
item={playSettings.item}
|
||||||
videoRef={videoRef}
|
videoRef={videoRef}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Controls } from "@/components/video-player/Controls";
|
import { Controls } from "@/components/video-player/Controls";
|
||||||
|
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
@@ -17,14 +18,15 @@ import * as Haptics from "expo-haptics";
|
|||||||
import { useFocusEffect } from "expo-router";
|
import { useFocusEffect } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { Pressable, useWindowDimensions, View } from "react-native";
|
import { Dimensions, Pressable, StatusBar, View } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import Video, {
|
import Video, {
|
||||||
OnProgressData,
|
OnProgressData,
|
||||||
SelectedTrackType,
|
|
||||||
VideoRef,
|
VideoRef,
|
||||||
|
SelectedTrack,
|
||||||
|
SelectedTrackType,
|
||||||
} from "react-native-video";
|
} from "react-native-video";
|
||||||
|
import { WithDefault } from "react-native/Libraries/Types/CodegenTypes";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const { playSettings, playUrl, playSessionId } = usePlaySettings();
|
const { playSettings, playUrl, playSessionId } = usePlaySettings();
|
||||||
@@ -34,7 +36,8 @@ export default function page() {
|
|||||||
const poster = usePoster(playSettings, api);
|
const poster = usePoster(playSettings, api);
|
||||||
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
|
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
|
||||||
const firstTime = useRef(true);
|
const firstTime = useRef(true);
|
||||||
const dimensions = useWindowDimensions();
|
|
||||||
|
const screenDimensions = Dimensions.get("screen");
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [showControls, setShowControls] = useState(true);
|
const [showControls, setShowControls] = useState(true);
|
||||||
@@ -169,8 +172,9 @@ export default function page() {
|
|||||||
}, [play, stop])
|
}, [play, stop])
|
||||||
);
|
);
|
||||||
|
|
||||||
useOrientation();
|
const { orientation } = useOrientation();
|
||||||
useOrientationSettings();
|
useOrientationSettings();
|
||||||
|
useAndroidNavigationBar();
|
||||||
|
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
isPlaying: isPlaying,
|
isPlaying: isPlaying,
|
||||||
@@ -212,36 +216,23 @@ export default function page() {
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
width: screenDimensions.width,
|
||||||
flexDirection: "column",
|
height: screenDimensions.height,
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
width: dimensions.width,
|
|
||||||
height: dimensions.height,
|
|
||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
|
className="flex flex-col items-center justify-center"
|
||||||
>
|
>
|
||||||
<SystemBars hidden />
|
<StatusBar hidden />
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setShowControls(!showControls);
|
setShowControls(!showControls);
|
||||||
}}
|
}}
|
||||||
style={{
|
className="absolute z-0 h-full w-full"
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: dimensions.width,
|
|
||||||
height: dimensions.height,
|
|
||||||
zIndex: 0,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Video
|
<Video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
source={videoSource}
|
source={videoSource}
|
||||||
style={{
|
style={{ width: "100%", height: "100%" }}
|
||||||
width: dimensions.width,
|
|
||||||
height: dimensions.height,
|
|
||||||
}}
|
|
||||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
||||||
onProgress={onProgress}
|
onProgress={onProgress}
|
||||||
onError={() => {}}
|
onError={() => {}}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import * as Notifications from "expo-notifications";
|
|||||||
import { router, Stack } from "expo-router";
|
import { router, Stack } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
import * as TaskManager from "expo-task-manager";
|
import * as TaskManager from "expo-task-manager";
|
||||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
@@ -38,7 +39,6 @@ import { AppState } from "react-native";
|
|||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
import { Toaster } from "sonner-native";
|
import { Toaster } from "sonner-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
@@ -326,7 +326,7 @@ function Layout() {
|
|||||||
<PlaySettingsProvider>
|
<PlaySettingsProvider>
|
||||||
<DownloadProvider>
|
<DownloadProvider>
|
||||||
<BottomSheetModalProvider>
|
<BottomSheetModalProvider>
|
||||||
<SystemBars style="light" hidden={false} />
|
<StatusBar style="light" backgroundColor="#000" />
|
||||||
<ThemeProvider value={DarkTheme}>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack initialRouteName="/home">
|
<Stack initialRouteName="/home">
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -334,7 +334,6 @@ function Layout() {
|
|||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
title: "",
|
title: "",
|
||||||
header: () => null,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
|
|||||||
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 |
@@ -1,9 +1,8 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import React, { useCallback, useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { Platform, TouchableOpacity, ViewProps } from "react-native";
|
import { Platform, TouchableOpacity, ViewProps } from "react-native";
|
||||||
import GoogleCast, {
|
import GoogleCast, {
|
||||||
CastButton,
|
|
||||||
CastContext,
|
CastContext,
|
||||||
useCastDevice,
|
useCastDevice,
|
||||||
useDevices,
|
useDevices,
|
||||||
@@ -40,32 +39,18 @@ export const Chromecast: React.FC<Props> = ({
|
|||||||
})();
|
})();
|
||||||
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
}, [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")
|
if (background === "transparent")
|
||||||
return (
|
return (
|
||||||
<>
|
<TouchableOpacity
|
||||||
<TouchableOpacity
|
onPress={() => {
|
||||||
onPress={() => {
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
else CastContext.showCastDialog();
|
||||||
else CastContext.showCastDialog();
|
}}
|
||||||
}}
|
className="rounded-full h-10 w-10 flex items-center justify-center b"
|
||||||
className="rounded-full h-10 w-10 flex items-center justify-center b"
|
{...props}
|
||||||
{...props}
|
>
|
||||||
>
|
<Feather name="cast" size={22} color={"white"} />
|
||||||
<Feather name="cast" size={22} color={"white"} />
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
|
||||||
<AndroidCastButton />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (Platform.OS === "android")
|
if (Platform.OS === "android")
|
||||||
@@ -97,7 +82,6 @@ export const Chromecast: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<Feather name="cast" size={22} color={"white"} />
|
<Feather name="cast" size={22} color={"white"} />
|
||||||
</BlurView>
|
</BlurView>
|
||||||
<AndroidCastButton />
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
getDefaultPlaySettings(item, settings);
|
getDefaultPlaySettings(item, settings);
|
||||||
|
|
||||||
// 4. Set states
|
// 4. Set states
|
||||||
setSelectedMediaSource(mediaSource ?? undefined);
|
setSelectedMediaSource(mediaSource);
|
||||||
setSelectedAudioStream(audioIndex ?? 0);
|
setSelectedAudioStream(audioIndex ?? 0);
|
||||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||||
setMaxBitrate(bitrate);
|
setMaxBitrate(bitrate);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { useFocusEffect, useNavigation } from "expo-router";
|
|||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Alert, View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Chromecast } from "./Chromecast";
|
import { Chromecast } from "./Chromecast";
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
@@ -59,11 +59,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
audioIndex,
|
audioIndex,
|
||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!mediaSource) {
|
|
||||||
Alert.alert("Error", "No media source found for this item.");
|
|
||||||
navigation.goBack();
|
|
||||||
}
|
|
||||||
}, [item, settings])
|
}, [item, settings])
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -246,7 +241,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PlayButton className="grow" />
|
<PlayButton item={item} url={playUrl} className="grow" />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
@@ -6,11 +6,10 @@ import { runtimeTicksToMinutes } from "@/utils/time";
|
|||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { Alert, Linking, TouchableOpacity, View } from "react-native";
|
import { Linking, TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
CastButton,
|
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
@@ -29,31 +28,32 @@ import { Button } from "./Button";
|
|||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
|
||||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {}
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
|
item?: BaseItemDto | null;
|
||||||
|
url?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
const ANIMATION_DURATION = 500;
|
const ANIMATION_DURATION = 500;
|
||||||
const MIN_PLAYBACK_WIDTH = 15;
|
const MIN_PLAYBACK_WIDTH = 15;
|
||||||
|
|
||||||
export const PlayButton: React.FC<Props> = ({ ...props }) => {
|
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||||
const { playSettings, playUrl: url } = usePlaySettings();
|
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
|
|
||||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
|
|
||||||
const router = useRouter();
|
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 startWidth = useSharedValue(0);
|
||||||
const targetWidth = useSharedValue(0);
|
const targetWidth = useSharedValue(0);
|
||||||
const endColor = useSharedValue(colorAtom);
|
const endColor = useSharedValue(memoizedColor);
|
||||||
const startColor = useSharedValue(colorAtom);
|
const startColor = useSharedValue(memoizedColor);
|
||||||
const widthProgress = useSharedValue(0);
|
const widthProgress = useSharedValue(0);
|
||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
@@ -62,11 +62,7 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
|
|||||||
return !url?.includes("m3u8");
|
return !url?.includes("m3u8");
|
||||||
}, [url]);
|
}, [url]);
|
||||||
|
|
||||||
const item = useMemo(() => {
|
const onPress = async () => {
|
||||||
return playSettings?.item;
|
|
||||||
}, [playSettings?.item]);
|
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
|
||||||
if (!url || !item) {
|
if (!url || !item) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"No URL or item provided to PlayButton",
|
"No URL or item provided to PlayButton",
|
||||||
@@ -102,7 +98,7 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
|
|||||||
|
|
||||||
switch (selectedIndex) {
|
switch (selectedIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
await CastContext.getPlayServicesState().then(async (state) => {
|
await CastContext.getPlayServicesState().then((state) => {
|
||||||
if (state && state !== PlayServicesState.SUCCESS)
|
if (state && state !== PlayServicesState.SUCCESS)
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
CastContext.showPlayServicesErrorDialog(state);
|
||||||
else {
|
else {
|
||||||
@@ -112,34 +108,10 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
|
|||||||
CastContext.showExpandedControls();
|
CastContext.showExpandedControls();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get a new URL with the Chromecast device profile:
|
|
||||||
const data = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
deviceProfile: chromecastProfile,
|
|
||||||
item,
|
|
||||||
mediaSourceId: playSettings?.mediaSource?.Id,
|
|
||||||
startTimeTicks: 0,
|
|
||||||
maxStreamingBitrate: playSettings?.bitrate?.value,
|
|
||||||
audioStreamIndex: playSettings?.audioIndex ?? 0,
|
|
||||||
subtitleStreamIndex: playSettings?.subtitleIndex ?? -1,
|
|
||||||
userId: user?.Id,
|
|
||||||
forceDirectPlay: settings?.forceDirectPlay,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!data?.url) {
|
|
||||||
console.warn("No URL returned from getStreamUrl", data);
|
|
||||||
Alert.alert(
|
|
||||||
"Client error",
|
|
||||||
"Could not create stream for Chromecast"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
client
|
client
|
||||||
.loadMedia({
|
.loadMedia({
|
||||||
mediaInfo: {
|
mediaInfo: {
|
||||||
contentUrl: data?.url,
|
contentUrl: url,
|
||||||
contentType: "video/mp4",
|
contentType: "video/mp4",
|
||||||
metadata:
|
metadata:
|
||||||
item.Type === "Episode"
|
item.Type === "Episode"
|
||||||
@@ -212,32 +184,21 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}, [
|
};
|
||||||
url,
|
|
||||||
item,
|
|
||||||
client,
|
|
||||||
settings,
|
|
||||||
api,
|
|
||||||
user,
|
|
||||||
playSettings,
|
|
||||||
router,
|
|
||||||
showActionSheetWithOptions,
|
|
||||||
mediaStatus,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
if (!item || !item.RunTimeTicks) return 0;
|
if (!memoizedItem || !memoizedItem.RunTimeTicks) return 0;
|
||||||
const userData = item.UserData;
|
const userData = memoizedItem.UserData;
|
||||||
if (userData && userData.PlaybackPositionTicks) {
|
if (userData && userData.PlaybackPositionTicks) {
|
||||||
return userData.PlaybackPositionTicks > 0
|
return userData.PlaybackPositionTicks > 0
|
||||||
? Math.max(
|
? Math.max(
|
||||||
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
(userData.PlaybackPositionTicks / memoizedItem.RunTimeTicks) * 100,
|
||||||
MIN_PLAYBACK_WIDTH
|
MIN_PLAYBACK_WIDTH
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}, [item]);
|
}, [memoizedItem]);
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
() => derivedTargetWidth.value,
|
() => derivedTargetWidth.value,
|
||||||
@@ -253,7 +214,7 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
() => colorAtom,
|
() => memoizedColor,
|
||||||
(newColor) => {
|
(newColor) => {
|
||||||
endColor.value = newColor;
|
endColor.value = newColor;
|
||||||
colorChangeProgress.value = 0;
|
colorChangeProgress.value = 0;
|
||||||
@@ -262,19 +223,19 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
|
|||||||
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[colorAtom]
|
[memoizedColor]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout_2 = setTimeout(() => {
|
const timeout_2 = setTimeout(() => {
|
||||||
startColor.value = colorAtom;
|
startColor.value = memoizedColor;
|
||||||
startWidth.value = targetWidth.value;
|
startWidth.value = targetWidth.value;
|
||||||
}, ANIMATION_DURATION);
|
}, ANIMATION_DURATION);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeout_2);
|
clearTimeout(timeout_2);
|
||||||
};
|
};
|
||||||
}, [colorAtom, item]);
|
}, [memoizedColor, memoizedItem]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ANIMATED STYLES
|
* ANIMATED STYLES
|
||||||
@@ -357,7 +318,6 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
|
|||||||
{client && (
|
{client && (
|
||||||
<Animated.Text style={animatedTextStyle}>
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<Feather name="cast" size={22} />
|
<Feather name="cast" size={22} />
|
||||||
<CastButton tintColor="transparent" />
|
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
)}
|
)}
|
||||||
{!client && settings?.openInVLC && (
|
{!client && settings?.openInVLC && (
|
||||||
|
|||||||
22
components/_page_template.tsx
Normal file
22
components/_page_template.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,9 +11,12 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { TouchableOpacityProps, View } from "react-native";
|
import { TouchableOpacityProps, View } from "react-native";
|
||||||
|
import { getColors } from "react-native-image-colors";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
library: BaseItemDto;
|
library: BaseItemDto;
|
||||||
@@ -50,6 +53,10 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
[library]
|
[library]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If we want to use image colors for library cards
|
||||||
|
// const [color] = useAtom(itemThemeColorAtom)
|
||||||
|
// useImageColors({ url });
|
||||||
|
|
||||||
const { data: itemsCount } = useQuery({
|
const { data: itemsCount } = useQuery({
|
||||||
queryKey: ["library-count", library.Id],
|
queryKey: ["library-count", library.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -61,7 +68,6 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
});
|
});
|
||||||
return response.data.TotalRecordCount;
|
return response.data.TotalRecordCount;
|
||||||
},
|
},
|
||||||
staleTime: 1000 * 60 * 60,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
|
|||||||
64
components/list/ListInputItem.tsx
Normal file
64
components/list/ListInputItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
44
components/list/ListItem.tsx
Normal file
44
components/list/ListItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
24
components/list/ListSection.tsx
Normal file
24
components/list/ListSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
registerBackgroundFetchAsync,
|
registerBackgroundFetchAsync,
|
||||||
unregisterBackgroundFetchAsync,
|
unregisterBackgroundFetchAsync,
|
||||||
} from "@/utils/background-tasks";
|
} from "@/utils/background-tasks";
|
||||||
|
import { getStatistics } from "@/utils/optimize-server";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import * as BackgroundFetch from "expo-background-fetch";
|
import * as BackgroundFetch from "expo-background-fetch";
|
||||||
@@ -18,7 +19,6 @@ import * as TaskManager from "expo-task-manager";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
|
||||||
Linking,
|
Linking,
|
||||||
Switch,
|
Switch,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -32,8 +32,6 @@ import { Input } from "../common/Input";
|
|||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { MediaToggles } from "./MediaToggles";
|
import { MediaToggles } from "./MediaToggles";
|
||||||
import axios from "axios";
|
|
||||||
import { getStatistics } from "@/utils/optimize-server";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,44 @@ export const Controls: React.FC<Props> = ({
|
|||||||
|
|
||||||
const windowDimensions = Dimensions.get("window");
|
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 { previousItem, nextItem } = useAdjacentItems({ item });
|
||||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||||
item,
|
item,
|
||||||
@@ -85,6 +123,17 @@ export const Controls: React.FC<Props> = ({
|
|||||||
|
|
||||||
const wasPlayingRef = useRef(false);
|
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(
|
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||||
item.Id,
|
item.Id,
|
||||||
currentTime,
|
currentTime,
|
||||||
@@ -131,23 +180,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
router.replace("/play-video");
|
router.replace("/play-video");
|
||||||
}, [nextItem, settings]);
|
}, [nextItem, settings]);
|
||||||
|
|
||||||
const updateTimes = useCallback(
|
|
||||||
(currentProgress: number, maxValue: number) => {
|
|
||||||
const current = ticksToSeconds(currentProgress);
|
|
||||||
const remaining = ticksToSeconds(maxValue - currentProgress);
|
|
||||||
|
|
||||||
setCurrentTime(current);
|
|
||||||
setRemainingTime(remaining);
|
|
||||||
|
|
||||||
if (currentProgress === maxValue) {
|
|
||||||
setShowControls(true);
|
|
||||||
// Automatically play the next item if it exists
|
|
||||||
goToNextItem();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[goToNextItem]
|
|
||||||
);
|
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
() => ({
|
() => ({
|
||||||
progress: progress.value,
|
progress: progress.value,
|
||||||
@@ -284,7 +316,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
toggleControls();
|
toggleControls();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -293,9 +325,10 @@ export const Controls: React.FC<Props> = ({
|
|||||||
width: windowDimensions.width + 100,
|
width: windowDimensions.width + 100,
|
||||||
height: windowDimensions.height + 100,
|
height: windowDimensions.height + 100,
|
||||||
},
|
},
|
||||||
|
animatedStyles,
|
||||||
]}
|
]}
|
||||||
className={`bg-black/50 z-0`}
|
className={`bg-black/50 z-0`}
|
||||||
></View>
|
></Animated.View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
@@ -314,14 +347,14 @@ export const Controls: React.FC<Props> = ({
|
|||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: insets.top,
|
top: insets.top,
|
||||||
right: insets.right,
|
right: insets.right,
|
||||||
opacity: showControls ? 1 : 0,
|
|
||||||
},
|
},
|
||||||
|
animatedTopStyles,
|
||||||
]}
|
]}
|
||||||
pointerEvents={showControls ? "auto" : "none"}
|
pointerEvents={showControls ? "auto" : "none"}
|
||||||
className={`flex flex-row items-center space-x-2 z-10 p-4`}
|
className={`flex flex-row items-center space-x-2 z-10 p-4`}
|
||||||
@@ -344,9 +377,9 @@ export const Controls: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<Ionicons name="close" size={24} color="white" />
|
<Ionicons name="close" size={24} color="white" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</Animated.View>
|
||||||
|
|
||||||
<View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -354,8 +387,8 @@ export const Controls: React.FC<Props> = ({
|
|||||||
maxHeight: windowDimensions.height,
|
maxHeight: windowDimensions.height,
|
||||||
left: insets.left,
|
left: insets.left,
|
||||||
bottom: Platform.OS === "ios" ? insets.bottom : insets.bottom,
|
bottom: Platform.OS === "ios" ? insets.bottom : insets.bottom,
|
||||||
opacity: showControls ? 1 : 0,
|
|
||||||
},
|
},
|
||||||
|
animatedBottomStyles,
|
||||||
]}
|
]}
|
||||||
pointerEvents={showControls ? "auto" : "none"}
|
pointerEvents={showControls ? "auto" : "none"}
|
||||||
className={`flex flex-col p-4 `}
|
className={`flex flex-col p-4 `}
|
||||||
@@ -490,7 +523,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
4
eas.json
4
eas.json
@@ -22,13 +22,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.18.0",
|
"channel": "0.17.0",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.18.0",
|
"channel": "0.17.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
Object.defineProperty(exports, "__esModule", {
|
|
||||||
value: true
|
|
||||||
});
|
|
||||||
exports.default = void 0;
|
|
||||||
var _configPlugins = require("@expo/config-plugins");
|
|
||||||
const withAndroidEdgeToEdgeTheme = (config, props) => {
|
|
||||||
const themes = {
|
|
||||||
Material2: "Theme.EdgeToEdge.Material2",
|
|
||||||
Material3: "Theme.EdgeToEdge.Material3"
|
|
||||||
};
|
|
||||||
const ignoreList = new Set(["android:enforceNavigationBarContrast", "android:enforceStatusBarContrast", "android:fitsSystemWindows", "android:navigationBarColor", "android:statusBarColor", "android:windowDrawsSystemBarBackgrounds", "android:windowLayoutInDisplayCutoutMode", "android:windowLightNavigationBar", "android:windowLightStatusBar", "android:windowTranslucentNavigation", "android:windowTranslucentStatus"]);
|
|
||||||
return (0, _configPlugins.withAndroidStyles)(config, config => {
|
|
||||||
const {
|
|
||||||
androidStatusBar = {},
|
|
||||||
userInterfaceStyle = "light"
|
|
||||||
} = config;
|
|
||||||
const {
|
|
||||||
barStyle
|
|
||||||
} = androidStatusBar;
|
|
||||||
const {
|
|
||||||
android = {}
|
|
||||||
} = props;
|
|
||||||
const {
|
|
||||||
parentTheme = "Default"
|
|
||||||
} = android;
|
|
||||||
config.modResults.resources.style = config.modResults.resources.style?.map(style => {
|
|
||||||
if (style.$.name === "AppTheme") {
|
|
||||||
style.$.parent = themes[parentTheme] ?? "Theme.EdgeToEdge";
|
|
||||||
if (style.item != null) {
|
|
||||||
style.item = style.item.filter(item => !ignoreList.has(item.$.name));
|
|
||||||
}
|
|
||||||
if (barStyle != null) {
|
|
||||||
style.item.push({
|
|
||||||
$: {
|
|
||||||
name: "android:windowLightStatusBar"
|
|
||||||
},
|
|
||||||
_: String(barStyle === "dark-content")
|
|
||||||
});
|
|
||||||
} else if (userInterfaceStyle !== "automatic") {
|
|
||||||
style.item.push({
|
|
||||||
$: {
|
|
||||||
name: "android:windowLightStatusBar"
|
|
||||||
},
|
|
||||||
_: String(userInterfaceStyle === "light")
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return style;
|
|
||||||
});
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
var _default = exports.default = (0, _configPlugins.createRunOncePlugin)(withAndroidEdgeToEdgeTheme, "react-native-edge-to-edge");
|
|
||||||
//# sourceMappingURL=expo.js.map
|
|
||||||
17
hooks/useAndroidNavigationBar.ts
Normal file
17
hooks/useAndroidNavigationBar.ts
Normal 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");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
27
hooks/useNavigationBarVisibility.ts
Normal file
27
hooks/useNavigationBarVisibility.ts
Normal 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]);
|
||||||
|
};
|
||||||
20
package.json
20
package.json
@@ -9,8 +9,7 @@
|
|||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"test": "jest --watchAll",
|
"test": "jest --watchAll",
|
||||||
"lint": "expo lint",
|
"lint": "expo lint"
|
||||||
"postinstall": "patch-package"
|
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"preset": "jest-expo"
|
"preset": "jest-expo"
|
||||||
@@ -31,16 +30,14 @@
|
|||||||
"@shopify/flash-list": "1.6.4",
|
"@shopify/flash-list": "1.6.4",
|
||||||
"@tanstack/react-query": "^5.56.2",
|
"@tanstack/react-query": "^5.56.2",
|
||||||
"@types/lodash": "^4.17.9",
|
"@types/lodash": "^4.17.9",
|
||||||
"@types/react-native-vector-icons": "^6.4.18",
|
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"add": "^2.0.6",
|
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"expo": "~51.0.39",
|
"expo": "~51.0.36",
|
||||||
"expo-background-fetch": "~12.0.1",
|
"expo-background-fetch": "~12.0.1",
|
||||||
"expo-blur": "~13.0.2",
|
"expo-blur": "~13.0.2",
|
||||||
"expo-build-properties": "~0.12.5",
|
"expo-build-properties": "~0.12.5",
|
||||||
"expo-constants": "~16.0.2",
|
"expo-constants": "~16.0.2",
|
||||||
"expo-dev-client": "~4.0.29",
|
"expo-dev-client": "~4.0.27",
|
||||||
"expo-device": "~6.0.2",
|
"expo-device": "~6.0.2",
|
||||||
"expo-font": "~12.0.10",
|
"expo-font": "~12.0.10",
|
||||||
"expo-haptics": "~13.0.1",
|
"expo-haptics": "~13.0.1",
|
||||||
@@ -48,12 +45,13 @@
|
|||||||
"expo-keep-awake": "~13.0.2",
|
"expo-keep-awake": "~13.0.2",
|
||||||
"expo-linear-gradient": "~13.0.2",
|
"expo-linear-gradient": "~13.0.2",
|
||||||
"expo-linking": "~6.3.1",
|
"expo-linking": "~6.3.1",
|
||||||
|
"expo-navigation-bar": "~3.0.7",
|
||||||
"expo-network": "~6.0.1",
|
"expo-network": "~6.0.1",
|
||||||
"expo-notifications": "~0.28.19",
|
"expo-notifications": "~0.28.18",
|
||||||
"expo-router": "~3.5.24",
|
"expo-router": "~3.5.23",
|
||||||
"expo-screen-orientation": "~7.0.5",
|
"expo-screen-orientation": "~7.0.5",
|
||||||
"expo-sensors": "~13.0.9",
|
"expo-sensors": "~13.0.9",
|
||||||
"expo-splash-screen": "~0.27.7",
|
"expo-splash-screen": "~0.27.6",
|
||||||
"expo-status-bar": "~1.12.1",
|
"expo-status-bar": "~1.12.1",
|
||||||
"expo-system-ui": "~3.0.7",
|
"expo-system-ui": "~3.0.7",
|
||||||
"expo-task-manager": "~11.8.2",
|
"expo-task-manager": "~11.8.2",
|
||||||
@@ -68,10 +66,8 @@
|
|||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-native": "0.74.5",
|
"react-native": "0.74.5",
|
||||||
"react-native-awesome-slider": "^2.5.3",
|
"react-native-awesome-slider": "^2.5.3",
|
||||||
"react-native-bottom-tabs": "^0.4.0",
|
|
||||||
"react-native-circular-progress": "^1.4.0",
|
"react-native-circular-progress": "^1.4.0",
|
||||||
"react-native-compressor": "^1.8.25",
|
"react-native-compressor": "^1.8.25",
|
||||||
"react-native-edge-to-edge": "^1.1.0",
|
|
||||||
"react-native-gesture-handler": "~2.16.1",
|
"react-native-gesture-handler": "~2.16.1",
|
||||||
"react-native-get-random-values": "^1.11.0",
|
"react-native-get-random-values": "^1.11.0",
|
||||||
"react-native-google-cast": "^4.8.3",
|
"react-native-google-cast": "^4.8.3",
|
||||||
@@ -105,8 +101,6 @@
|
|||||||
"@types/react-test-renderer": "^18.0.7",
|
"@types/react-test-renderer": "^18.0.7",
|
||||||
"jest": "^29.2.1",
|
"jest": "^29.2.1",
|
||||||
"jest-expo": "~51.0.4",
|
"jest-expo": "~51.0.4",
|
||||||
"patch-package": "^8.0.0",
|
|
||||||
"postinstall-postinstall": "^2.1.0",
|
|
||||||
"react-test-renderer": "18.2.0",
|
"react-test-renderer": "18.2.0",
|
||||||
"typescript": "~5.3.3"
|
"typescript": "~5.3.3"
|
||||||
},
|
},
|
||||||
|
|||||||
42
plugins/withAndroidMainActivityAttributes.js
Normal file
42
plugins/withAndroidMainActivityAttributes.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
const { withAndroidManifest } = require("@expo/config-plugins");
|
||||||
|
|
||||||
|
function addAttributesToMainActivity(androidManifest, attributes) {
|
||||||
|
const { manifest } = androidManifest;
|
||||||
|
|
||||||
|
if (!Array.isArray(manifest["application"])) {
|
||||||
|
console.warn("withAndroidMainActivityAttributes: No application array in manifest?");
|
||||||
|
return androidManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const application = manifest["application"].find(
|
||||||
|
(item) => item.$["android:name"] === ".MainApplication"
|
||||||
|
);
|
||||||
|
if (!application) {
|
||||||
|
console.warn("withAndroidMainActivityAttributes: No .MainApplication?");
|
||||||
|
return androidManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(application["activity"])) {
|
||||||
|
console.warn("withAndroidMainActivityAttributes: No activity array in .MainApplication?");
|
||||||
|
return androidManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activity = application["activity"].find(
|
||||||
|
(item) => item.$["android:name"] === ".MainActivity"
|
||||||
|
);
|
||||||
|
if (!activity) {
|
||||||
|
console.warn("withAndroidMainActivityAttributes: No .MainActivity?");
|
||||||
|
return androidManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
activity.$ = { ...activity.$, ...attributes };
|
||||||
|
|
||||||
|
return androidManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function withAndroidMainActivityAttributes(config, attributes) {
|
||||||
|
return withAndroidManifest(config, (config) => {
|
||||||
|
config.modResults = addAttributesToMainActivity(config.modResults, attributes);
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
};
|
||||||
20
plugins/withExpandedController.js
Normal file
20
plugins/withExpandedController.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const { withAppDelegate } = require("@expo/config-plugins");
|
||||||
|
|
||||||
|
const withExpandedController = (config) => {
|
||||||
|
return withAppDelegate(config, async (config) => {
|
||||||
|
const contents = config.modResults.contents;
|
||||||
|
|
||||||
|
// Looking for the initialProps string inside didFinishLaunchingWithOptions,
|
||||||
|
// and injecting expanded controller config.
|
||||||
|
// Should be updated once there is an expo config option - see https://github.com/react-native-google-cast/react-native-google-cast/discussions/537
|
||||||
|
const injectionIndex = contents.indexOf("self.initialProps = @{};");
|
||||||
|
config.modResults.contents =
|
||||||
|
contents.substring(0, injectionIndex) +
|
||||||
|
`\n [GCKCastContext sharedInstance].useDefaultExpandedMediaControls = true; \n` +
|
||||||
|
contents.substring(injectionIndex);
|
||||||
|
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = withExpandedController;
|
||||||
@@ -52,7 +52,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.18.0" },
|
clientInfo: { name: "Streamyfin", version: "0.17.0" },
|
||||||
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -86,7 +86,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="0.18.0"`,
|
}, DeviceId="${deviceId}", Version="0.17.0"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Settings } from "../atoms/settings";
|
|||||||
interface PlaySettings {
|
interface PlaySettings {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
bitrate: (typeof BITRATES)[0];
|
bitrate: (typeof BITRATES)[0];
|
||||||
mediaSource?: MediaSourceInfo | null;
|
mediaSource: MediaSourceInfo | undefined;
|
||||||
audioIndex?: number | null;
|
audioIndex?: number | null;
|
||||||
subtitleIndex?: number | null;
|
subtitleIndex?: number | null;
|
||||||
}
|
}
|
||||||
@@ -29,9 +29,10 @@ export function getDefaultPlaySettings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Get first media source
|
// 1. Get first media source
|
||||||
|
|
||||||
const mediaSource = item.MediaSources?.[0];
|
const mediaSource = item.MediaSources?.[0];
|
||||||
|
|
||||||
|
if (!mediaSource) throw new Error("No media source found");
|
||||||
|
|
||||||
// 2. Get default or preferred audio
|
// 2. Get default or preferred audio
|
||||||
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
||||||
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ export const runtimeTicksToMinutes = (
|
|||||||
const hours = Math.floor(ticks / ticksPerHour);
|
const hours = Math.floor(ticks / ticksPerHour);
|
||||||
const minutes = Math.floor((ticks % ticksPerHour) / ticksPerMinute);
|
const minutes = Math.floor((ticks % ticksPerHour) / ticksPerMinute);
|
||||||
|
|
||||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
return `${hours}h ${minutes}m`;
|
||||||
else return `${minutes}m`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const runtimeTicksToSeconds = (
|
export const runtimeTicksToSeconds = (
|
||||||
|
|||||||
Reference in New Issue
Block a user