Compare commits

..

1 Commits

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

2
.gitignore vendored
View File

@@ -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

View File

@@ -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

View File

@@ -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} />
))} ))}

View File

@@ -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 />

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 { 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>

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

@@ -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();

View File

@@ -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">

View File

@@ -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>
); );
} }

View File

@@ -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

View File

@@ -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}

View File

@@ -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={() => {}}

View File

@@ -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

BIN
bun.lockb

Binary file not shown.

View File

@@ -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>
); );
}; };

View File

@@ -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);

View File

@@ -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" && (

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

@@ -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 && (

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

@@ -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;

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

@@ -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 {}

View File

@@ -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>
); );
}; };

View File

@@ -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"

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

@@ -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

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

@@ -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

@@ -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"
}, },

View 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;
});
};

View 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;

View File

@@ -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]);

View File

@@ -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(

View File

@@ -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 = (