forked from Ninjalama/streamyfin_mirror
Compare commits
41 Commits
refactor/s
...
revert-377
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e73299429 | ||
|
|
293a9517a5 | ||
|
|
38b6215046 | ||
|
|
9692c173ae | ||
|
|
a297ac4843 | ||
|
|
8470cbe8d5 | ||
|
|
1e869a2c2f | ||
|
|
b6502c042a | ||
|
|
7f0446b85f | ||
|
|
11fbe19f80 | ||
|
|
5c97b85492 | ||
|
|
e60cec69f8 | ||
|
|
7bc1c22770 | ||
|
|
e86dab5613 | ||
|
|
eeb803223c | ||
|
|
1a43f7ef1b | ||
|
|
f4624bdc25 | ||
|
|
3c5f2b4079 | ||
|
|
955190a9cc | ||
|
|
e1e4f4833c | ||
|
|
3b987646a6 | ||
|
|
0e574ea18d | ||
|
|
1a5fcdcb10 | ||
|
|
62b00837ec | ||
|
|
0fc48497d0 | ||
|
|
7e12136211 | ||
|
|
7639de153b | ||
|
|
ea3cc18b3c | ||
|
|
c9fb52086e | ||
|
|
878edc6909 | ||
|
|
74f0aca517 | ||
|
|
60bb3b905d | ||
|
|
fdde5fb56c | ||
|
|
49ae9c6f57 | ||
|
|
2254adb8d6 | ||
|
|
d4c722aeac | ||
|
|
eefcfb8be5 | ||
|
|
4af2712cc0 | ||
|
|
958b870bf0 | ||
|
|
ce7e1b255f | ||
|
|
acae4b4544 |
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -4,9 +4,7 @@ title: "[Bug]: "
|
||||
labels:
|
||||
- ["❌ bug"]
|
||||
projects:
|
||||
- ["fredrikburmester/5"]
|
||||
assignees:
|
||||
- fredrikburmester
|
||||
- ["streamyfin/3"]
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
@@ -45,7 +43,7 @@ body:
|
||||
label: Version
|
||||
description: What version of Streamyfin are you running?
|
||||
options:
|
||||
- 0.23.0
|
||||
- 0.24.0
|
||||
- 0.22.0
|
||||
- 0.21.0
|
||||
- older
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -4,7 +4,8 @@ about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: '✨ enhancement'
|
||||
assignees: ''
|
||||
|
||||
projects:
|
||||
- streamyfin/3
|
||||
---
|
||||
|
||||
**Describe the solution you'd like**
|
||||
|
||||
49
.github/workflows/build-ios.yaml
vendored
Normal file
49
.github/workflows/build-ios.yaml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: Automatic Build and Deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos-15
|
||||
name: Build IOS
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
name: Check out repository
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- run: |
|
||||
bun i && bun run submodule-reload
|
||||
npx expo prebuild
|
||||
- uses: sparkfabrik/ios-build-action@v2.3.0
|
||||
with:
|
||||
upload-to-testflight: false
|
||||
increment-build-number: false
|
||||
build-pods: true
|
||||
pods-path: "ios/Podfile"
|
||||
configuration: Release
|
||||
# Change later to app-store if wanted
|
||||
export-method: appstore
|
||||
#export-method: ad-hoc
|
||||
workspace-path: "ios/Streamyfin.xcodeproj/project.xcworkspace/"
|
||||
project-path: "ios/Streamyfin.xcodeproj"
|
||||
scheme: Streamyfin
|
||||
apple-key-id: ${{ secrets.APPLE_KEY_ID }}
|
||||
apple-key-issuer-id: ${{ secrets.APPLE_KEY_ISSUER_ID }}
|
||||
apple-key-content: ${{ secrets.APPLE_KEY_CONTENT }}
|
||||
team-id: ${{ secrets.TEAM_ID }}
|
||||
team-name: ${{ secrets.TEAM_NAME }}
|
||||
#match-password: ${{ secrets.MATCH_PASSWORD }}
|
||||
#match-git-url: ${{ secrets.MATCH_GIT_URL }}
|
||||
#match-git-basic-authorization: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
|
||||
#match-build-type: "appstore"
|
||||
#browserstack-upload: true
|
||||
#browserstack-username: ${{ secrets.BROWSERSTACK_USERNAME }}
|
||||
#browserstack-access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
|
||||
#fastlane-env: stage
|
||||
ios-app-id: com.stetsed.teststreamyfin
|
||||
output-path: build-${{ github.sha }}.ipa
|
||||
@@ -13,7 +13,7 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
- 🚀 **Skp intro / credits support**
|
||||
- 🚀 **Skip Intro / Credits Support**
|
||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
||||
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||
|
||||
4
app.json
4
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.23.0",
|
||||
"version": "0.24.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -36,7 +36,7 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 49,
|
||||
"versionCode": 50,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||
},
|
||||
|
||||
@@ -11,6 +11,9 @@ export default function SearchLayout() {
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: "Favorites",
|
||||
headerLargeStyle: {
|
||||
backgroundColor: "black",
|
||||
},
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
@@ -15,6 +16,9 @@ export default function IndexLayout() {
|
||||
headerLargeTitle: true,
|
||||
headerTitle: "Home",
|
||||
headerBlurEffect: "prominent",
|
||||
headerLargeStyle: {
|
||||
backgroundColor: "black",
|
||||
},
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerRight: () => (
|
||||
|
||||
@@ -6,16 +6,21 @@ import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {useNavigation, useRouter} from "expo-router";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {useEffect, useMemo, useRef} from "react";
|
||||
import {Alert, ScrollView, TouchableOpacity, View} from "react-native";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import {DownloadSize} from "@/components/downloads/DownloadSize";
|
||||
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
|
||||
import {toast} from "sonner-native";
|
||||
import {writeToLog} from "@/utils/log";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { toast } from "sonner-native";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
@@ -56,28 +61,29 @@ export default function page() {
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity
|
||||
onPress={bottomSheetModalRef.current?.present}
|
||||
>
|
||||
<DownloadSize items={downloadedFiles?.map(f => f.item) || []}/>
|
||||
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
|
||||
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
||||
</TouchableOpacity>
|
||||
)
|
||||
})
|
||||
),
|
||||
});
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const deleteMovies = () => deleteFileByType("Movie")
|
||||
.then(() => toast.success("Deleted all movies successfully!"))
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error("Failed to delete all movies");
|
||||
});
|
||||
const deleteShows = () => deleteFileByType("Episode")
|
||||
.then(() => toast.success("Deleted all TV-Series successfully!"))
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error("Failed to delete all TV-Series");
|
||||
});
|
||||
const deleteAllMedia = async () => await Promise.all([deleteMovies(), deleteShows()])
|
||||
const deleteMovies = () =>
|
||||
deleteFileByType("Movie")
|
||||
.then(() => toast.success("Deleted all movies successfully!"))
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error("Failed to delete all movies");
|
||||
});
|
||||
const deleteShows = () =>
|
||||
deleteFileByType("Episode")
|
||||
.then(() => toast.success("Deleted all TV-Series successfully!"))
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error("Failed to delete all TV-Series");
|
||||
});
|
||||
const deleteAllMedia = async () =>
|
||||
await Promise.all([deleteMovies(), deleteShows()]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -94,7 +100,7 @@ export default function page() {
|
||||
<View className="bg-neutral-900 p-4 rounded-2xl">
|
||||
<Text className="text-lg font-bold">Queue</Text>
|
||||
<Text className="text-xs opacity-70 text-red-600">
|
||||
Queue and downloads will be lost on app restart
|
||||
Queue and active downloads will be lost on app restart
|
||||
</Text>
|
||||
<View className="flex flex-col space-y-2 mt-2">
|
||||
{queue.map((q, index) => (
|
||||
@@ -107,7 +113,9 @@ export default function page() {
|
||||
>
|
||||
<View>
|
||||
<Text className="font-semibold">{q.item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">{q.item.Type}</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
{q.item.Type}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
@@ -118,7 +126,7 @@ export default function page() {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Ionicons name="close" size={24} color="red"/>
|
||||
<Ionicons name="close" size={24} color="red" />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
@@ -130,7 +138,7 @@ export default function page() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ActiveDownloads/>
|
||||
<ActiveDownloads />
|
||||
</View>
|
||||
|
||||
{movies.length > 0 && (
|
||||
@@ -145,7 +153,7 @@ export default function page() {
|
||||
<View className="px-4 flex flex-row">
|
||||
{movies?.map((item) => (
|
||||
<View className="mb-2 last:mb-0" key={item.item.Id}>
|
||||
<MovieCard item={item.item}/>
|
||||
<MovieCard item={item.item} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
@@ -157,13 +165,18 @@ export default function page() {
|
||||
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
||||
<Text className="text-lg font-bold">TV-Series</Text>
|
||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||
<Text className="text-xs font-bold">{groupedBySeries?.length}</Text>
|
||||
<Text className="text-xs font-bold">
|
||||
{groupedBySeries?.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className="px-4 flex flex-row">
|
||||
{groupedBySeries?.map((items) => (
|
||||
<View className="mb-2 last:mb-0" key={items[0].item.SeriesId}>
|
||||
<View
|
||||
className="mb-2 last:mb-0"
|
||||
key={items[0].item.SeriesId}
|
||||
>
|
||||
<SeriesCard
|
||||
items={items.map((i) => i.item)}
|
||||
key={items[0].item.SeriesId}
|
||||
@@ -200,9 +213,15 @@ export default function page() {
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className="p-4 space-y-4 mb-4">
|
||||
<Button color="purple" onPress={deleteMovies}>Delete all Movies</Button>
|
||||
<Button color="purple" onPress={deleteShows}>Delete all TV-Series</Button>
|
||||
<Button color="red" onPress={deleteAllMedia}>Delete all</Button>
|
||||
<Button color="purple" onPress={deleteMovies}>
|
||||
Delete all Movies
|
||||
</Button>
|
||||
<Button color="purple" onPress={deleteShows}>
|
||||
Delete all TV-Series
|
||||
</Button>
|
||||
<Button color="red" onPress={deleteAllMedia}>
|
||||
Delete all
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function page() {
|
||||
|
||||
return (
|
||||
<View className="px-4 pt-4">
|
||||
<ListGroup title={"Enable plugin"} className="mb-4">
|
||||
<ListGroup title={"Enable plugin"} className="">
|
||||
<ListItem
|
||||
title={"Enable Popular Lists"}
|
||||
onPress={() => {
|
||||
@@ -68,41 +68,6 @@ export default function page() {
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
|
||||
{settings.usePopularPlugin && (
|
||||
<ListGroup title="Media List Collections">
|
||||
{mediaListCollections?.map((mlc) => (
|
||||
<ListItem key={mlc.Id} title={mlc.Name}>
|
||||
<Switch
|
||||
value={settings.mediaListCollectionIds?.includes(mlc.Id!)}
|
||||
onValueChange={(value) => {
|
||||
if (!settings.mediaListCollectionIds) {
|
||||
updateSettings({
|
||||
mediaListCollectionIds: [mlc.Id!],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateSettings({
|
||||
mediaListCollectionIds:
|
||||
settings.mediaListCollectionIds.includes(mlc.Id!)
|
||||
? settings.mediaListCollectionIds.filter(
|
||||
(id) => id !== mlc.Id
|
||||
)
|
||||
: [...settings.mediaListCollectionIds, mlc.Id!],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
{isLoadingMediaListCollections && <Loader />}
|
||||
{mediaListCollections?.length === 0 && (
|
||||
<Text className="text-xs opacity-50 p-4">
|
||||
No collections found. Add some in Jellyfin.
|
||||
</Text>
|
||||
)}
|
||||
</ListGroup>
|
||||
)}
|
||||
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||
Popular Lists is a plugin that enables you to show custom Jellyfin lists
|
||||
on the Streamyfin home page.{" "}
|
||||
@@ -110,6 +75,61 @@ export default function page() {
|
||||
Read more about Popular Lists.
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
{settings.usePopularPlugin && (
|
||||
<>
|
||||
{!isLoadingMediaListCollections ? (
|
||||
<>
|
||||
{mediaListCollections?.length === 0 ? (
|
||||
<Text className="text-xs opacity-50 p-4">
|
||||
No collections found. Add some in Jellyfin.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<ListGroup title="Media List Collections" className="mt-4">
|
||||
{mediaListCollections?.map((mlc) => (
|
||||
<ListItem key={mlc.Id} title={mlc.Name}>
|
||||
<Switch
|
||||
value={settings.mediaListCollectionIds?.includes(
|
||||
mlc.Id!
|
||||
)}
|
||||
onValueChange={(value) => {
|
||||
if (!settings.mediaListCollectionIds) {
|
||||
updateSettings({
|
||||
mediaListCollectionIds: [mlc.Id!],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateSettings({
|
||||
mediaListCollectionIds:
|
||||
settings.mediaListCollectionIds.includes(
|
||||
mlc.Id!
|
||||
)
|
||||
? settings.mediaListCollectionIds.filter(
|
||||
(id) => id !== mlc.Id
|
||||
)
|
||||
: [
|
||||
...settings.mediaListCollectionIds,
|
||||
mlc.Id!,
|
||||
],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</ListGroup>
|
||||
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||
Select the lists you want displayed on the home screen.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ const Page: React.FC = () => {
|
||||
data: details,
|
||||
isFetching,
|
||||
isLoading,
|
||||
refetch
|
||||
} = useQuery({
|
||||
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
||||
@@ -63,6 +64,7 @@ const Page: React.FC = () => {
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
retryOnMount: true,
|
||||
refetchInterval: 0,
|
||||
queryFn: async () => {
|
||||
return result.mediaType === MediaType.MOVIE
|
||||
? jellyseerrApi?.movieDetails(result.id!!)
|
||||
@@ -94,15 +96,18 @@ const Page: React.FC = () => {
|
||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||
|
||||
const request = useCallback(
|
||||
() =>
|
||||
async () => {
|
||||
requestMedia(mediaTitle, {
|
||||
mediaId: Number(result.id!!),
|
||||
mediaType: result.mediaType!!,
|
||||
tvdbId: details?.externalIds?.tvdbId,
|
||||
seasons: (details as TvDetails)?.seasons
|
||||
?.filter?.((s) => s.seasonNumber !== 0)
|
||||
?.map?.((s) => s.seasonNumber),
|
||||
}),
|
||||
mediaId: Number(result.id!!),
|
||||
mediaType: result.mediaType!!,
|
||||
tvdbId: details?.externalIds?.tvdbId,
|
||||
seasons: (details as TvDetails)?.seasons
|
||||
?.filter?.((s) => s.seasonNumber !== 0)
|
||||
?.map?.((s) => s.seasonNumber),
|
||||
},
|
||||
refetch
|
||||
)
|
||||
},
|
||||
[details, result, requestMedia]
|
||||
);
|
||||
|
||||
@@ -205,6 +210,7 @@ const Page: React.FC = () => {
|
||||
isLoading={isLoading || isFetching}
|
||||
result={result as TvResult}
|
||||
details={details as TvDetails}
|
||||
refetch={refetch}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -150,6 +150,8 @@ const Page = () => {
|
||||
itemType = "Series";
|
||||
} else if (library.CollectionType === "boxsets") {
|
||||
itemType = "BoxSet";
|
||||
} else if (library.CollectionType === "music") {
|
||||
itemType = "MusicAlbum";
|
||||
}
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
|
||||
@@ -19,6 +19,9 @@ export default function IndexLayout() {
|
||||
headerLargeTitle: true,
|
||||
headerTitle: "Library",
|
||||
headerBlurEffect: "prominent",
|
||||
headerLargeStyle: {
|
||||
backgroundColor: "black",
|
||||
},
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerRight: () => (
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import {commonScreenOptions, nestedTabPageScreenOptions} from "@/components/stacks/NestedTabPageStack";
|
||||
import {
|
||||
commonScreenOptions,
|
||||
nestedTabPageScreenOptions,
|
||||
} from "@/components/stacks/NestedTabPageStack";
|
||||
import { Stack } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
@@ -11,6 +14,9 @@ export default function SearchLayout() {
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: "Search",
|
||||
headerLargeStyle: {
|
||||
backgroundColor: "black",
|
||||
},
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
@@ -29,10 +35,7 @@ export default function SearchLayout() {
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="jellyseerr/page"
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,7 +96,6 @@ export default function search() {
|
||||
} else {
|
||||
if (!settings?.marlinServerUrl) return [];
|
||||
|
||||
console.log(settings.marlinServerUrl);
|
||||
const url = `${
|
||||
settings.marlinServerUrl
|
||||
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
||||
@@ -105,8 +104,6 @@ export default function search() {
|
||||
|
||||
const response1 = await axios.get(url);
|
||||
|
||||
console.log(response1.statusText);
|
||||
|
||||
const ids = response1.data.ids;
|
||||
|
||||
if (!ids || !ids.length) return [];
|
||||
|
||||
@@ -104,7 +104,6 @@ export default function page() {
|
||||
} = useQuery({
|
||||
queryKey: ["item", itemId],
|
||||
queryFn: async () => {
|
||||
console.log("Offline:", offline);
|
||||
if (offline) {
|
||||
const item = await getDownloadedItem(itemId);
|
||||
if (item) return item.item;
|
||||
@@ -128,7 +127,6 @@ export default function page() {
|
||||
} = useQuery({
|
||||
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
||||
queryFn: async () => {
|
||||
console.log("Offline:", offline);
|
||||
if (offline) {
|
||||
const data = await getDownloadedItem(itemId);
|
||||
if (!data?.mediaSource) return null;
|
||||
@@ -195,8 +193,6 @@ export default function page() {
|
||||
playSessionId: stream.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Actually marked as paused");
|
||||
} else {
|
||||
videoRef.current?.play();
|
||||
if (!offline && stream) {
|
||||
@@ -339,7 +335,6 @@ export default function page() {
|
||||
React.useCallback(() => {
|
||||
return async () => {
|
||||
stop();
|
||||
console.log("Unmounted");
|
||||
};
|
||||
}, [])
|
||||
);
|
||||
@@ -349,10 +344,8 @@ export default function page() {
|
||||
useEffect(() => {
|
||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||
if (appState.match(/inactive|background/) && nextAppState === "active") {
|
||||
console.log("App has come to the foreground!");
|
||||
// Handle app coming to the foreground
|
||||
} else if (nextAppState.match(/inactive|background/)) {
|
||||
console.log("App has gone to the background!");
|
||||
// Handle app going to the background
|
||||
if (videoRef.current && videoRef.current.pause) {
|
||||
videoRef.current.pause();
|
||||
@@ -442,7 +435,6 @@ export default function page() {
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
opacity: showControls ? (Platform.OS === "android" ? 0.7 : 0.5) : 1,
|
||||
}}
|
||||
>
|
||||
<VlcPlayerView
|
||||
|
||||
@@ -169,18 +169,15 @@ export default function page() {
|
||||
);
|
||||
|
||||
const play = useCallback(() => {
|
||||
console.log("play");
|
||||
videoRef.current?.resume();
|
||||
reportPlaybackStart();
|
||||
}, [videoRef]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
console.log("play");
|
||||
videoRef.current?.pause();
|
||||
}, [videoRef]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
console.log("stop");
|
||||
setIsPlaybackStopped(true);
|
||||
videoRef.current?.pause();
|
||||
reportPlaybackStopped();
|
||||
|
||||
@@ -260,13 +260,6 @@ const Player = () => {
|
||||
progress.value = ticks;
|
||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||
|
||||
console.log(
|
||||
"onProgress ~",
|
||||
ticks,
|
||||
isPlaying,
|
||||
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
|
||||
);
|
||||
|
||||
// TODO: Use this when streaming with HLS url, but NOT when direct playing
|
||||
// TODO: since playable duration is always 0 then.
|
||||
setIsBuffering(data.playableDuration === 0);
|
||||
@@ -339,11 +332,7 @@ const Player = () => {
|
||||
|
||||
// Most likely the subtitle is burned in.
|
||||
if (embeddedTrackIndex === -1) return;
|
||||
console.log(
|
||||
"Setting selected text track",
|
||||
subtitleIndex,
|
||||
embeddedTrackIndex
|
||||
);
|
||||
|
||||
setSelectedTextTrack({
|
||||
type: SelectedTrackType.INDEX,
|
||||
value: embeddedTrackIndex,
|
||||
@@ -398,7 +387,6 @@ const Player = () => {
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
opacity: showControls ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{videoSource ? (
|
||||
@@ -439,7 +427,6 @@ const Player = () => {
|
||||
setIsBuffering(e.isBuffering);
|
||||
}}
|
||||
onAudioTracks={(e) => {
|
||||
console.log("onAudioTracks: ", e.audioTracks);
|
||||
setAudioTracks(
|
||||
e.audioTracks.map((t) => ({
|
||||
index: t.index,
|
||||
@@ -493,7 +480,6 @@ const Player = () => {
|
||||
}}
|
||||
getAudioTracks={getAudioTracks}
|
||||
setAudioTrack={(i) => {
|
||||
console.log("setAudioTrack ~", i);
|
||||
setSelectedAudioTrack({
|
||||
type: SelectedTrackType.INDEX,
|
||||
value: i,
|
||||
|
||||
@@ -5,7 +5,6 @@ import YoutubePlayer, { PLAYER_STATES } from "react-native-youtube-iframe";
|
||||
|
||||
export default function page() {
|
||||
const searchParams = useGlobalSearchParams();
|
||||
console.log(searchParams);
|
||||
|
||||
const { url } = searchParams as { url: string };
|
||||
|
||||
|
||||
249
app/login.tsx
249
app/login.tsx
@@ -1,6 +1,7 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
@@ -8,7 +9,7 @@ import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
@@ -120,7 +121,7 @@ const Login: React.FC = () => {
|
||||
* - Sets loadingServerCheck state to true at the beginning and false at the end.
|
||||
* - Logs errors and timeout information to the console.
|
||||
*/
|
||||
async function checkUrl(url: string) {
|
||||
const checkUrl = useCallback(async (url: string) => {
|
||||
setLoadingServerCheck(true);
|
||||
|
||||
try {
|
||||
@@ -130,6 +131,7 @@ const Login: React.FC = () => {
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as PublicSystemInfo;
|
||||
|
||||
setServerName(data.ServerName || "");
|
||||
return url;
|
||||
}
|
||||
@@ -138,7 +140,7 @@ const Login: React.FC = () => {
|
||||
} finally {
|
||||
setLoadingServerCheck(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handles the connection attempt to a Jellyfin server.
|
||||
@@ -156,7 +158,7 @@ const Login: React.FC = () => {
|
||||
* - Sets the server address using `setServer` if the connection is successful.
|
||||
*
|
||||
*/
|
||||
const handleConnect = async (url: string) => {
|
||||
const handleConnect = useCallback(async (url: string) => {
|
||||
url = url.trim();
|
||||
|
||||
const result = await checkUrl(url);
|
||||
@@ -170,7 +172,7 @@ const Login: React.FC = () => {
|
||||
}
|
||||
|
||||
setServer({ address: url });
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleQuickConnect = async () => {
|
||||
try {
|
||||
@@ -187,128 +189,129 @@ const Login: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (api?.basePath) {
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1, height: "100%" }}
|
||||
>
|
||||
<View className="flex flex-col h-full relative items-center justify-center">
|
||||
<View className="px-4 -mt-20 w-full">
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Text className="text-2xl font-bold -mb-2">
|
||||
Log in
|
||||
<>
|
||||
{serverName ? (
|
||||
<>
|
||||
{" to "}
|
||||
<Text className="text-purple-600">{serverName}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
</Text>
|
||||
<Text className="text-xs text-neutral-400">{serverURL}</Text>
|
||||
<Input
|
||||
placeholder="Username"
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, username: text })
|
||||
}
|
||||
value={credentials.username}
|
||||
autoFocus
|
||||
secureTextEntry={false}
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="username"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
|
||||
<Input
|
||||
className="mb-2"
|
||||
placeholder="Password"
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, password: text })
|
||||
}
|
||||
value={credentials.password}
|
||||
secureTextEntry
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="password"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text className="text-red-600 mb-2">{error}</Text>
|
||||
</View>
|
||||
|
||||
<View className="absolute bottom-0 left-0 w-full px-4 mb-2">
|
||||
<Button
|
||||
color="black"
|
||||
onPress={handleQuickConnect}
|
||||
className="w-full mb-2"
|
||||
>
|
||||
Use Quick Connect
|
||||
</Button>
|
||||
<Button onPress={handleLogin} loading={loading}>
|
||||
Log in
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1, height: "100%" }}
|
||||
>
|
||||
<View className="flex flex-col h-full relative items-center justify-center w-full">
|
||||
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
|
||||
<Image
|
||||
style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
marginLeft: -23,
|
||||
marginBottom: -20,
|
||||
}}
|
||||
source={require("@/assets/images/StreamyFinFinal.png")}
|
||||
/>
|
||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||
<Text className="text-neutral-500">
|
||||
Enter the URL to your Jellyfin server
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Server URL"
|
||||
onChangeText={setServerURL}
|
||||
value={serverURL}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
maxLength={500}
|
||||
/>
|
||||
<Text className="text-xs text-neutral-500">
|
||||
Make sure to include http or https
|
||||
</Text>
|
||||
</View>
|
||||
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
|
||||
<Button
|
||||
loading={loadingServerCheck}
|
||||
disabled={loadingServerCheck}
|
||||
onPress={async () => await handleConnect(serverURL)}
|
||||
className="w-full grow"
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
{api?.basePath ? (
|
||||
<>
|
||||
<View className="flex flex-col h-full relative items-center justify-center">
|
||||
<View className="px-4 -mt-20 w-full">
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Text className="text-2xl font-bold -mb-2">
|
||||
Log in
|
||||
<>
|
||||
{serverName ? (
|
||||
<>
|
||||
{" to "}
|
||||
<Text className="text-purple-600">{serverName}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
</Text>
|
||||
<Text className="text-xs text-neutral-400">
|
||||
{api.basePath}
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Username"
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, username: text })
|
||||
}
|
||||
value={credentials.username}
|
||||
autoFocus
|
||||
secureTextEntry={false}
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="username"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
|
||||
<Input
|
||||
className="mb-2"
|
||||
placeholder="Password"
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, password: text })
|
||||
}
|
||||
value={credentials.password}
|
||||
secureTextEntry
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="password"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text className="text-red-600 mb-2">{error}</Text>
|
||||
</View>
|
||||
|
||||
<View className="absolute bottom-0 left-0 w-full px-4 mb-2">
|
||||
<Button
|
||||
color="black"
|
||||
onPress={handleQuickConnect}
|
||||
className="w-full mb-2"
|
||||
>
|
||||
Use Quick Connect
|
||||
</Button>
|
||||
<Button onPress={handleLogin} loading={loading}>
|
||||
Log in
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View className="flex flex-col h-full relative items-center justify-center w-full">
|
||||
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
|
||||
<Image
|
||||
style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
marginLeft: -23,
|
||||
marginBottom: -20,
|
||||
}}
|
||||
source={require("@/assets/images/StreamyFinFinal.png")}
|
||||
/>
|
||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||
<Text className="text-neutral-500">
|
||||
Enter the URL to your Jellyfin server
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Server URL"
|
||||
onChangeText={setServerURL}
|
||||
value={serverURL}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
maxLength={500}
|
||||
/>
|
||||
<Text className="text-xs text-neutral-500 ml-4">
|
||||
Make sure to include http or https
|
||||
</Text>
|
||||
<PreviousServersList
|
||||
onServerSelect={(s) => {
|
||||
handleConnect(s.address);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
|
||||
<Button
|
||||
loading={loadingServerCheck}
|
||||
disabled={loadingServerCheck}
|
||||
onPress={async () => await handleConnect(serverURL)}
|
||||
className="w-full grow"
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ export const Tags: React.FC<TagProps & ViewProps> = ({ tags, textClass = "text-x
|
||||
return (
|
||||
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
|
||||
{tags.map((tag, idx) => (
|
||||
<View>
|
||||
<View key={idx}>
|
||||
<Tag key={idx} textClass={textClass} text={tag}/>
|
||||
</View>
|
||||
))}
|
||||
|
||||
48
components/PreviousServersList.tsx
Normal file
48
components/PreviousServersList.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useMMKVString } from "react-native-mmkv";
|
||||
import { ListGroup } from "./list/ListGroup";
|
||||
import { ListItem } from "./list/ListItem";
|
||||
|
||||
interface Server {
|
||||
address: string;
|
||||
}
|
||||
|
||||
interface PreviousServersListProps {
|
||||
onServerSelect: (server: Server) => void;
|
||||
}
|
||||
|
||||
export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||
onServerSelect,
|
||||
}) => {
|
||||
const [_previousServers, setPreviousServers] =
|
||||
useMMKVString("previousServers");
|
||||
|
||||
const previousServers = useMemo(() => {
|
||||
return JSON.parse(_previousServers || "[]") as Server[];
|
||||
}, [_previousServers]);
|
||||
|
||||
if (!previousServers.length) return null;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ListGroup title="previous servers" className="mt-4">
|
||||
{previousServers.map((s) => (
|
||||
<ListItem
|
||||
key={s.address}
|
||||
onPress={() => onServerSelect(s)}
|
||||
title={s.address}
|
||||
showArrow
|
||||
/>
|
||||
))}
|
||||
<ListItem
|
||||
onPress={() => {
|
||||
setPreviousServers("[]");
|
||||
}}
|
||||
title={"Clear"}
|
||||
textColor="red"
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -16,7 +16,7 @@ export const itemRouter = (
|
||||
item: BaseItemDto | BaseItemPerson,
|
||||
from: string
|
||||
) => {
|
||||
console.log(item.Type);
|
||||
console.log(item.Type, item?.CollectionType);
|
||||
|
||||
if ("CollectionType" in item && item.CollectionType === "livetv") {
|
||||
return `/(auth)/(tabs)/${from}/livetv`;
|
||||
|
||||
@@ -84,21 +84,27 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
||||
|
||||
const width = Dimensions.get("screen").width;
|
||||
|
||||
if (settings?.usePopularPlugin === false) return null;
|
||||
if (l1 || l2) return null;
|
||||
if (!popularItems) return null;
|
||||
|
||||
return (
|
||||
<View className="flex flex-col items-center" {...props}>
|
||||
<View className="flex flex-col items-center mt-2" {...props}>
|
||||
<Carousel
|
||||
autoPlay={true}
|
||||
autoPlayInterval={3000}
|
||||
loop={true}
|
||||
ref={ref}
|
||||
autoPlay={false}
|
||||
loop={true}
|
||||
snapEnabled={true}
|
||||
mode="parallax"
|
||||
modeConfig={{
|
||||
parallaxScrollingScale: 0.86,
|
||||
parallaxScrollingOffset: 100,
|
||||
}}
|
||||
width={width}
|
||||
height={204}
|
||||
data={popularItems}
|
||||
onProgressChange={progress}
|
||||
renderItem={({ item, index }) => <RenderItem item={item} />}
|
||||
renderItem={({ item, index }) => <RenderItem key={index} item={item} />}
|
||||
/>
|
||||
<Pagination.Basic
|
||||
progress={progress}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
CollectionType,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
@@ -50,18 +51,52 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
||||
[library]
|
||||
);
|
||||
|
||||
const itemType = useMemo(() => {
|
||||
let _itemType: BaseItemKind | undefined;
|
||||
|
||||
if (library.CollectionType === "movies") {
|
||||
_itemType = "Movie";
|
||||
} else if (library.CollectionType === "tvshows") {
|
||||
_itemType = "Series";
|
||||
} else if (library.CollectionType === "boxsets") {
|
||||
_itemType = "BoxSet";
|
||||
} else if (library.CollectionType === "music") {
|
||||
_itemType = "MusicAlbum";
|
||||
}
|
||||
|
||||
return _itemType;
|
||||
}, [library.CollectionType]);
|
||||
|
||||
const itemTypeName = useMemo(() => {
|
||||
let nameStr: string;
|
||||
|
||||
if (library.CollectionType === "movies") {
|
||||
nameStr = "movies";
|
||||
} else if (library.CollectionType === "tvshows") {
|
||||
nameStr = "series";
|
||||
} else if (library.CollectionType === "boxsets") {
|
||||
nameStr = "box sets";
|
||||
} else if (library.CollectionType === "music") {
|
||||
nameStr = "albums";
|
||||
} else {
|
||||
nameStr = "items";
|
||||
}
|
||||
|
||||
return nameStr;
|
||||
}, [library.CollectionType]);
|
||||
|
||||
const { data: itemsCount } = useQuery({
|
||||
queryKey: ["library-count", library.Id],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
const response = await getItemsApi(api).getItems({
|
||||
const response = await getItemsApi(api!).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: library.Id,
|
||||
recursive: true,
|
||||
limit: 0,
|
||||
includeItemTypes: itemType ? [itemType] : undefined,
|
||||
});
|
||||
return response.data.TotalRecordCount;
|
||||
},
|
||||
staleTime: 1000 * 60 * 60,
|
||||
});
|
||||
|
||||
if (!url) return null;
|
||||
@@ -80,7 +115,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
||||
</Text>
|
||||
{settings?.libraryOptions?.showStats && (
|
||||
<Text className="font-bold text-xs text-neutral-500 text-start ml-auto">
|
||||
{itemsCount} items
|
||||
{itemsCount} {itemTypeName}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -109,6 +144,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
@@ -128,7 +164,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
||||
)}
|
||||
{settings?.libraryOptions?.showStats && (
|
||||
<Text className="font-bold text-xs text-start px-4">
|
||||
{itemsCount} items
|
||||
{itemsCount} {itemTypeName}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -145,7 +181,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
||||
</Text>
|
||||
{settings?.libraryOptions?.showStats && (
|
||||
<Text className="font-bold text-xs text-neutral-500 text-start px-4">
|
||||
{itemsCount} items
|
||||
{itemsCount} {itemTypeName}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -23,14 +23,13 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
|
||||
const childrenArray = Children.toArray(children);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View {...props}>
|
||||
<Text className="ml-4 mb-1 uppercase text-[#8E8D91] text-xs">
|
||||
{title}
|
||||
</Text>
|
||||
<View
|
||||
style={[]}
|
||||
className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900"
|
||||
{...props}
|
||||
>
|
||||
{Children.map(childrenArray, (child, index) => {
|
||||
if (isValidElement<{ style?: ViewStyle }>(child)) {
|
||||
|
||||
@@ -15,11 +15,12 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import { RoundButton } from "@/components/RoundButton";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {QueryObserverResult, RefetchOptions, useQuery} from "@tanstack/react-query";
|
||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||
import { Image } from "expo-image";
|
||||
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||
import { Loader } from "../Loader";
|
||||
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
|
||||
|
||||
const JellyseerrSeasonEpisodes: React.FC<{
|
||||
details: TvDetails;
|
||||
@@ -100,7 +101,8 @@ const JellyseerrSeasons: React.FC<{
|
||||
isLoading: boolean;
|
||||
result?: TvResult;
|
||||
details?: TvDetails;
|
||||
}> = ({ isLoading, result, details }) => {
|
||||
refetch: (options?: (RefetchOptions | undefined)) => Promise<QueryObserverResult<TvDetails | MovieDetails | undefined, Error>>;
|
||||
}> = ({ isLoading, result, details, refetch }) => {
|
||||
if (!details) return null;
|
||||
|
||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||
@@ -168,6 +170,21 @@ const JellyseerrSeasons: React.FC<{
|
||||
[requestAll]
|
||||
);
|
||||
|
||||
const requestSeason = useCallback(async (canRequest: Boolean, seasonNumber: number) => {
|
||||
if (canRequest) {
|
||||
requestMedia(
|
||||
`${result?.name!!}, Season ${seasonNumber}`,
|
||||
{
|
||||
mediaId: details.id,
|
||||
mediaType: MediaType.TV,
|
||||
tvdbId: details.externalIds?.tvdbId,
|
||||
seasons: [seasonNumber],
|
||||
},
|
||||
refetch
|
||||
)
|
||||
}
|
||||
}, [requestMedia]);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<View>
|
||||
@@ -231,22 +248,7 @@ const JellyseerrSeasons: React.FC<{
|
||||
return (
|
||||
<JellyseerrIconStatus
|
||||
key={0}
|
||||
onPress={
|
||||
canRequest
|
||||
? () =>
|
||||
requestMedia(
|
||||
`${result?.name!!}, Season ${
|
||||
season.seasonNumber
|
||||
}`,
|
||||
{
|
||||
mediaId: details.id,
|
||||
mediaType: MediaType.TV,
|
||||
tvdbId: details.externalIds?.tvdbId,
|
||||
seasons: [season.seasonNumber],
|
||||
}
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
onPress={() => requestSeason(canRequest, season.seasonNumber)}
|
||||
className={canRequest ? "bg-gray-700/40" : undefined}
|
||||
mediaStatus={
|
||||
seasons?.find(
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Input } from "../common/Input";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { Loader } from "../Loader";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Button } from "../Button";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useAtom } from "jotai";
|
||||
import { toast } from "sonner-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Button } from "../Button";
|
||||
import { Input } from "../common/Input";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const JellyseerrSettings = () => {
|
||||
const {
|
||||
|
||||
@@ -16,7 +16,7 @@ export const PluginSettings = () => {
|
||||
<ListGroup title="Plugins">
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/jellyseerr/page")}
|
||||
title={"Jellyseerr Settings"}
|
||||
title={"Jellyseerr"}
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
|
||||
@@ -1,59 +1,113 @@
|
||||
import { Alert, View, ViewProps } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { Button } from "../Button";
|
||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useAtom } from "jotai";
|
||||
import Constants from "expo-constants";
|
||||
import Application from "expo-application";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetTextInput,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import { Alert, View, ViewProps } from "react-native";
|
||||
import { Button } from "../Button";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [quickConnectCode, setQuickConnectCode] = useState<string>();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const openQuickConnectAuthCodeInput = () => {
|
||||
Alert.prompt(
|
||||
"Quick connect",
|
||||
"Enter the quick connect code",
|
||||
async (text) => {
|
||||
if (text) {
|
||||
try {
|
||||
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
||||
code: text,
|
||||
userId: user?.Id,
|
||||
});
|
||||
if (res.status === 200) {
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
Alert.alert("Success", "Quick connect authorized");
|
||||
} else {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
Alert.alert("Error", "Invalid code");
|
||||
}
|
||||
} catch (e) {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
Alert.alert("Error", "Invalid code");
|
||||
}
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const authorizeQuickConnect = useCallback(async () => {
|
||||
if (quickConnectCode) {
|
||||
try {
|
||||
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
||||
code: quickConnectCode,
|
||||
userId: user?.Id,
|
||||
});
|
||||
if (res.status === 200) {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
Alert.alert("Success", "Quick connect authorized");
|
||||
setQuickConnectCode(undefined);
|
||||
bottomSheetModalRef?.current?.close();
|
||||
} else {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
Alert.alert("Error", "Invalid code");
|
||||
}
|
||||
} catch (e) {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
Alert.alert("Error", "Invalid code");
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
}, [api, user, quickConnectCode]);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<ListGroup title={"Quick Connect"}>
|
||||
<ListItem
|
||||
onPress={openQuickConnectAuthCodeInput}
|
||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||
title="Authorize Quick Connect"
|
||||
textColor="blue"
|
||||
></ListItem>
|
||||
/>
|
||||
</ListGroup>
|
||||
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={renderBackdrop}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||
<View>
|
||||
<Text className="font-bold text-2xl text-neutral-100">
|
||||
Quick Connect
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-col space-y-2">
|
||||
<View className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full">
|
||||
<BottomSheetTextInput
|
||||
style={{ color: "white" }}
|
||||
clearButtonMode="always"
|
||||
placeholder="Enter the quick connect code..."
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={quickConnectCode}
|
||||
onChangeText={setQuickConnectCode}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
className="mt-auto"
|
||||
onPress={authorizeQuickConnect}
|
||||
color="purple"
|
||||
>
|
||||
Authorize
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,10 @@ export const UserInfo: React.FC<Props> = ({ ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const version = Application?.nativeApplicationVersion || "N/A";
|
||||
const version =
|
||||
Application?.nativeApplicationVersion ||
|
||||
Application?.nativeBuildVersion ||
|
||||
"N/A";
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
|
||||
@@ -497,33 +497,6 @@ export const Controls: React.FC<Props> = ({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<VideoProvider
|
||||
getAudioTracks={getAudioTracks}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
setAudioTrack={setAudioTrack}
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
|
||||
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
|
||||
opacity: showControls ? 1 : 0,
|
||||
zIndex: 1000,
|
||||
},
|
||||
]}
|
||||
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
|
||||
>
|
||||
{!mediaSource?.TranscodingUrl ? (
|
||||
<DropdownViewDirect showControls={showControls} />
|
||||
) : (
|
||||
<DropdownViewTranscoding showControls={showControls} />
|
||||
)}
|
||||
</View>
|
||||
</VideoProvider>
|
||||
|
||||
<Pressable
|
||||
onPressIn={() => {
|
||||
toggleControls();
|
||||
@@ -532,6 +505,8 @@ export const Controls: React.FC<Props> = ({
|
||||
position: "absolute",
|
||||
width: Dimensions.get("window").width,
|
||||
height: Dimensions.get("window").height,
|
||||
backgroundColor: "black",
|
||||
opacity: showControls ? 0.5 : 0,
|
||||
}}
|
||||
></Pressable>
|
||||
|
||||
@@ -541,61 +516,82 @@ export const Controls: React.FC<Props> = ({
|
||||
position: "absolute",
|
||||
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
|
||||
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
|
||||
width: settings?.safeAreaInControlsEnabled
|
||||
? Dimensions.get("window").width - insets.left - insets.right
|
||||
: Dimensions.get("window").width,
|
||||
opacity: showControls ? 1 : 0,
|
||||
},
|
||||
]}
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
|
||||
className={`flex flex-row w-full p-4 `}
|
||||
>
|
||||
{item?.Type === "Episode" && !offline && (
|
||||
<View className="mr-auto">
|
||||
<VideoProvider
|
||||
getAudioTracks={getAudioTracks}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
setAudioTrack={setAudioTrack}
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
>
|
||||
{!mediaSource?.TranscodingUrl ? (
|
||||
<DropdownViewDirect showControls={showControls} />
|
||||
) : (
|
||||
<DropdownViewTranscoding showControls={showControls} />
|
||||
)}
|
||||
</VideoProvider>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center space-x-2 ">
|
||||
{item?.Type === "Episode" && !offline && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
switchOnEpisodeMode();
|
||||
}}
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons name="list" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{previousItem && !offline && (
|
||||
<TouchableOpacity
|
||||
onPress={goToPreviousItem}
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons name="play-skip-back" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{nextItem && !offline && (
|
||||
<TouchableOpacity
|
||||
onPress={goToNextItem}
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons name="play-skip-forward" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{mediaSource?.TranscodingUrl && (
|
||||
<TouchableOpacity
|
||||
onPress={toggleIgnoreSafeAreas}
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons
|
||||
name={ignoreSafeAreas ? "contract-outline" : "expand"}
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
switchOnEpisodeMode();
|
||||
onPress={async () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
router.back();
|
||||
}}
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons name="list" size={24} color="white" />
|
||||
<Ionicons name="close" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{previousItem && !offline && (
|
||||
<TouchableOpacity
|
||||
onPress={goToPreviousItem}
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons name="play-skip-back" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{nextItem && !offline && (
|
||||
<TouchableOpacity
|
||||
onPress={goToNextItem}
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons name="play-skip-forward" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{mediaSource?.TranscodingUrl && (
|
||||
<TouchableOpacity
|
||||
onPress={toggleIgnoreSafeAreas}
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons
|
||||
name={ignoreSafeAreas ? "contract-outline" : "expand"}
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
router.back();
|
||||
}}
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons name="close" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
|
||||
@@ -118,14 +118,7 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
zIndex: 1000,
|
||||
opacity: showControls ? 1 : 0,
|
||||
}}
|
||||
className="p-4"
|
||||
>
|
||||
<View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
|
||||
|
||||
4
eas.json
4
eas.json
@@ -22,13 +22,13 @@
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"channel": "0.23.0",
|
||||
"channel": "0.24.0",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"channel": "0.23.0",
|
||||
"channel": "0.24.0",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useMemo } from "react";
|
||||
|
||||
// Used only for intial play settings.
|
||||
// Used only for initial play settings.
|
||||
const useDefaultPlaySettings = (
|
||||
item: BaseItemDto,
|
||||
settings: Settings | null
|
||||
|
||||
@@ -337,12 +337,13 @@ export const useJellyseerr = () => {
|
||||
}, []);
|
||||
|
||||
const requestMedia = useCallback(
|
||||
(title: string, request: MediaRequestBody) => {
|
||||
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
|
||||
jellyseerrApi?.request?.(request)?.then((mediaRequest) => {
|
||||
switch (mediaRequest.status) {
|
||||
case MediaRequestStatus.PENDING:
|
||||
case MediaRequestStatus.APPROVED:
|
||||
toast.success(`Requested ${title}!`);
|
||||
onSuccess?.()
|
||||
break;
|
||||
case MediaRequestStatus.DECLINED:
|
||||
toast.error(`You don't have permission to request!`);
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"@futurejj/react-native-visibility-sensor": "^1.3.5",
|
||||
"@gorhom/bottom-sheet": "^4.6.4",
|
||||
"@jellyfin/sdk": "^0.11.0",
|
||||
"@kesha-antonov/react-native-background-downloader": "^3.2.1",
|
||||
"@kesha-antonov/react-native-background-downloader": "3.1.2",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-community/netinfo": "11.3.1",
|
||||
"@react-native-menu/menu": "^1.1.6",
|
||||
@@ -76,7 +76,7 @@
|
||||
"react-native-circular-progress": "^1.4.1",
|
||||
"react-native-compressor": "^1.9.0",
|
||||
"react-native-device-info": "^14.0.1",
|
||||
"react-native-edge-to-edge": "^1.1.1",
|
||||
"react-native-edge-to-edge": "^1.1.3",
|
||||
"react-native-gesture-handler": "~2.16.1",
|
||||
"react-native-get-random-values": "^1.11.0",
|
||||
"react-native-google-cast": "^4.8.3",
|
||||
@@ -87,7 +87,7 @@
|
||||
"react-native-pager-view": "6.3.0",
|
||||
"react-native-progress": "^5.0.1",
|
||||
"react-native-reanimated": "~3.10.1",
|
||||
"react-native-reanimated-carousel": "4.0.0-canary.15",
|
||||
"react-native-reanimated-carousel": "4.0.0-canary.22",
|
||||
"react-native-safe-area-context": "4.10.5",
|
||||
"react-native-screens": "3.31.1",
|
||||
"react-native-svg": "15.2.0",
|
||||
@@ -98,7 +98,7 @@
|
||||
"react-native-video": "^6.7.0",
|
||||
"react-native-volume-manager": "^1.10.0",
|
||||
"react-native-web": "~0.19.13",
|
||||
"react-native-webview": "^13.12.5",
|
||||
"react-native-webview": "13.8.6",
|
||||
"react-native-youtube-iframe": "^2.3.0",
|
||||
"sonner-native": "^0.14.2",
|
||||
"tailwindcss": "3.3.2",
|
||||
|
||||
@@ -19,6 +19,7 @@ import React, {
|
||||
import { Platform } from "react-native";
|
||||
import uuid from "react-native-uuid";
|
||||
import { getDeviceName } from "react-native-device-info";
|
||||
import { toast } from "sonner-native";
|
||||
|
||||
interface Server {
|
||||
address: string;
|
||||
@@ -54,7 +55,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.23.0" },
|
||||
clientInfo: { name: "Streamyfin", version: "0.24.0" },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
@@ -91,7 +92,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
return {
|
||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||
Platform.OS === "android" ? "Android" : "iOS"
|
||||
}, DeviceId="${deviceId}", Version="0.23.0"`,
|
||||
}, DeviceId="${deviceId}", Version="0.24.0"`,
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
@@ -179,6 +180,19 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setApi(apiInstance);
|
||||
storage.set("serverUrl", server.address);
|
||||
},
|
||||
onSuccess: (_, server) => {
|
||||
const previousServers = JSON.parse(
|
||||
storage.getString("previousServers") || "[]"
|
||||
);
|
||||
const updatedServers = [
|
||||
server,
|
||||
...previousServers.filter((s: Server) => s.address !== server.address),
|
||||
];
|
||||
storage.set(
|
||||
"previousServers",
|
||||
JSON.stringify(updatedServers.slice(0, 5))
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to set server:", error);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user