mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
fix: tv playback (#820)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> Signed-off-by: lancechant <13349722+lancechant@users.noreply.github.com> Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com> Co-authored-by: Uruk <contact@uruk.dev> Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
This commit is contained in:
14
.claude/settings.local.json
Normal file
14
.claude/settings.local.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(bun install:*)",
|
||||||
|
"Bash(bunx expo prebuild:*)",
|
||||||
|
"Bash(bunx expo run:*)",
|
||||||
|
"Bash(npx expo prebuild:*)",
|
||||||
|
"Bash(npx expo run:*)",
|
||||||
|
"Bash(xcodebuild:*)"
|
||||||
|
],
|
||||||
|
"deny": []
|
||||||
|
}
|
||||||
|
}
|
||||||
7
.cursor/rules/no-custom-ios-folder-logic.mdc
Normal file
7
.cursor/rules/no-custom-ios-folder-logic.mdc
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
description: Don't write code directly in the ios folder.
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
We never write code directly in the ios folder. This code is generated by expo plugins.
|
||||||
2
.github/workflows/build-ios.yml
vendored
2
.github/workflows/build-ios.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
|||||||
- name: 🏗 Setup EAS
|
- name: 🏗 Setup EAS
|
||||||
uses: expo/expo-github-action@main
|
uses: expo/expo-github-action@main
|
||||||
with:
|
with:
|
||||||
eas-version: 16.7.1
|
eas-version: 16.17.4
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
- name: 🏗️ Build iOS app
|
- name: 🏗️ Build iOS app
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ module.exports = ({ config }) => {
|
|||||||
"react-native-google-cast",
|
"react-native-google-cast",
|
||||||
{ useDefaultExpandedMediaControls: true },
|
{ useDefaultExpandedMediaControls: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Add the background downloader plugin only for non-TV builds
|
||||||
|
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
android: {
|
android: {
|
||||||
|
|||||||
18
app.json
18
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.29.6",
|
"version": "0.29.13",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -32,7 +32,8 @@
|
|||||||
"dark": "./assets/images/icon-plain.png",
|
"dark": "./assets/images/icon-plain.png",
|
||||||
"light": "./assets/images/icon-ios-light.png",
|
"light": "./assets/images/icon-ios-light.png",
|
||||||
"tinted": "./assets/images/icon-ios-tinted.png"
|
"tinted": "./assets/images/icon-ios-tinted.png"
|
||||||
}
|
},
|
||||||
|
"appleTeamId": "MWD5K362T8"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
@@ -72,10 +73,7 @@
|
|||||||
{
|
{
|
||||||
"ios": {
|
"ios": {
|
||||||
"deploymentTarget": "15.6",
|
"deploymentTarget": "15.6",
|
||||||
"extraPods": [
|
"useFrameworks": "static"
|
||||||
{ "name": "SDWebImage", "modular_headers": true },
|
|
||||||
{ "name": "SDWebImageSVGCoder", "modular_headers": true }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"compileSdkVersion": 35,
|
"compileSdkVersion": 35,
|
||||||
@@ -120,7 +118,6 @@
|
|||||||
["./plugins/withAndroidManifest.js"],
|
["./plugins/withAndroidManifest.js"],
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
["./plugins/withGradleProperties.js"],
|
["./plugins/withGradleProperties.js"],
|
||||||
["./plugins/withRNBackgroundDownloader.js"],
|
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
@@ -136,12 +133,7 @@
|
|||||||
"color": "#9333EA"
|
"color": "#9333EA"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
"./plugins/with-runtime-framework-headers.js",
|
||||||
"react-native-google-cast",
|
|
||||||
{
|
|
||||||
"useDefaultExpandedMediaControls": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"react-native-bottom-tabs"
|
"react-native-bottom-tabs"
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function SearchLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.favorites"),
|
headerTitle: t("tabs.favorites"),
|
||||||
headerLargeStyle: {
|
headerLargeStyle: {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default function IndexLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.home"),
|
headerTitle: t("tabs.home"),
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Image } from "expo-image";
|
|||||||
import { useFocusEffect, useRouter } from "expo-router";
|
import { useFocusEffect, useRouter } from "expo-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Linking, TouchableOpacity, View } from "react-native";
|
import { Linking, Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
@@ -19,7 +19,9 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='bg-neutral-900 h-full py-16 px-4 space-y-8'>
|
<View
|
||||||
|
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 space-y-4" : "py-16 space-y-8"} px-4`}
|
||||||
|
>
|
||||||
<View>
|
<View>
|
||||||
<Text className='text-3xl font-bold text-center mb-2'>
|
<Text className='text-3xl font-bold text-center mb-2'>
|
||||||
{t("home.intro.welcome_to_streamyfin")}
|
{t("home.intro.welcome_to_streamyfin")}
|
||||||
@@ -49,42 +51,50 @@ export default function page() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-row items-center mt-4'>
|
{!Platform.isTV && (
|
||||||
<View
|
<>
|
||||||
style={{
|
<View className='flex flex-row items-center mt-4'>
|
||||||
width: 50,
|
<View
|
||||||
height: 50,
|
style={{
|
||||||
}}
|
width: 50,
|
||||||
className='flex items-center justify-center'
|
height: 50,
|
||||||
>
|
}}
|
||||||
<Ionicons name='cloud-download-outline' size={32} color='white' />
|
className='flex items-center justify-center'
|
||||||
</View>
|
>
|
||||||
<View className='shrink ml-2'>
|
<Ionicons
|
||||||
<Text className='font-bold mb-1'>
|
name='cloud-download-outline'
|
||||||
{t("home.intro.downloads_feature_title")}
|
size={32}
|
||||||
</Text>
|
color='white'
|
||||||
<Text className='shrink text-xs'>
|
/>
|
||||||
{t("home.intro.downloads_feature_description")}
|
</View>
|
||||||
</Text>
|
<View className='shrink ml-2'>
|
||||||
</View>
|
<Text className='font-bold mb-1'>
|
||||||
</View>
|
{t("home.intro.downloads_feature_title")}
|
||||||
<View className='flex flex-row items-center mt-4'>
|
</Text>
|
||||||
<View
|
<Text className='shrink text-xs'>
|
||||||
style={{
|
{t("home.intro.downloads_feature_description")}
|
||||||
width: 50,
|
</Text>
|
||||||
height: 50,
|
</View>
|
||||||
}}
|
</View>
|
||||||
className='flex items-center justify-center'
|
<View className='flex flex-row items-center mt-4'>
|
||||||
>
|
<View
|
||||||
<Feather name='cast' size={28} color={"white"} />
|
style={{
|
||||||
</View>
|
width: 50,
|
||||||
<View className='shrink ml-2'>
|
height: 50,
|
||||||
<Text className='font-bold mb-1'>Chromecast</Text>
|
}}
|
||||||
<Text className='shrink text-xs'>
|
className='flex items-center justify-center'
|
||||||
{t("home.intro.chromecast_feature_description")}
|
>
|
||||||
</Text>
|
<Feather name='cast' size={28} color={"white"} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
<View className='shrink ml-2'>
|
||||||
|
<Text className='font-bold mb-1'>Chromecast</Text>
|
||||||
|
<Text className='shrink text-xs'>
|
||||||
|
{t("home.intro.chromecast_feature_description")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<View className='flex flex-row items-center mt-4'>
|
<View className='flex flex-row items-center mt-4'>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -99,19 +109,22 @@ export default function page() {
|
|||||||
<Text className='font-bold mb-1'>
|
<Text className='font-bold mb-1'>
|
||||||
{t("home.intro.centralised_settings_plugin_title")}
|
{t("home.intro.centralised_settings_plugin_title")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='shrink text-xs'>
|
<View className='flex-row flex-wrap items-baseline'>
|
||||||
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
<Text className='shrink text-xs'>
|
||||||
<Text
|
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
||||||
className='text-purple-600'
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
Linking.openURL(
|
Linking.openURL(
|
||||||
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
|
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("home.intro.read_more")}
|
<Text className='text-xs text-purple-600 underline'>
|
||||||
</Text>
|
{t("home.intro.read_more")}
|
||||||
</Text>
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -434,8 +434,6 @@ const TranscodingStreamView = ({
|
|||||||
isTranscoding,
|
isTranscoding,
|
||||||
properties,
|
properties,
|
||||||
transcodeProperties,
|
transcodeProperties,
|
||||||
value,
|
|
||||||
transcodeValue,
|
|
||||||
}: TranscodingStreamViewProps) => {
|
}: TranscodingStreamViewProps) => {
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-col pt-2 first:pt-0'>
|
<View className='flex flex-col pt-2 first:pt-0'>
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default function page() {
|
|||||||
{new Date(log.timestamp).toLocaleString()}
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text uiTextView selectable className='text-xs'>
|
<Text selectable className='text-xs'>
|
||||||
{log.message}
|
{log.message}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function page() {
|
|||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
|
||||||
const { companyId, name, image, type } = local as unknown as {
|
const { companyId, image, type } = local as unknown as {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
name: string;
|
name: string;
|
||||||
image: string;
|
image: string;
|
||||||
|
|||||||
@@ -221,11 +221,7 @@ const Page: React.FC = () => {
|
|||||||
| TvDetails
|
| TvDetails
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text selectable className='font-bold text-2xl mb-1'>
|
||||||
uiTextView
|
|
||||||
selectable
|
|
||||||
className='font-bold text-2xl mb-1'
|
|
||||||
>
|
|
||||||
{mediaTitle}
|
{mediaTitle}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='opacity-50'>{releaseYear}</Text>
|
<Text className='opacity-50'>{releaseYear}</Text>
|
||||||
@@ -256,26 +252,28 @@ const Page: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
details?.mediaInfo?.jellyfinMediaId && (
|
details?.mediaInfo?.jellyfinMediaId && (
|
||||||
<View className='flex flex-row space-x-2 mt-4'>
|
<View className='flex flex-row space-x-2 mt-4'>
|
||||||
<Button
|
{!Platform.isTV && (
|
||||||
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
<Button
|
||||||
color='transparent'
|
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
||||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
color='transparent'
|
||||||
iconLeft={
|
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||||
<Ionicons
|
iconLeft={
|
||||||
name='warning-outline'
|
<Ionicons
|
||||||
size={20}
|
name='warning-outline'
|
||||||
color='white'
|
size={20}
|
||||||
/>
|
color='white'
|
||||||
}
|
/>
|
||||||
style={{
|
}
|
||||||
borderWidth: 1,
|
style={{
|
||||||
borderStyle: "solid",
|
borderWidth: 1,
|
||||||
}}
|
borderStyle: "solid",
|
||||||
>
|
}}
|
||||||
<Text className='text-sm'>
|
>
|
||||||
{t("jellyseerr.report_issue_button")}
|
<Text className='text-sm'>
|
||||||
</Text>
|
{t("jellyseerr.report_issue_button")}
|
||||||
</Button>
|
</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
|
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -333,92 +331,95 @@ const Page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
onDismiss={() => _setRequestBody(undefined)}
|
onDismiss={() => _setRequestBody(undefined)}
|
||||||
/>
|
/>
|
||||||
<BottomSheetModal
|
{!Platform.isTV && (
|
||||||
ref={bottomSheetModalRef}
|
// This is till it's fixed because the menu isn't selectable on TV
|
||||||
enableDynamicSizing
|
<BottomSheetModal
|
||||||
handleIndicatorStyle={{
|
ref={bottomSheetModalRef}
|
||||||
backgroundColor: "white",
|
enableDynamicSizing
|
||||||
}}
|
handleIndicatorStyle={{
|
||||||
backgroundStyle={{
|
backgroundColor: "white",
|
||||||
backgroundColor: "#171717",
|
}}
|
||||||
}}
|
backgroundStyle={{
|
||||||
backdropComponent={renderBackdrop}
|
backgroundColor: "#171717",
|
||||||
>
|
}}
|
||||||
<BottomSheetView>
|
backdropComponent={renderBackdrop}
|
||||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
>
|
||||||
<View>
|
<BottomSheetView>
|
||||||
<Text className='font-bold text-2xl text-neutral-100'>
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
{t("jellyseerr.whats_wrong")}
|
<View>
|
||||||
</Text>
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
</View>
|
{t("jellyseerr.whats_wrong")}
|
||||||
<View className='flex flex-col space-y-2 items-start'>
|
</Text>
|
||||||
<View className='flex flex-col'>
|
</View>
|
||||||
<DropdownMenu.Root>
|
<View className='flex flex-col space-y-2 items-start'>
|
||||||
<DropdownMenu.Trigger>
|
<View className='flex flex-col'>
|
||||||
<View className='flex flex-col'>
|
<DropdownMenu.Root>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
<DropdownMenu.Trigger>
|
||||||
{t("jellyseerr.issue_type")}
|
<View className='flex flex-col'>
|
||||||
</Text>
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
{t("jellyseerr.issue_type")}
|
||||||
<Text style={{}} className='' numberOfLines={1}>
|
|
||||||
{issueType
|
|
||||||
? IssueTypeName[issueType]
|
|
||||||
: t("jellyseerr.select_an_issue")}
|
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
</View>
|
<Text style={{}} className='' numberOfLines={1}>
|
||||||
</DropdownMenu.Trigger>
|
{issueType
|
||||||
<DropdownMenu.Content
|
? IssueTypeName[issueType]
|
||||||
loop={false}
|
: t("jellyseerr.select_an_issue")}
|
||||||
side='bottom'
|
</Text>
|
||||||
align='center'
|
</TouchableOpacity>
|
||||||
alignOffset={0}
|
</View>
|
||||||
avoidCollisions={true}
|
</DropdownMenu.Trigger>
|
||||||
collisionPadding={0}
|
<DropdownMenu.Content
|
||||||
sideOffset={0}
|
loop={false}
|
||||||
>
|
side='bottom'
|
||||||
<DropdownMenu.Label>
|
align='center'
|
||||||
{t("jellyseerr.types")}
|
alignOffset={0}
|
||||||
</DropdownMenu.Label>
|
avoidCollisions={true}
|
||||||
{Object.entries(IssueTypeName)
|
collisionPadding={0}
|
||||||
.reverse()
|
sideOffset={0}
|
||||||
.map(([key, value], _idx) => (
|
>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Label>
|
||||||
key={value}
|
{t("jellyseerr.types")}
|
||||||
onSelect={() =>
|
</DropdownMenu.Label>
|
||||||
setIssueType(key as unknown as IssueType)
|
{Object.entries(IssueTypeName)
|
||||||
}
|
.reverse()
|
||||||
>
|
.map(([key, value], _idx) => (
|
||||||
<DropdownMenu.ItemTitle>
|
<DropdownMenu.Item
|
||||||
{value}
|
key={value}
|
||||||
</DropdownMenu.ItemTitle>
|
onSelect={() =>
|
||||||
</DropdownMenu.Item>
|
setIssueType(key as unknown as IssueType)
|
||||||
))}
|
}
|
||||||
</DropdownMenu.Content>
|
>
|
||||||
</DropdownMenu.Root>
|
<DropdownMenu.ItemTitle>
|
||||||
</View>
|
{value}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
||||||
<BottomSheetTextInput
|
<BottomSheetTextInput
|
||||||
multiline
|
multiline
|
||||||
maxLength={254}
|
maxLength={254}
|
||||||
style={{ color: "white" }}
|
style={{ color: "white" }}
|
||||||
clearButtonMode='always'
|
clearButtonMode='always'
|
||||||
placeholder={t("jellyseerr.describe_the_issue")}
|
placeholder={t("jellyseerr.describe_the_issue")}
|
||||||
placeholderTextColor='#9CA3AF'
|
placeholderTextColor='#9CA3AF'
|
||||||
// Issue with multiline + Textinput inside a portal
|
// Issue with multiline + Textinput inside a portal
|
||||||
// https://github.com/callstack/react-native-paper/issues/1668
|
// https://github.com/callstack/react-native-paper/issues/1668
|
||||||
defaultValue={issueMessage}
|
defaultValue={issueMessage}
|
||||||
onChangeText={setIssueMessage}
|
onChangeText={setIssueMessage}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<Button className='mt-auto' onPress={submitIssue} color='purple'>
|
||||||
|
{t("jellyseerr.submit_button")}
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
<Button className='mt-auto' onPress={submitIssue} color='purple'>
|
</BottomSheetView>
|
||||||
{t("jellyseerr.submit_button")}
|
</BottomSheetModal>
|
||||||
</Button>
|
)}
|
||||||
</View>
|
|
||||||
</BottomSheetView>
|
|
||||||
</BottomSheetModal>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,14 +21,13 @@ export default function page() {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
jellyseerrApi,
|
jellyseerrApi,
|
||||||
jellyseerrUser,
|
|
||||||
jellyseerrRegion: region,
|
jellyseerrRegion: region,
|
||||||
jellyseerrLocale: locale,
|
jellyseerrLocale: locale,
|
||||||
} = useJellyseerr();
|
} = useJellyseerr();
|
||||||
|
|
||||||
const { personId } = local as { personId: string };
|
const { personId } = local as { personId: string };
|
||||||
|
|
||||||
const { data, isLoading, isFetching } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ["jellyseerr", "person", personId],
|
queryKey: ["jellyseerr", "person", personId],
|
||||||
queryFn: async () => ({
|
queryFn: async () => ({
|
||||||
details: await jellyseerrApi?.personDetails(personId),
|
details: await jellyseerrApi?.personDetails(personId),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type {
|
import {
|
||||||
|
createMaterialTopTabNavigator,
|
||||||
MaterialTopTabNavigationEventMap,
|
MaterialTopTabNavigationEventMap,
|
||||||
MaterialTopTabNavigationOptions,
|
MaterialTopTabNavigationOptions,
|
||||||
} from "@react-navigation/material-top-tabs";
|
} from "@react-navigation/material-top-tabs";
|
||||||
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
|
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
|
|||||||
@@ -24,14 +24,6 @@ export default function page() {
|
|||||||
const [date, _setDate] = useState<Date>(new Date());
|
const [date, _setDate] = useState<Date>(new Date());
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
const { data: guideInfo } = useQuery({
|
|
||||||
queryKey: ["livetv", "guideInfo"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await getLiveTvApi(api!).getGuideInfo();
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: channels } = useQuery({
|
const { data: channels } = useQuery({
|
||||||
queryKey: ["livetv", "channels", currentPage],
|
queryKey: ["livetv", "channels", currentPage],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default function IndexLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.library"),
|
headerTitle: t("tabs.library"),
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
@@ -200,7 +200,7 @@ export default function IndexLayout() {
|
|||||||
name='[libraryId]'
|
name='[libraryId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -213,7 +213,7 @@ export default function IndexLayout() {
|
|||||||
name='collections/[collectionId]'
|
name='collections/[collectionId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default function SearchLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.search"),
|
headerTitle: t("tabs.search"),
|
||||||
headerLargeStyle: {
|
headerLargeStyle: {
|
||||||
@@ -31,7 +31,7 @@ export default function SearchLayout() {
|
|||||||
name='collections/[collectionId]'
|
name='collections/[collectionId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
|
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";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
@@ -257,6 +258,26 @@ export default function search() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* <View
|
||||||
|
className='flex flex-col'
|
||||||
|
style={{
|
||||||
|
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||||
|
}}
|
||||||
|
> */}
|
||||||
|
{Platform.isTV && (
|
||||||
|
<Input
|
||||||
|
placeholder={t("search.search")}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
router.setParams({ q: "" });
|
||||||
|
setSearch(text);
|
||||||
|
}}
|
||||||
|
keyboardType='default'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
clearButtonMode='while-editing'
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<View
|
<View
|
||||||
className='flex flex-col'
|
className='flex flex-col'
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default function TabLayout() {
|
|||||||
>
|
>
|
||||||
<NativeTabs.Screen redirect name='index' />
|
<NativeTabs.Screen redirect name='index' />
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
listeners={({ navigation }) => ({
|
listeners={(_e) => ({
|
||||||
tabPress: (_e) => {
|
tabPress: (_e) => {
|
||||||
eventBus.emit("scrollToTop");
|
eventBus.emit("scrollToTop");
|
||||||
},
|
},
|
||||||
@@ -69,7 +69,7 @@ export default function TabLayout() {
|
|||||||
title: t("tabs.home"),
|
title: t("tabs.home"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? ({ focused }) => require("@/assets/icons/house.fill.png")
|
? (_e) => require("@/assets/icons/house.fill.png")
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
focused
|
focused
|
||||||
? { sfSymbol: "house.fill" }
|
? { sfSymbol: "house.fill" }
|
||||||
@@ -77,7 +77,7 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
listeners={({ navigation }) => ({
|
listeners={(_e) => ({
|
||||||
tabPress: (_e) => {
|
tabPress: (_e) => {
|
||||||
eventBus.emit("searchTabPressed");
|
eventBus.emit("searchTabPressed");
|
||||||
},
|
},
|
||||||
@@ -87,7 +87,7 @@ export default function TabLayout() {
|
|||||||
title: t("tabs.search"),
|
title: t("tabs.search"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? ({ focused }) => require("@/assets/icons/magnifyingglass.png")
|
? (_e) => require("@/assets/icons/magnifyingglass.png")
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
focused
|
focused
|
||||||
? { sfSymbol: "magnifyingglass" }
|
? { sfSymbol: "magnifyingglass" }
|
||||||
@@ -116,7 +116,7 @@ export default function TabLayout() {
|
|||||||
title: t("tabs.library"),
|
title: t("tabs.library"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? ({ focused }) => require("@/assets/icons/server.rack.png")
|
? (_e) => require("@/assets/icons/server.rack.png")
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
focused
|
focused
|
||||||
? { sfSymbol: "rectangle.stack.fill" }
|
? { sfSymbol: "rectangle.stack.fill" }
|
||||||
@@ -130,7 +130,7 @@ export default function TabLayout() {
|
|||||||
tabBarItemHidden: !settings?.showCustomMenuLinks,
|
tabBarItemHidden: !settings?.showCustomMenuLinks,
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? ({ focused }) => require("@/assets/icons/list.png")
|
? (_e) => require("@/assets/icons/list.png")
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
focused
|
focused
|
||||||
? { sfSymbol: "list.dash.fill" }
|
? { sfSymbol: "list.dash.fill" }
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ export default function page() {
|
|||||||
result = { mediaSource: data.mediaSource, sessionId: "", url };
|
result = { mediaSource: data.mediaSource, sessionId: "", url };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (!item) return;
|
||||||
const res = await getStreamUrl({
|
const res = await getStreamUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
@@ -584,7 +585,7 @@ export default function page() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{videoRef.current && !isPipStarted && isMounted === true && item ? (
|
{!isPipStarted && isMounted === true && item && (
|
||||||
<Controls
|
<Controls
|
||||||
mediaSource={stream?.mediaSource}
|
mediaSource={stream?.mediaSource}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -600,7 +601,7 @@ export default function page() {
|
|||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
ignoreSafeAreas={ignoreSafeAreas}
|
||||||
isVideoLoaded={isVideoLoaded}
|
isVideoLoaded={isVideoLoaded}
|
||||||
startPictureInPicture={videoRef?.current?.startPictureInPicture}
|
startPictureInPicture={videoRef.current?.startPictureInPicture}
|
||||||
play={videoRef.current?.play}
|
play={videoRef.current?.play}
|
||||||
pause={videoRef.current?.pause}
|
pause={videoRef.current?.pause}
|
||||||
seek={videoRef.current?.seekTo}
|
seek={videoRef.current?.seekTo}
|
||||||
@@ -608,12 +609,12 @@ export default function page() {
|
|||||||
getAudioTracks={videoRef.current?.getAudioTracks}
|
getAudioTracks={videoRef.current?.getAudioTracks}
|
||||||
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
||||||
offline={offline}
|
offline={offline}
|
||||||
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
setSubtitleTrack={videoRef.current?.setSubtitleTrack}
|
||||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
setSubtitleURL={videoRef.current?.setSubtitleURL}
|
||||||
setAudioTrack={videoRef.current.setAudioTrack}
|
setAudioTrack={videoRef.current?.setAudioTrack}
|
||||||
isVlc
|
isVlc
|
||||||
/>
|
/>
|
||||||
) : null}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ScrollViewStyleReset } from "expo-router/html";
|
import { ScrollViewStyleReset } from "expo-router/html";
|
||||||
import type { PropsWithChildren } from "react";
|
import { type PropsWithChildren } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This file is web-only and used to configure the root HTML for every web page during static rendering.
|
* This file is web-only and used to configure the root HTML for every web page during static rendering.
|
||||||
|
|||||||
156
app/_layout.tsx
156
app/_layout.tsx
@@ -146,91 +146,99 @@ if (!Platform.isTV) {
|
|||||||
console.log("TaskManager ~ trigger");
|
console.log("TaskManager ~ trigger");
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const settingsData = storage.getString("settings");
|
|
||||||
|
|
||||||
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
try {
|
||||||
|
const settingsData = storage.getString("settings");
|
||||||
|
|
||||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
const url = settings?.optimizedVersionsServerUrl;
|
|
||||||
if (!settings?.autoDownload || !url)
|
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
|
||||||
|
|
||||||
const token = getTokenFromStorage();
|
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||||
const deviceId = getOrSetDeviceId();
|
const url = settings?.optimizedVersionsServerUrl;
|
||||||
const baseDirectory = FileSystem.documentDirectory;
|
|
||||||
|
|
||||||
if (!token || !deviceId || !baseDirectory)
|
if (!settings?.autoDownload || !url)
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const jobs = await getAllJobsByDeviceId({
|
const token = getTokenFromStorage();
|
||||||
deviceId,
|
const deviceId = getOrSetDeviceId();
|
||||||
authHeader: token,
|
const baseDirectory = FileSystem.documentDirectory;
|
||||||
url,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
if (!token || !deviceId || !baseDirectory)
|
||||||
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
for (const job of jobs) {
|
const jobs = await getAllJobsByDeviceId({
|
||||||
if (job.status === "completed") {
|
deviceId,
|
||||||
const downloadUrl = `${url}download/${job.id}`;
|
authHeader: token,
|
||||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
url,
|
||||||
|
});
|
||||||
|
|
||||||
if (tasks.find((task: { id: string }) => task.id === job.id)) {
|
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
||||||
console.log("TaskManager ~ Download already in progress: ", job.id);
|
|
||||||
continue;
|
for (const job of jobs) {
|
||||||
|
if (job.status === "completed") {
|
||||||
|
const downloadUrl = `${url}download/${job.id}`;
|
||||||
|
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||||
|
|
||||||
|
if (tasks.find((task: { id: string }) => task.id === job.id)) {
|
||||||
|
console.log("TaskManager ~ Download already in progress: ", job.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
BackGroundDownloader.download({
|
||||||
|
id: job.id,
|
||||||
|
url: downloadUrl,
|
||||||
|
destination: `${baseDirectory}${job.item.Id}.mp4`,
|
||||||
|
headers: {
|
||||||
|
Authorization: token,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.begin(() => {
|
||||||
|
console.log("TaskManager ~ Download started: ", job.id);
|
||||||
|
})
|
||||||
|
.done(() => {
|
||||||
|
console.log("TaskManager ~ Download completed: ", job.id);
|
||||||
|
saveDownloadedItemInfo(job.item);
|
||||||
|
BackGroundDownloader.completeHandler(job.id);
|
||||||
|
cancelJobById({
|
||||||
|
authHeader: token,
|
||||||
|
id: job.id,
|
||||||
|
url: url,
|
||||||
|
});
|
||||||
|
Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: job.item.Name,
|
||||||
|
body: "Download completed",
|
||||||
|
data: {
|
||||||
|
url: "/downloads",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trigger: null,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.error((error: any) => {
|
||||||
|
console.log("TaskManager ~ Download error: ", job.id, error);
|
||||||
|
BackGroundDownloader.completeHandler(job.id);
|
||||||
|
Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: job.item.Name,
|
||||||
|
body: "Download failed",
|
||||||
|
data: {
|
||||||
|
url: "/downloads",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trigger: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
BackGroundDownloader.download({
|
|
||||||
id: job.id,
|
|
||||||
url: downloadUrl,
|
|
||||||
destination: `${baseDirectory}${job.item.Id}.mp4`,
|
|
||||||
headers: {
|
|
||||||
Authorization: token,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.begin(() => {
|
|
||||||
console.log("TaskManager ~ Download started: ", job.id);
|
|
||||||
})
|
|
||||||
.done(() => {
|
|
||||||
console.log("TaskManager ~ Download completed: ", job.id);
|
|
||||||
saveDownloadedItemInfo(job.item);
|
|
||||||
BackGroundDownloader.completeHandler(job.id);
|
|
||||||
cancelJobById({
|
|
||||||
authHeader: token,
|
|
||||||
id: job.id,
|
|
||||||
url: url,
|
|
||||||
});
|
|
||||||
Notifications.scheduleNotificationAsync({
|
|
||||||
content: {
|
|
||||||
title: job.item.Name,
|
|
||||||
body: "Download completed",
|
|
||||||
data: {
|
|
||||||
url: "/downloads",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
trigger: null,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.error((error: any) => {
|
|
||||||
console.log("TaskManager ~ Download error: ", job.id, error);
|
|
||||||
BackGroundDownloader.completeHandler(job.id);
|
|
||||||
Notifications.scheduleNotificationAsync({
|
|
||||||
content: {
|
|
||||||
title: job.item.Name,
|
|
||||||
body: "Download failed",
|
|
||||||
data: {
|
|
||||||
url: "/downloads",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
trigger: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
||||||
// Be sure to return the successful result type!
|
|
||||||
return BackgroundFetch.BackgroundFetchResult.NewData;
|
// Be sure to return the successful result type!
|
||||||
|
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Background task error:", error);
|
||||||
|
return BackgroundFetch.BackgroundFetchResult.Failed;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,15 +7,27 @@ declare module "react-native-mmkv" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add the augmentation methods directly to the MMKV prototype
|
||||||
|
// This follows the recommended pattern while adding the helper methods your app uses
|
||||||
MMKV.prototype.get = function <T>(key: string): T | undefined {
|
MMKV.prototype.get = function <T>(key: string): T | undefined {
|
||||||
const serializedItem = this.getString(key);
|
try {
|
||||||
return serializedItem ? JSON.parse(serializedItem) : undefined;
|
const serializedItem = this.getString(key);
|
||||||
|
if (!serializedItem) return undefined;
|
||||||
|
return JSON.parse(serializedItem);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to parse MMKV value for key "${key}":`, error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
||||||
if (value === undefined) {
|
try {
|
||||||
this.delete(key);
|
if (value === undefined) {
|
||||||
} else {
|
this.delete(key);
|
||||||
this.set(key, JSON.stringify(value));
|
} else {
|
||||||
|
this.set(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to set MMKV value for key "${key}":`, error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { Platform, type ViewProps } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import GoogleCast, {
|
import GoogleCast, {
|
||||||
CastButton,
|
CastButton,
|
||||||
CastContext,
|
CastContext,
|
||||||
@@ -11,12 +11,6 @@ import GoogleCast, {
|
|||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
background?: "blur" | "transparent";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Chromecast({
|
export function Chromecast({
|
||||||
width = 48,
|
width = 48,
|
||||||
height = 48,
|
height = 48,
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from "zeego/context-menu";
|
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
|
||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -17,7 +15,6 @@ import Animated, {
|
|||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
@@ -37,12 +34,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
selectedOptions,
|
selectedOptions,
|
||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||||
const _api = useAtomValue(apiAtom);
|
|
||||||
const _user = useAtomValue(userAtom);
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function InfiniteHorizontalScroll({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
getNextPageParam: (lastPage, pages) => {
|
getNextPageParam: (lastPage, pages) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useRouter, useSegments } from "expo-router";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { type PropsWithChildren, useCallback, useMemo } from "react";
|
import { type PropsWithChildren, useCallback, useMemo } from "react";
|
||||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||||
import * as ContextMenu from "@/components/ContextMenu";
|
import * as ContextMenu from "zeego/context-menu";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import { Platform, Text as RNText, type TextProps } from "react-native";
|
import { Platform, Text as RNText, type TextProps } from "react-native";
|
||||||
import { UITextView } from "react-native-uitextview";
|
export function Text(props: TextProps) {
|
||||||
export function Text(
|
|
||||||
props: TextProps & {
|
|
||||||
uiTextView?: boolean;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const { style, ...otherProps } = props;
|
const { style, ...otherProps } = props;
|
||||||
if (Platform.isTV)
|
if (Platform.isTV)
|
||||||
return (
|
return (
|
||||||
@@ -16,7 +11,7 @@ export function Text(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UITextView
|
<RNText
|
||||||
allowFontScaling={false}
|
allowFontScaling={false}
|
||||||
style={[{ color: "white" }, style]}
|
style={[{ color: "white" }, style]}
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ interface DownloadCardProps extends TouchableOpacityProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||||
const { processes, startDownload } = useDownload();
|
const { startDownload } = useDownload();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { removeProcess, setProcesses } = useDownload();
|
const { removeProcess } = useDownload();
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ interface EpisodeCardProps extends TouchableOpacityProps {
|
|||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||||
const { deleteFile } = useDownload();
|
const { deleteFile } = useDownload();
|
||||||
const { openFile } = useDownloadedFileOpener();
|
const { openFile } = useDownloadedFileOpener();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ export const FilterSheet = <T,>({
|
|||||||
renderItemLabel,
|
renderItemLabel,
|
||||||
showSearch = true,
|
showSearch = true,
|
||||||
multiple = false,
|
multiple = false,
|
||||||
...props
|
|
||||||
}: Props<T>) => {
|
}: Props<T>) => {
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
const snapPoints = useMemo(() => ["80%"], []);
|
const snapPoints = useMemo(() => ["80%"], []);
|
||||||
|
|||||||
@@ -50,11 +50,8 @@ const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
|
|||||||
const DetailFacts: React.FC<
|
const DetailFacts: React.FC<
|
||||||
{ details?: MovieDetails | TvDetails } & ViewProps
|
{ details?: MovieDetails | TvDetails } & ViewProps
|
||||||
> = ({ details, className, ...props }) => {
|
> = ({ details, className, ...props }) => {
|
||||||
const {
|
const { jellyseerrRegion: region, jellyseerrLocale: locale } =
|
||||||
jellyseerrUser,
|
useJellyseerr();
|
||||||
jellyseerrRegion: region,
|
|
||||||
jellyseerrLocale: locale,
|
|
||||||
} = useJellyseerr();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const releases = useMemo(
|
const releases = useMemo(
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ const ParallaxSlideShow = <T,>({
|
|||||||
renderItem,
|
renderItem,
|
||||||
keyExtractor,
|
keyExtractor,
|
||||||
onEndReached,
|
onEndReached,
|
||||||
...props
|
|
||||||
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
|||||||
@@ -38,16 +38,7 @@ const RequestModal = forwardRef<
|
|||||||
Props & Omit<ViewProps, "id">
|
Props & Omit<ViewProps, "id">
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
{
|
{ id, title, requestBody, type, isAnime = false, onRequested, onDismiss },
|
||||||
id,
|
|
||||||
title,
|
|
||||||
requestBody,
|
|
||||||
type,
|
|
||||||
isAnime = false,
|
|
||||||
onRequested,
|
|
||||||
onDismiss,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
|||||||
[slide],
|
[slide],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isFetching, isLoading } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ["jellyseerr", "discover", slide.type, slide.id],
|
queryKey: ["jellyseerr", "discover", slide.type, slide.id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return jellyseerrApi?.getGenreSliders(
|
return jellyseerrApi?.getGenreSliders(
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces
|
|||||||
const RequestCard: React.FC<{ request: MediaRequest }> = ({ request }) => {
|
const RequestCard: React.FC<{ request: MediaRequest }> = ({ request }) => {
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
|
||||||
const {
|
const { data: details } = useQuery({
|
||||||
data: details,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"jellyseerr",
|
"jellyseerr",
|
||||||
"detail",
|
"detail",
|
||||||
@@ -57,11 +53,7 @@ const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
|
||||||
const {
|
const { data: requests } = useQuery({
|
||||||
data: requests,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["jellyseerr", "recent_requests"],
|
queryKey: ["jellyseerr", "recent_requests"],
|
||||||
queryFn: async () => jellyseerrApi?.requests(),
|
queryFn: async () => jellyseerrApi?.requests(),
|
||||||
enabled: !!jellyseerrApi,
|
enabled: !!jellyseerrApi,
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ const ListItemContent = ({
|
|||||||
showArrow,
|
showArrow,
|
||||||
iconAfter,
|
iconAfter,
|
||||||
children,
|
children,
|
||||||
...props
|
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface Props extends ViewProps {
|
|||||||
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text uiTextView selectable className='font-bold text-2xl mb-1'>
|
<Text selectable className='font-bold text-2xl mb-1'>
|
||||||
{item?.Name}
|
{item?.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='opacity-50'>{item?.ProductionYear}</Text>
|
<Text className='opacity-50'>{item?.ProductionYear}</Text>
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ const JellyseerrPoster: React.FC<Props> = ({
|
|||||||
horizontal,
|
horizontal,
|
||||||
showDownloadInfo,
|
showDownloadInfo,
|
||||||
mediaRequest,
|
mediaRequest,
|
||||||
...props
|
|
||||||
}) => {
|
}) => {
|
||||||
const { jellyseerrApi, getTitle, getYear, getMediaType } = useJellyseerr();
|
const { jellyseerrApi, getTitle, getYear, getMediaType } = useJellyseerr();
|
||||||
const loadingOpacity = useSharedValue(1);
|
const loadingOpacity = useSharedValue(1);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const SearchItemWrapper = <T,>({
|
|||||||
onEndReachedThreshold={1}
|
onEndReachedThreshold={1}
|
||||||
onEndReached={onEndReached}
|
onEndReached={onEndReached}
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
renderItem={({ item, index }) => (item ? renderItem(item) : <></>)}
|
renderItem={({ item }) => (item ? renderItem(item) : null)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const EpisodeTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text uiTextView className='font-bold text-2xl' selectable>
|
<Text className='font-bold text-2xl' selectable>
|
||||||
{item?.Name}
|
{item?.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='flex flex-row items-center mb-1'>
|
<View className='flex flex-row items-center mb-1'>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const JellyseerrSeasonEpisodes: React.FC<{
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const RenderItem = ({ item, index }: any) => {
|
const RenderItem = ({ item }: any) => {
|
||||||
const {
|
const {
|
||||||
jellyseerrApi,
|
jellyseerrApi,
|
||||||
jellyseerrRegion: region,
|
jellyseerrRegion: region,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ type Props = {
|
|||||||
|
|
||||||
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
||||||
|
|
||||||
export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { ListItem } from "../list/ListItem";
|
|||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const AppLanguageSelector: React.FC<Props> = ({ ...props }) => {
|
export const AppLanguageSelector: React.FC<Props> = () => {
|
||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { ListItem } from "../list/ListItem";
|
|||||||
|
|
||||||
export const Dashboard = () => {
|
export const Dashboard = () => {
|
||||||
const [settings, _updateSettings] = useSettings();
|
const [settings, _updateSettings] = useSettings();
|
||||||
const { sessions = [], isLoading } = useSessions({} as useSessionsProps);
|
const { sessions = [] } = useSessions({} as useSessionsProps);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export default function DownloadSettings({ ...props }) {
|
export default function DownloadSettings() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,8 @@ import { ListGroup } from "../list/ListGroup";
|
|||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export const JellyseerrSettings = () => {
|
export const JellyseerrSettings = () => {
|
||||||
const {
|
const { jellyseerrUser, setJellyseerrUser, clearAllJellyseerData } =
|
||||||
jellyseerrApi,
|
useJellyseerr();
|
||||||
jellyseerrUser,
|
|
||||||
setJellyseerrUser,
|
|
||||||
clearAllJellyseerData,
|
|
||||||
} = useJellyseerr();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const StorageSettings = () => {
|
|||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
const errorHapticFeedback = useHaptic("error");
|
const errorHapticFeedback = useHaptic("error");
|
||||||
|
|
||||||
const { data: size, isLoading: appSizeLoading } = useQuery({
|
const { data: size } = useQuery({
|
||||||
queryKey: ["appSize", appSizeUsage],
|
queryKey: ["appSize", appSizeUsage],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const app = await appSizeUsage;
|
const app = await appSizeUsage;
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { Platform, StyleSheet, View } from "react-native";
|
import { Platform, StyleSheet, View } from "react-native";
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import type { VolumeResult } from "react-native-volume-manager";
|
||||||
|
|
||||||
const VolumeManager = Platform.isTV
|
const VolumeManager = Platform.isTV
|
||||||
? null
|
? null
|
||||||
: require("react-native-volume-manager");
|
: require("react-native-volume-manager");
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { VolumeResult } from "react-native-volume-manager";
|
|
||||||
|
|
||||||
interface AudioSliderProps {
|
interface AudioSliderProps {
|
||||||
setVisibility: (show: boolean) => void;
|
setVisibility: (show: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -47,7 +46,7 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
|||||||
|
|
||||||
const handleValueChange = async (value: number) => {
|
const handleValueChange = async (value: number) => {
|
||||||
volume.value = value;
|
volume.value = value;
|
||||||
await VolumeManager.setVolume(value / 100);
|
// await VolumeManager.setVolume(value / 100);
|
||||||
|
|
||||||
// Re-call showNativeVolumeUI to ensure the setting is applied on iOS
|
// Re-call showNativeVolumeUI to ensure the setting is applied on iOS
|
||||||
VolumeManager.showNativeVolumeUI({ enabled: false });
|
VolumeManager.showNativeVolumeUI({ enabled: false });
|
||||||
@@ -55,7 +54,7 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isTv) return;
|
if (isTv) return;
|
||||||
const volumeListener = VolumeManager.addVolumeListener(
|
const _volumeListener = VolumeManager.addVolumeListener(
|
||||||
(result: VolumeResult) => {
|
(result: VolumeResult) => {
|
||||||
volume.value = result.volume * 100;
|
volume.value = result.volume * 100;
|
||||||
setVisibility(true);
|
setVisibility(true);
|
||||||
@@ -73,7 +72,7 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
volumeListener.remove();
|
// volumeListener.remove();
|
||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
clearTimeout(timeoutRef.current);
|
clearTimeout(timeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
Platform,
|
Platform,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
|
useTVEventHandler,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
@@ -156,6 +157,134 @@ export const Controls: FC<Props> = ({
|
|||||||
prefetchAllTrickplayImages();
|
prefetchAllTrickplayImages();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const remoteScrubProgress = useSharedValue<number | null>(null);
|
||||||
|
const isRemoteScrubbing = useSharedValue(false);
|
||||||
|
const SCRUB_INTERVAL = isVlc ? secondsToMs(10) : msToTicks(secondsToMs(10));
|
||||||
|
const [showRemoteBubble, setShowRemoteBubble] = useState(false);
|
||||||
|
|
||||||
|
const [longPressScrubMode, setLongPressScrubMode] = useState<
|
||||||
|
"FF" | "RW" | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
useTVEventHandler((evt) => {
|
||||||
|
if (!evt) return;
|
||||||
|
|
||||||
|
switch (evt.eventType) {
|
||||||
|
case "longLeft": {
|
||||||
|
setLongPressScrubMode((prev) => (!prev ? "RW" : null));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "longRight": {
|
||||||
|
setLongPressScrubMode((prev) => (!prev ? "FF" : null));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "left":
|
||||||
|
case "right": {
|
||||||
|
isRemoteScrubbing.value = true;
|
||||||
|
setShowRemoteBubble(true);
|
||||||
|
|
||||||
|
const direction = evt.eventType === "left" ? -1 : 1;
|
||||||
|
const base = remoteScrubProgress.value ?? progress.value;
|
||||||
|
const updated = Math.max(
|
||||||
|
min.value,
|
||||||
|
Math.min(max.value, base + direction * SCRUB_INTERVAL),
|
||||||
|
);
|
||||||
|
remoteScrubProgress.value = updated;
|
||||||
|
const progressInTicks = isVlc ? msToTicks(updated) : updated;
|
||||||
|
calculateTrickplayUrl(progressInTicks);
|
||||||
|
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
||||||
|
const hours = Math.floor(progressInSeconds / 3600);
|
||||||
|
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||||
|
const seconds = progressInSeconds % 60;
|
||||||
|
setTime({ hours, minutes, seconds });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "select": {
|
||||||
|
if (isRemoteScrubbing.value && remoteScrubProgress.value != null) {
|
||||||
|
progress.value = remoteScrubProgress.value;
|
||||||
|
|
||||||
|
const seekTarget = isVlc
|
||||||
|
? Math.max(0, remoteScrubProgress.value)
|
||||||
|
: Math.max(0, ticksToSeconds(remoteScrubProgress.value));
|
||||||
|
|
||||||
|
seek(seekTarget);
|
||||||
|
if (isPlaying) play();
|
||||||
|
|
||||||
|
isRemoteScrubbing.value = false;
|
||||||
|
remoteScrubProgress.value = null;
|
||||||
|
setShowRemoteBubble(false);
|
||||||
|
} else {
|
||||||
|
togglePlay();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "down":
|
||||||
|
case "up":
|
||||||
|
// cancel scrubbing on other directions
|
||||||
|
isRemoteScrubbing.value = false;
|
||||||
|
remoteScrubProgress.value = null;
|
||||||
|
setShowRemoteBubble(false);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showControls) toggleControls();
|
||||||
|
});
|
||||||
|
|
||||||
|
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isActive = true;
|
||||||
|
let seekTime = 10;
|
||||||
|
|
||||||
|
const scrubWithLongPress = () => {
|
||||||
|
if (!isActive || !longPressScrubMode) return;
|
||||||
|
|
||||||
|
setIsSliding(true);
|
||||||
|
const scrubFn =
|
||||||
|
longPressScrubMode === "FF" ? handleSeekForward : handleSeekBackward;
|
||||||
|
scrubFn(seekTime);
|
||||||
|
seekTime *= 1.1;
|
||||||
|
|
||||||
|
longPressTimeoutRef.current = setTimeout(scrubWithLongPress, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (longPressScrubMode) {
|
||||||
|
isActive = true;
|
||||||
|
scrubWithLongPress();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isActive = false;
|
||||||
|
setIsSliding(false);
|
||||||
|
if (longPressTimeoutRef.current) {
|
||||||
|
clearTimeout(longPressTimeoutRef.current);
|
||||||
|
longPressTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [longPressScrubMode]);
|
||||||
|
|
||||||
|
const effectiveProgress = useSharedValue(0);
|
||||||
|
|
||||||
|
// Recompute progress whenever remote scrubbing is active
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => ({
|
||||||
|
isScrubbing: isRemoteScrubbing.value,
|
||||||
|
scrub: remoteScrubProgress.value,
|
||||||
|
actual: progress.value,
|
||||||
|
}),
|
||||||
|
(current) => {
|
||||||
|
effectiveProgress.value =
|
||||||
|
current.isScrubbing && current.scrub != null
|
||||||
|
? current.scrub
|
||||||
|
: current.actual;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item) {
|
if (item) {
|
||||||
progress.value = isVlc
|
progress.value = isVlc
|
||||||
@@ -374,20 +503,19 @@ export const Controls: FC<Props> = ({
|
|||||||
|
|
||||||
pause();
|
pause();
|
||||||
isSeeking.value = true;
|
isSeeking.value = true;
|
||||||
}, [showControls, isPlaying]);
|
}, [showControls, isPlaying, pause]);
|
||||||
|
|
||||||
const handleSliderComplete = useCallback(
|
const handleSliderComplete = useCallback(
|
||||||
async (value: number) => {
|
async (value: number) => {
|
||||||
isSeeking.value = false;
|
isSeeking.value = false;
|
||||||
progress.value = value;
|
progress.value = value;
|
||||||
setIsSliding(false);
|
setIsSliding(false);
|
||||||
|
|
||||||
seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))));
|
seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))));
|
||||||
if (wasPlayingRef.current) {
|
if (wasPlayingRef.current) {
|
||||||
play();
|
play();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isVlc],
|
[isVlc, seek, play],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
||||||
@@ -424,7 +552,43 @@ export const Controls: FC<Props> = ({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Error seeking video backwards", error);
|
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||||
}
|
}
|
||||||
}, [settings, isPlaying, isVlc]);
|
}, [settings, isPlaying, isVlc, play, seek]);
|
||||||
|
|
||||||
|
const handleSeekBackward = useCallback(
|
||||||
|
async (seconds: number) => {
|
||||||
|
wasPlayingRef.current = isPlaying;
|
||||||
|
try {
|
||||||
|
const curr = progress.value;
|
||||||
|
if (curr !== undefined) {
|
||||||
|
const newTime = isVlc
|
||||||
|
? Math.max(0, curr - secondsToMs(seconds))
|
||||||
|
: Math.max(0, ticksToSeconds(curr) - seconds);
|
||||||
|
seek(newTime);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isPlaying, isVlc, seek],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSeekForward = useCallback(
|
||||||
|
async (seconds: number) => {
|
||||||
|
wasPlayingRef.current = isPlaying;
|
||||||
|
try {
|
||||||
|
const curr = progress.value;
|
||||||
|
if (curr !== undefined) {
|
||||||
|
const newTime = isVlc
|
||||||
|
? curr + secondsToMs(seconds)
|
||||||
|
: ticksToSeconds(curr) + seconds;
|
||||||
|
seek(Math.max(0, newTime));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isPlaying, isVlc, seek],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSkipForward = useCallback(async () => {
|
const handleSkipForward = useCallback(async () => {
|
||||||
if (!settings?.forwardSkipTime) {
|
if (!settings?.forwardSkipTime) {
|
||||||
@@ -446,7 +610,7 @@ export const Controls: FC<Props> = ({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||||
}
|
}
|
||||||
}, [settings, isPlaying, isVlc]);
|
}, [settings, isPlaying, isVlc, play, seek]);
|
||||||
|
|
||||||
const toggleIgnoreSafeAreas = useCallback(() => {
|
const toggleIgnoreSafeAreas = useCallback(() => {
|
||||||
setIgnoreSafeAreas((prev) => !prev);
|
setIgnoreSafeAreas((prev) => !prev);
|
||||||
@@ -667,80 +831,87 @@ export const Controls: FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<BrightnessSlider />
|
<BrightnessSlider />
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity onPress={handleSkipBackward}>
|
{!Platform.isTV && (
|
||||||
<View
|
<TouchableOpacity onPress={handleSkipBackward}>
|
||||||
style={{
|
<View
|
||||||
position: "relative",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
opacity: showControls ? 1 : 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name='refresh-outline'
|
|
||||||
size={50}
|
|
||||||
color='white'
|
|
||||||
style={{
|
|
||||||
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
color: "white",
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: "bold",
|
|
||||||
bottom: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{settings?.rewindSkipTime}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
togglePlay();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!isBuffering ? (
|
|
||||||
<Ionicons
|
|
||||||
name={isPlaying ? "pause" : "play"}
|
|
||||||
size={50}
|
|
||||||
color='white'
|
|
||||||
style={{
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
opacity: showControls ? 1 : 0,
|
opacity: showControls ? 1 : 0,
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
) : (
|
<Ionicons
|
||||||
<Loader size={"large"} />
|
name='refresh-outline'
|
||||||
)}
|
size={50}
|
||||||
</TouchableOpacity>
|
color='white'
|
||||||
|
style={{
|
||||||
|
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
color: "white",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "bold",
|
||||||
|
bottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{settings?.rewindSkipTime}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
<TouchableOpacity onPress={handleSkipForward}>
|
<View
|
||||||
<View
|
style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}
|
||||||
style={{
|
>
|
||||||
position: "relative",
|
<TouchableOpacity
|
||||||
justifyContent: "center",
|
onPress={() => {
|
||||||
alignItems: "center",
|
togglePlay();
|
||||||
opacity: showControls ? 1 : 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name='refresh-outline' size={50} color='white' />
|
{!isBuffering ? (
|
||||||
<Text
|
<Ionicons
|
||||||
|
name={isPlaying ? "pause" : "play"}
|
||||||
|
size={50}
|
||||||
|
color='white'
|
||||||
|
style={{
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Loader size={"large"} />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!Platform.isTV && (
|
||||||
|
<TouchableOpacity onPress={handleSkipForward}>
|
||||||
|
<View
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "relative",
|
||||||
color: "white",
|
justifyContent: "center",
|
||||||
fontSize: 16,
|
alignItems: "center",
|
||||||
fontWeight: "bold",
|
opacity: showControls ? 1 : 0,
|
||||||
bottom: 10,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{settings?.forwardSkipTime}
|
<Ionicons name='refresh-outline' size={50} color='white' />
|
||||||
</Text>
|
<Text
|
||||||
</View>
|
style={{
|
||||||
</TouchableOpacity>
|
position: "absolute",
|
||||||
|
color: "white",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "bold",
|
||||||
|
bottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{settings?.forwardSkipTime}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -850,10 +1021,12 @@ export const Controls: FC<Props> = ({
|
|||||||
containerStyle={{
|
containerStyle={{
|
||||||
borderRadius: 100,
|
borderRadius: 100,
|
||||||
}}
|
}}
|
||||||
renderBubble={() => isSliding && memoizedRenderBubble()}
|
renderBubble={() =>
|
||||||
|
(isSliding || showRemoteBubble) && memoizedRenderBubble()
|
||||||
|
}
|
||||||
sliderHeight={10}
|
sliderHeight={10}
|
||||||
thumbWidth={0}
|
thumbWidth={0}
|
||||||
progress={progress}
|
progress={effectiveProgress}
|
||||||
minimumValue={min}
|
minimumValue={min}
|
||||||
maximumValue={max}
|
maximumValue={max}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
[seasons, seasonIndex],
|
[seasons, seasonIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: episodes, isFetching } = useQuery({
|
const { data: episodes } = useQuery({
|
||||||
queryKey: ["episodes", item.SeriesId, selectedSeasonId],
|
queryKey: ["episodes", item.SeriesId, selectedSeasonId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
|
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
|
||||||
|
|||||||
6
eas.json
6
eas.json
@@ -46,14 +46,14 @@
|
|||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.29.6",
|
"channel": "0.29.13",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.29.6",
|
"channel": "0.29.13",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
},
|
},
|
||||||
"production-apk-tv": {
|
"production-apk-tv": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.29.6",
|
"channel": "0.29.13",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -16,34 +16,46 @@ export type HapticFeedbackType =
|
|||||||
export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
|
export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
|
const isDisabled =
|
||||||
|
isTv ||
|
||||||
|
!Haptics ||
|
||||||
|
settings?.disableHapticFeedback ||
|
||||||
|
Platform.OS === "web";
|
||||||
|
|
||||||
const createHapticHandler = useCallback(
|
const createHapticHandler = useCallback(
|
||||||
(type: typeof Haptics.ImpactFeedbackStyle) => {
|
(type: typeof Haptics.ImpactFeedbackStyle) => {
|
||||||
return Platform.OS === "web" || Platform.isTV
|
if (!Haptics || !type) return () => {};
|
||||||
? () => {}
|
return () => Haptics.impactAsync(type);
|
||||||
: () => Haptics.impactAsync(type);
|
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const createNotificationFeedback = useCallback(
|
const createNotificationFeedback = useCallback(
|
||||||
(type: typeof Haptics.NotificationFeedbackType) => {
|
(type: typeof Haptics.NotificationFeedbackType) => {
|
||||||
return Platform.OS === "web" || Platform.isTV
|
if (!Haptics || !type) return () => {};
|
||||||
? () => {}
|
return () => Haptics.notificationAsync(type);
|
||||||
: () => Haptics.notificationAsync(type);
|
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const hapticHandlers = useMemo(
|
const hapticHandlers = useMemo(() => {
|
||||||
() => ({
|
if (!Haptics) {
|
||||||
|
return {
|
||||||
|
light: () => {},
|
||||||
|
medium: () => {},
|
||||||
|
heavy: () => {},
|
||||||
|
selection: () => {},
|
||||||
|
success: () => {},
|
||||||
|
warning: () => {},
|
||||||
|
error: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
light: createHapticHandler(Haptics.ImpactFeedbackStyle.Light),
|
light: createHapticHandler(Haptics.ImpactFeedbackStyle.Light),
|
||||||
medium: createHapticHandler(Haptics.ImpactFeedbackStyle.Medium),
|
medium: createHapticHandler(Haptics.ImpactFeedbackStyle.Medium),
|
||||||
heavy: createHapticHandler(Haptics.ImpactFeedbackStyle.Heavy),
|
heavy: createHapticHandler(Haptics.ImpactFeedbackStyle.Heavy),
|
||||||
selection:
|
selection: Haptics.selectionAsync,
|
||||||
Platform.OS === "web" || Platform.isTV
|
|
||||||
? () => {}
|
|
||||||
: Haptics.selectionAsync,
|
|
||||||
success: createNotificationFeedback(
|
success: createNotificationFeedback(
|
||||||
Haptics.NotificationFeedbackType.Success,
|
Haptics.NotificationFeedbackType.Success,
|
||||||
),
|
),
|
||||||
@@ -51,16 +63,11 @@ export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
|
|||||||
Haptics.NotificationFeedbackType.Warning,
|
Haptics.NotificationFeedbackType.Warning,
|
||||||
),
|
),
|
||||||
error: createNotificationFeedback(Haptics.NotificationFeedbackType.Error),
|
error: createNotificationFeedback(Haptics.NotificationFeedbackType.Error),
|
||||||
}),
|
};
|
||||||
[createHapticHandler, createNotificationFeedback],
|
}, [createHapticHandler, createNotificationFeedback]);
|
||||||
);
|
|
||||||
|
|
||||||
if (isTv) {
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings?.disableHapticFeedback) {
|
if (settings?.disableHapticFeedback) {
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
return hapticHandlers[feedbackType];
|
return isDisabled ? () => {} : hapticHandlers[feedbackType];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { getColors, ImageColorsResult } from "react-native-image-colors";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
adjustToNearBlack,
|
adjustToNearBlack,
|
||||||
@@ -12,9 +13,6 @@ import {
|
|||||||
import { getItemImage } from "@/utils/getItemImage";
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
// import { getColors } from "react-native-image-colors";
|
|
||||||
const Colors = !Platform.isTV ? require("react-native-image-colors") : null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook to extract and manage image colors for a given item.
|
* Custom hook to extract and manage image colors for a given item.
|
||||||
*
|
*
|
||||||
@@ -65,48 +63,45 @@ export const useImageColors = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Colors.getColors(source.uri, {
|
// Extract colors from the image
|
||||||
|
getColors(source.uri, {
|
||||||
fallback: "#fff",
|
fallback: "#fff",
|
||||||
cache: false,
|
cache: false,
|
||||||
})
|
})
|
||||||
.then(
|
.then((colors: ImageColorsResult) => {
|
||||||
(colors: {
|
let primary = "#fff";
|
||||||
platform: string;
|
let text = "#000";
|
||||||
dominant: string;
|
let backup = "#fff";
|
||||||
vibrant: string;
|
|
||||||
detail: string;
|
|
||||||
primary: string;
|
|
||||||
}) => {
|
|
||||||
let primary = "#fff";
|
|
||||||
let text = "#000";
|
|
||||||
let backup = "#fff";
|
|
||||||
|
|
||||||
if (colors.platform === "android") {
|
// Select the appropriate color based on the platform
|
||||||
primary = colors.dominant;
|
if (colors.platform === "android") {
|
||||||
backup = colors.vibrant;
|
primary = colors.dominant;
|
||||||
} else if (colors.platform === "ios") {
|
backup = colors.vibrant;
|
||||||
primary = colors.detail;
|
} else if (colors.platform === "ios") {
|
||||||
backup = colors.primary;
|
primary = colors.detail;
|
||||||
}
|
backup = colors.primary;
|
||||||
|
}
|
||||||
|
|
||||||
if (primary && isCloseToBlack(primary)) {
|
// Adjust the primary color if it's too close to black
|
||||||
if (backup && !isCloseToBlack(backup)) primary = backup;
|
if (primary && isCloseToBlack(primary)) {
|
||||||
primary = adjustToNearBlack(primary);
|
if (backup && !isCloseToBlack(backup)) primary = backup;
|
||||||
}
|
primary = adjustToNearBlack(primary);
|
||||||
|
}
|
||||||
|
|
||||||
if (primary) text = calculateTextColor(primary);
|
// Calculate the text color based on the primary color
|
||||||
|
if (primary) text = calculateTextColor(primary);
|
||||||
|
|
||||||
setPrimaryColor({
|
setPrimaryColor({
|
||||||
primary,
|
primary,
|
||||||
text,
|
text,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (source.uri && primary) {
|
// Cache the colors in storage
|
||||||
storage.set(`${source.uri}-primary`, primary);
|
if (source.uri && primary) {
|
||||||
storage.set(`${source.uri}-text`, text);
|
storage.set(`${source.uri}-primary`, primary);
|
||||||
}
|
storage.set(`${source.uri}-text`, text);
|
||||||
},
|
}
|
||||||
)
|
})
|
||||||
.catch((error: any) => {
|
.catch((error: any) => {
|
||||||
console.error("Error getting colors", error);
|
console.error("Error getting colors", error);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,12 +14,6 @@ interface TrickplayData {
|
|||||||
ThumbnailCount?: number;
|
ThumbnailCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrickplayInfo {
|
|
||||||
resolution: string;
|
|
||||||
aspectRatio: number;
|
|
||||||
data: TrickplayData;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TrickplayUrl {
|
interface TrickplayUrl {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
|
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||||
const { getDefaultConfig } = require("expo/metro-config");
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
|
|
||||||
const config = getDefaultConfig(__dirname);
|
/** @type {import('expo/metro-config').MetroConfig} */
|
||||||
|
const config = getDefaultConfig(__dirname); // eslint-disable-line no-undef
|
||||||
|
|
||||||
|
// Add Hermes parser
|
||||||
|
config.transformer.hermesParser = true;
|
||||||
|
|
||||||
|
// When enabled, the optional code below will allow Metro to resolve
|
||||||
|
// and bundle source files with TV-specific extensions
|
||||||
|
// (e.g., *.ios.tv.tsx, *.android.tv.tsx, *.tv.tsx)
|
||||||
|
//
|
||||||
|
// Metro will still resolve source files with standard extensions
|
||||||
|
// as usual if TV-specific files are not found for a module.
|
||||||
|
//
|
||||||
if (process.env?.EXPO_TV === "1") {
|
if (process.env?.EXPO_TV === "1") {
|
||||||
const originalSourceExts = config.resolver.sourceExts;
|
const originalSourceExts = config.resolver.sourceExts;
|
||||||
const tvSourceExts = [
|
const tvSourceExts = [
|
||||||
@@ -11,4 +23,6 @@ if (process.env?.EXPO_TV === "1") {
|
|||||||
config.resolver.sourceExts = tvSourceExts;
|
config.resolver.sourceExts = tvSourceExts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// config.resolver.unstable_enablePackageExports = false;
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = config;
|
||||||
|
|||||||
138
package.json
138
package.json
@@ -20,124 +20,122 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bottom-tabs/react-navigation": "^0.9.2",
|
"@bottom-tabs/react-navigation": "^0.9.2",
|
||||||
"@expo/config-plugins": "~9.0.15",
|
"@expo/config-plugins": "~10.1.1",
|
||||||
|
"@expo/metro-runtime": "~5.0.4",
|
||||||
"@expo/react-native-action-sheet": "^4.1.1",
|
"@expo/react-native-action-sheet": "^4.1.1",
|
||||||
"@expo/vector-icons": "^14.0.4",
|
"@expo/vector-icons": "^14.1.0",
|
||||||
"@futurejj/react-native-visibility-sensor": "^1.3.10",
|
|
||||||
"@gorhom/bottom-sheet": "^5.1.0",
|
"@gorhom/bottom-sheet": "^5.1.0",
|
||||||
"@jellyfin/sdk": "^0.11.0",
|
"@jellyfin/sdk": "^0.11.0",
|
||||||
"@kesha-antonov/react-native-background-downloader": "3.2.6",
|
"@kesha-antonov/react-native-background-downloader": "^3.2.6",
|
||||||
"@react-native-community/netinfo": "11.4.1",
|
"@react-native-community/netinfo": "^11.4.1",
|
||||||
"@react-native-menu/menu": "^1.2.3",
|
"@react-native-menu/menu": "^1.2.3",
|
||||||
"@react-navigation/material-top-tabs": "^7.1.0",
|
"@react-navigation/material-top-tabs": "^7.2.14",
|
||||||
"@react-navigation/native": "^7.0.14",
|
"@react-navigation/native": "^7.0.14",
|
||||||
"@shopify/flash-list": "1.7.3",
|
"@shopify/flash-list": "^1.8.3",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
"add": "^2.0.6",
|
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"expo": "~52.0.31",
|
"expo": "^53.0.6",
|
||||||
"expo-asset": "~11.0.3",
|
"expo-application": "~6.1.4",
|
||||||
"expo-background-fetch": "~13.0.5",
|
"expo-asset": "~11.1.7",
|
||||||
"expo-blur": "~14.0.3",
|
"expo-background-fetch": "~13.1.5",
|
||||||
"expo-brightness": "~13.0.3",
|
"expo-blur": "~14.1.4",
|
||||||
"expo-build-properties": "~0.13.2",
|
"expo-brightness": "~13.1.4",
|
||||||
"expo-constants": "~17.0.5",
|
"expo-build-properties": "~0.14.6",
|
||||||
"expo-crypto": "~14.0.2",
|
"expo-constants": "~17.1.5",
|
||||||
"expo-dev-client": "~5.0.11",
|
"expo-dev-client": "^5.2.0",
|
||||||
"expo-device": "~7.0.2",
|
"expo-device": "~7.1.4",
|
||||||
"expo-doctor": "^1.13.5",
|
"expo-doctor": "^1.13.5",
|
||||||
"expo-font": "~13.0.3",
|
"expo-font": "~13.3.1",
|
||||||
"expo-haptics": "~14.0.1",
|
"expo-haptics": "~14.1.4",
|
||||||
"expo-image": "~2.0.4",
|
"expo-image": "~2.4.0",
|
||||||
"expo-keep-awake": "~14.0.2",
|
"expo-linear-gradient": "~14.1.4",
|
||||||
"expo-linear-gradient": "~14.0.2",
|
"expo-linking": "~7.1.4",
|
||||||
"expo-linking": "~7.0.5",
|
"expo-localization": "~16.1.5",
|
||||||
"expo-localization": "~16.0.1",
|
"expo-notifications": "~0.31.2",
|
||||||
"expo-network": "~7.0.5",
|
"expo-router": "~5.1.4",
|
||||||
"expo-notifications": "~0.29.13",
|
"expo-screen-orientation": "~8.1.6",
|
||||||
"expo-router": "~4.0.17",
|
"expo-sensors": "~14.1.4",
|
||||||
"expo-screen-orientation": "~8.0.4",
|
"expo-sharing": "~13.1.5",
|
||||||
"expo-sensors": "~14.0.2",
|
"expo-splash-screen": "~0.30.8",
|
||||||
"expo-sharing": "~13.0.1",
|
"expo-status-bar": "~2.2.3",
|
||||||
"expo-splash-screen": "~0.29.22",
|
"expo-system-ui": "~5.0.7",
|
||||||
"expo-status-bar": "~2.0.1",
|
"expo-task-manager": "~13.1.5",
|
||||||
"expo-system-ui": "~4.0.8",
|
"expo-web-browser": "~14.2.0",
|
||||||
"expo-task-manager": "~12.0.5",
|
|
||||||
"expo-updates": "~0.27.4",
|
|
||||||
"expo-web-browser": "~14.0.2",
|
|
||||||
"i18next": "^25.0.0",
|
"i18next": "^25.0.0",
|
||||||
"install": "^0.13.0",
|
"jotai": "^2.12.5",
|
||||||
"jotai": "^2.11.3",
|
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
"react": "18.3.1",
|
"react": "19.0.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "19.0.0",
|
||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "^15.4.0",
|
||||||
"react-native": "npm:react-native-tvos@~0.77.2-0",
|
"react-native": "npm:react-native-tvos@0.79.5-0",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "^0.9.2",
|
"react-native-bottom-tabs": "^0.9.2",
|
||||||
"react-native-circular-progress": "^1.4.1",
|
"react-native-circular-progress": "^1.4.1",
|
||||||
"react-native-collapsible": "^1.6.2",
|
"react-native-collapsible": "^1.6.2",
|
||||||
"react-native-compressor": "^1.10.3",
|
|
||||||
"react-native-country-flag": "^2.0.2",
|
"react-native-country-flag": "^2.0.2",
|
||||||
"react-native-device-info": "^14.0.4",
|
"react-native-device-info": "^14.0.4",
|
||||||
"react-native-edge-to-edge": "^1.4.3",
|
"react-native-gesture-handler": "~2.24.0",
|
||||||
"react-native-gesture-handler": "2.22.0",
|
"react-native-google-cast": "^4.9.0",
|
||||||
"react-native-get-random-values": "^1.11.0",
|
|
||||||
"react-native-google-cast": "^4.8.3",
|
|
||||||
"react-native-image-colors": "^2.4.0",
|
"react-native-image-colors": "^2.4.0",
|
||||||
"react-native-ios-context-menu": "^3.1.0",
|
"react-native-ios-context-menu": "^3.1.0",
|
||||||
"react-native-ios-utilities": "5.1.8",
|
"react-native-ios-utilities": "5.1.8",
|
||||||
"react-native-mmkv": "^2.12.2",
|
"react-native-mmkv": "2.12.2",
|
||||||
"react-native-pager-view": "6.5.1",
|
|
||||||
"react-native-progress": "^5.0.1",
|
|
||||||
"react-native-reanimated": "~3.16.7",
|
"react-native-reanimated": "~3.16.7",
|
||||||
"react-native-reanimated-carousel": "^4",
|
"react-native-reanimated-carousel": "4.0.2",
|
||||||
"react-native-safe-area-context": "5.5.0",
|
"react-native-safe-area-context": "5.4.0",
|
||||||
"react-native-screens": "~4.5.0",
|
"react-native-screens": "~4.11.1",
|
||||||
"react-native-svg": "15.11.1",
|
"react-native-svg": "15.11.2",
|
||||||
"react-native-tab-view": "^4.0.5",
|
|
||||||
"react-native-udp": "^4.1.7",
|
"react-native-udp": "^4.1.7",
|
||||||
"react-native-uitextview": "^1.4.0",
|
|
||||||
"react-native-url-polyfill": "^2.0.0",
|
"react-native-url-polyfill": "^2.0.0",
|
||||||
"react-native-uuid": "^2.0.3",
|
"react-native-uuid": "^2.0.3",
|
||||||
"react-native-video": "6.16.1",
|
"react-native-video": "6.14.1",
|
||||||
"react-native-volume-manager": "^2.0.8",
|
"react-native-volume-manager": "^2.0.8",
|
||||||
"react-native-web": "~0.19.13",
|
"react-native-web": "^0.20.0",
|
||||||
"react-native-webview": "13.13.2",
|
|
||||||
"sonner-native": "^0.21.0",
|
"sonner-native": "^0.21.0",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4",
|
||||||
"uuid": "^11.0.5",
|
"zeego": "^3.0.6",
|
||||||
"zeego": "^3",
|
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.26.8",
|
"@babel/core": "^7.20.0",
|
||||||
"@biomejs/biome": "^2.1.3",
|
"@biomejs/biome": "^2.1.3",
|
||||||
"@react-native-community/cli": "^19",
|
"@react-native-community/cli": "^19",
|
||||||
"@react-native-tvos/config-tv": "^0.1.1",
|
"@react-native-tvos/config-tv": "^0.1.1",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^29.5.12",
|
||||||
"@types/lodash": "^4.17.15",
|
"@types/lodash": "^4.17.15",
|
||||||
"@types/react": "~18.3.12",
|
"@types/react": "~19.0.10",
|
||||||
"@types/react-native-vector-icons": "^6.4.18",
|
|
||||||
"@types/react-test-renderer": "^19.0.0",
|
"@types/react-test-renderer": "^19.0.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"cross-env": "^10.0.0",
|
||||||
"cross-env": "^10",
|
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.1.2",
|
"lint-staged": "^16.1.2",
|
||||||
"postinstall-postinstall": "^2.1.0",
|
"postinstall-postinstall": "^2.1.0",
|
||||||
"react-test-renderer": "19.1.1",
|
"react-test-renderer": "19.1.1",
|
||||||
"typescript": "~5.8.0"
|
"typescript": "~5.8.3"
|
||||||
},
|
},
|
||||||
"private": true,
|
|
||||||
"expo": {
|
"expo": {
|
||||||
"install": {
|
"install": {
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"react-native"
|
"react-native",
|
||||||
|
"@shopify/flash-list",
|
||||||
|
"react-native-reanimated"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"doctor": {
|
||||||
|
"reactNativeDirectoryCheck": {
|
||||||
|
"exclude": [
|
||||||
|
"react-native-google-cast",
|
||||||
|
"react-native-udp",
|
||||||
|
"@bottom-tabs/react-navigation",
|
||||||
|
"@jellyfin/sdk",
|
||||||
|
"expo-doctor"
|
||||||
|
],
|
||||||
|
"listUnknownPackages": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"private": true,
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,jsx,ts,tsx}": [
|
"*.{js,jsx,ts,tsx}": [
|
||||||
"biome check --write --unsafe --no-errors-on-unmatched"
|
"biome check --write --unsafe --no-errors-on-unmatched"
|
||||||
|
|||||||
66
plugins/with-runtime-framework-headers.js
Normal file
66
plugins/with-runtime-framework-headers.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
const { withPodfile } = require("expo/config-plugins");
|
||||||
|
|
||||||
|
const PATCH_START = "## >>> runtime-framework headers";
|
||||||
|
const PATCH_END = "## <<< runtime-framework headers";
|
||||||
|
|
||||||
|
const EXTRA_HDRS = [
|
||||||
|
`\${PODS_CONFIGURATION_BUILD_DIR}/React-RuntimeApple/React_RuntimeApple.framework/Headers`,
|
||||||
|
`\${PODS_CONFIGURATION_BUILD_DIR}/React-RuntimeCore/React_RuntimeCore.framework/Headers`,
|
||||||
|
`\${PODS_CONFIGURATION_BUILD_DIR}/React-jserrorhandler/React_jserrorhandler.framework/Headers`,
|
||||||
|
`\${PODS_CONFIGURATION_BUILD_DIR}/React-jsinspector/jsinspector_modern.framework/Headers`,
|
||||||
|
`\${PODS_CONFIGURATION_BUILD_DIR}/React-runtimescheduler/React_runtimescheduler.framework/Headers`,
|
||||||
|
`\${PODS_CONFIGURATION_BUILD_DIR}/React-performancetimeline/React_performancetimeline.framework/Headers`,
|
||||||
|
`\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`,
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildPatch() {
|
||||||
|
return [
|
||||||
|
PATCH_START,
|
||||||
|
" extra_hdrs = [",
|
||||||
|
...EXTRA_HDRS.map((h) => ` "${h}",`),
|
||||||
|
" ]",
|
||||||
|
"",
|
||||||
|
" installer.pods_project.targets.each do |t|",
|
||||||
|
" t.build_configurations.each do |cfg|",
|
||||||
|
" cfg.build_settings['HEADER_SEARCH_PATHS'] ||= '$(inherited)'",
|
||||||
|
" cfg.build_settings['HEADER_SEARCH_PATHS'] << \" #{extra_hdrs.join(' ')}\"",
|
||||||
|
" end",
|
||||||
|
" end",
|
||||||
|
PATCH_END,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function withRuntimeFrameworkHeaders(config) {
|
||||||
|
return withPodfile(config, (config) => {
|
||||||
|
let podfile = config.modResults.contents;
|
||||||
|
|
||||||
|
// 1️⃣ ensure there's a post_install block
|
||||||
|
if (!/^\s*post_install\s+do\s+\|installer\|/m.test(podfile)) {
|
||||||
|
podfile += `
|
||||||
|
|
||||||
|
post_install do |installer|
|
||||||
|
end
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = buildPatch();
|
||||||
|
|
||||||
|
if (podfile.includes(PATCH_START)) {
|
||||||
|
// 🔄 update existing patch
|
||||||
|
podfile = podfile.replace(
|
||||||
|
new RegExp(`${PATCH_START}[\\s\\S]*?${PATCH_END}`),
|
||||||
|
patch,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// ➕ insert right after the post_install opening line
|
||||||
|
podfile = podfile.replace(
|
||||||
|
/^\s*post_install\s+do\s+\|installer\|.*$/m,
|
||||||
|
(match) => `${match}\n\n${patch}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ with-runtime-framework-headers: Podfile updated");
|
||||||
|
config.modResults.contents = podfile;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,48 +1,66 @@
|
|||||||
const { withAppDelegate } = require("@expo/config-plugins");
|
const { withAppDelegate, withXcodeProject } = require("@expo/config-plugins");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
function withRNBackgroundDownloader(expoConfig) {
|
/** @param {import("@expo/config-plugins").ExpoConfig} config */
|
||||||
return withAppDelegate(expoConfig, async (appDelegateConfig) => {
|
function withRNBackgroundDownloader(config) {
|
||||||
const { modResults: appDelegate } = appDelegateConfig;
|
/* 1️⃣ Add handleEventsForBackgroundURLSession to AppDelegate.swift */
|
||||||
const appDelegateLines = appDelegate.contents.split("\n");
|
config = withAppDelegate(config, (mod) => {
|
||||||
|
const tag = "handleEventsForBackgroundURLSession";
|
||||||
// Define the code to be added to AppDelegate.mm
|
if (!mod.modResults.contents.includes(tag)) {
|
||||||
const backgroundDownloaderImport =
|
mod.modResults.contents = mod.modResults.contents.replace(
|
||||||
"#import <RNBackgroundDownloader.h> // Required by react-native-background-downloader. Generated by expoPlugins/withRNBackgroundDownloader.js";
|
/\}\s*$/, // insert before final }
|
||||||
const backgroundDownloaderDelegate = `\n// Delegate method required by react-native-background-downloader. Generated by expoPlugins/withRNBackgroundDownloader.js
|
`
|
||||||
- (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)(void))completionHandler
|
func application(
|
||||||
{
|
_ application: UIApplication,
|
||||||
[RNBackgroundDownloader setCompletionHandlerWithIdentifier:identifier completionHandler:completionHandler];
|
handleEventsForBackgroundURLSession identifier: String,
|
||||||
}`;
|
completionHandler: @escaping () -> Void
|
||||||
|
) {
|
||||||
// Find the index of the AppDelegate import statement
|
RNBackgroundDownloader.setCompletionHandlerWithIdentifier(identifier, completionHandler: completionHandler)
|
||||||
const importIndex = appDelegateLines.findIndex((line) =>
|
}
|
||||||
/^#import "AppDelegate.h"/.test(line),
|
}`,
|
||||||
);
|
|
||||||
|
|
||||||
// Find the index of the last line before the @end statement
|
|
||||||
const endStatementIndex = appDelegateLines.findIndex((line) =>
|
|
||||||
/@end/.test(line),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Insert the import statement if it's not already present
|
|
||||||
if (!appDelegate.contents.includes(backgroundDownloaderImport)) {
|
|
||||||
appDelegateLines.splice(importIndex + 1, 0, backgroundDownloaderImport);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert the delegate method above the @end statement
|
|
||||||
if (!appDelegate.contents.includes(backgroundDownloaderDelegate)) {
|
|
||||||
appDelegateLines.splice(
|
|
||||||
endStatementIndex,
|
|
||||||
0,
|
|
||||||
backgroundDownloaderDelegate,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return mod;
|
||||||
// Update the contents of the AppDelegate file
|
|
||||||
appDelegate.contents = appDelegateLines.join("\n");
|
|
||||||
|
|
||||||
return appDelegateConfig;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* 2️⃣ Ensure bridging header exists & is attached to *every* app target */
|
||||||
|
config = withXcodeProject(config, (mod) => {
|
||||||
|
const project = mod.modResults;
|
||||||
|
const projectName = config.name || "App";
|
||||||
|
// Fix: Go up one more directory to get to ios/, not ios/ProjectName.xcodeproj/
|
||||||
|
const iosDir = path.dirname(path.dirname(project.filepath));
|
||||||
|
const headerRel = `${projectName}/${projectName}-Bridging-Header.h`;
|
||||||
|
const headerAbs = path.join(iosDir, headerRel);
|
||||||
|
|
||||||
|
// create / append import if missing
|
||||||
|
let headerText = "";
|
||||||
|
try {
|
||||||
|
headerText = fs.readFileSync(headerAbs, "utf8");
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== "ENOENT") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!headerText.includes("RNBackgroundDownloader.h")) {
|
||||||
|
fs.mkdirSync(path.dirname(headerAbs), { recursive: true });
|
||||||
|
fs.appendFileSync(headerAbs, '#import "RNBackgroundDownloader.h"\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expo 53's xcode‑js doesn't expose pbxTargets().
|
||||||
|
// Setting the property once at the project level is sufficient.
|
||||||
|
["Debug", "Release"].forEach((cfg) =>
|
||||||
|
project.updateBuildProperty(
|
||||||
|
"SWIFT_OBJC_BRIDGING_HEADER",
|
||||||
|
"Streamyfin/Streamyfin-Bridging-Header.h",
|
||||||
|
cfg,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return mod;
|
||||||
|
});
|
||||||
|
|
||||||
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = withRNBackgroundDownloader;
|
module.exports = withRNBackgroundDownloader;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import BackGroundDownloader from "@kesha-antonov/react-native-background-downloader";
|
|
||||||
import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import * as Application from "expo-application";
|
import * as Application from "expo-application";
|
||||||
@@ -42,6 +41,10 @@ import {
|
|||||||
import { Bitrate } from "../components/BitrateSelector";
|
import { Bitrate } from "../components/BitrateSelector";
|
||||||
import { apiAtom } from "./JellyfinProvider";
|
import { apiAtom } from "./JellyfinProvider";
|
||||||
|
|
||||||
|
const BackGroundDownloader = !Platform.isTV
|
||||||
|
? require("@kesha-antonov/react-native-background-downloader")
|
||||||
|
: null;
|
||||||
|
|
||||||
export type DownloadedItem = {
|
export type DownloadedItem = {
|
||||||
item: Partial<BaseItemDto>;
|
item: Partial<BaseItemDto>;
|
||||||
mediaSource: MediaSourceInfo;
|
mediaSource: MediaSourceInfo;
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.29.6" },
|
clientInfo: { name: "Streamyfin", version: "0.29.13" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -93,7 +93,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.29.6"`,
|
}, DeviceId="${deviceId}", Version="0.29.13"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
20
react-native.config.js
Normal file
20
react-native.config.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// react-native.config.js
|
||||||
|
//https://docs.expo.dev/modules/autolinking/
|
||||||
|
|
||||||
|
const isTV = process.env?.EXPO_TV === "1";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
dependencies: {
|
||||||
|
"react-native-volume-manager": !isTV
|
||||||
|
? {
|
||||||
|
platforms: {
|
||||||
|
// leaving this blank seems to enable auto-linking which is what we want for mobile
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
platforms: {
|
||||||
|
android: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -237,25 +237,38 @@ const loadSettings = (): Partial<Settings> => {
|
|||||||
return loadedValues;
|
return loadedValues;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load settings:", error);
|
console.error("Failed to load settings:", error);
|
||||||
return defaultValues;
|
return {};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const EXCLUDE_FROM_SAVE = ["home"];
|
const EXCLUDE_FROM_SAVE = ["home"];
|
||||||
|
|
||||||
const saveSettings = (settings: Settings) => {
|
const saveSettings = (settings: Settings) => {
|
||||||
for (const key of Object.keys(settings)) {
|
try {
|
||||||
if (EXCLUDE_FROM_SAVE.includes(key)) {
|
for (const key of Object.keys(settings)) {
|
||||||
delete settings[key as keyof Settings];
|
if (EXCLUDE_FROM_SAVE.includes(key)) {
|
||||||
|
delete settings[key as keyof Settings];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
const jsonValue = JSON.stringify(settings);
|
||||||
|
storage.set("settings", jsonValue);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save settings:", error);
|
||||||
}
|
}
|
||||||
const jsonValue = JSON.stringify(settings);
|
|
||||||
storage.set("settings", jsonValue);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const settingsAtom = atom<Partial<Settings> | null>(null);
|
export const settingsAtom = atom<Partial<Settings> | null>(null);
|
||||||
export const pluginSettingsAtom = atom(
|
const loadPluginSettings = () => {
|
||||||
storage.get<PluginLockableSettings>(STREAMYFIN_PLUGIN_SETTINGS),
|
try {
|
||||||
|
return storage.get<PluginLockableSettings>(STREAMYFIN_PLUGIN_SETTINGS);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load plugin settings:", error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pluginSettingsAtom = atom<PluginLockableSettings | undefined>(
|
||||||
|
loadPluginSettings(),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const useSettings = () => {
|
export const useSettings = () => {
|
||||||
@@ -317,7 +330,7 @@ export const useSettings = () => {
|
|||||||
// If admin sets locked to false but provides a value,
|
// If admin sets locked to false but provides a value,
|
||||||
// use user settings first and fallback on admin setting if required.
|
// use user settings first and fallback on admin setting if required.
|
||||||
const settings: Settings = useMemo(() => {
|
const settings: Settings = useMemo(() => {
|
||||||
const unlockedPluginDefaults = {} as Settings;
|
const unlockedPluginDefaults: Partial<Settings> = {};
|
||||||
const overrideSettings = Object.entries(pluginSettings ?? {}).reduce<
|
const overrideSettings = Object.entries(pluginSettings ?? {}).reduce<
|
||||||
Partial<Settings>
|
Partial<Settings>
|
||||||
>((acc, [key, setting]) => {
|
>((acc, [key, setting]) => {
|
||||||
@@ -331,14 +344,12 @@ export const useSettings = () => {
|
|||||||
value !== undefined &&
|
value !== undefined &&
|
||||||
_settings?.[settingsKey] !== value
|
_settings?.[settingsKey] !== value
|
||||||
) {
|
) {
|
||||||
Object.assign(unlockedPluginDefaults, {
|
(unlockedPluginDefaults as any)[settingsKey] = value;
|
||||||
[settingsKey]: value,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.assign(acc, {
|
(acc as any)[settingsKey] = locked
|
||||||
[settingsKey]: locked ? value : (_settings?.[settingsKey] ?? value),
|
? value
|
||||||
});
|
: (_settings?.[settingsKey] ?? value);
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export const reportPlaybackProgress = async ({
|
|||||||
itemId,
|
itemId,
|
||||||
positionTicks,
|
positionTicks,
|
||||||
IsPaused = false,
|
IsPaused = false,
|
||||||
deviceProfile,
|
|
||||||
}: ReportPlaybackProgressParams): Promise<void> => {
|
}: ReportPlaybackProgressParams): Promise<void> => {
|
||||||
if (!api || !sessionId || !itemId || !positionTicks) {
|
if (!api || !sessionId || !itemId || !positionTicks) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
import { MMKV } from "react-native-mmkv";
|
import { MMKV } from "react-native-mmkv";
|
||||||
|
|
||||||
|
// Create a single MMKV instance following the official documentation
|
||||||
|
// https://github.com/mrousavy/react-native-mmkv
|
||||||
export const storage = new MMKV();
|
export const storage = new MMKV();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type {
|
|||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { MMKV } from "react-native-mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { writeToLog } from "./log";
|
import { writeToLog } from "./log";
|
||||||
|
|
||||||
interface IJobInput {
|
interface IJobInput {
|
||||||
@@ -173,8 +173,6 @@ export function saveDownloadItemInfoToDiskTmp(
|
|||||||
url: string,
|
url: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
try {
|
try {
|
||||||
const storage = new MMKV();
|
|
||||||
|
|
||||||
const downloadInfo = JSON.stringify({
|
const downloadInfo = JSON.stringify({
|
||||||
item,
|
item,
|
||||||
mediaSource,
|
mediaSource,
|
||||||
@@ -206,7 +204,6 @@ export function getDownloadItemInfoFromDiskTmp(itemId: string): {
|
|||||||
url: string;
|
url: string;
|
||||||
} | null {
|
} | null {
|
||||||
try {
|
try {
|
||||||
const storage = new MMKV();
|
|
||||||
const rawInfo = storage.getString(`tmp_download_info_${itemId}`);
|
const rawInfo = storage.getString(`tmp_download_info_${itemId}`);
|
||||||
|
|
||||||
if (rawInfo) {
|
if (rawInfo) {
|
||||||
@@ -227,7 +224,6 @@ export function getDownloadItemInfoFromDiskTmp(itemId: string): {
|
|||||||
*/
|
*/
|
||||||
export function deleteDownloadItemInfoFromDiskTmp(itemId: string): boolean {
|
export function deleteDownloadItemInfoFromDiskTmp(itemId: string): boolean {
|
||||||
try {
|
try {
|
||||||
const storage = new MMKV();
|
|
||||||
storage.delete(`tmp_download_info_${itemId}`);
|
storage.delete(`tmp_download_info_${itemId}`);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user