diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..c15d5500 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,60 @@ +# Copilot Instructions for Streamyfin + +## Project Overview + +Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). +It supports mobile (iOS/Android) and TV platforms, and integrates with Jellyfin and Jellyseerr APIs. + +## Main Technologies + +- React Native (Expo) +- TypeScript +- React Query +- Jotai (state management) +- Jellyfin SDK (TypeScript) +- BiomeJS (code formatting/linting) +- EAS (Expo Application Services) +- Shell scripting (for automation) +- GitHub Actions (CI/CD) + +## Code Structure + +- `app/` – Main application code (screens, navigation, etc.) +- `components/` – Reusable UI components +- `providers/` – Context and API providers (e.g., JellyfinProvider.tsx) +- `utils/` – Utility functions and atoms +- `assets/` – Images and static assets +- `scripts/` – Automation scripts (Node.js, Bash) +- `plugins/` – Expo/Metro plugins +- `README.md` – Project documentation + +## Coding Conventions + +- Use TypeScript for all new code. +- Prefer functional React components. +- Use hooks for state and side effects. +- Use Jotai for global state. +- Use React Query for data fetching/caching. +- Use BiomeJS for formatting and linting. +- Follow the established folder structure for screens/components. + +## API Usage + +- Use the Jellyfin SDK for all server interactions. +- Use the `apiAtom` and `userAtom` from `JellyfinProvider` for authenticated API calls. +- For navigation, use `expo-router`. + +## Commit Messages + +- Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) (e.g., `feat:`, `fix:`, `chore:`). +- Example: `feat(player): add Chromecast support` + +## Special Instructions + +- When suggesting code, prefer using existing atoms, hooks, and utility functions. +- When adding new features, ensure they are accessible via both mobile and TV navigation if relevant. +- When updating dependencies or scripts, check for compatibility with Expo and EAS. + +--- + +**Copilot: Please use these instructions to provide context-aware suggestions and code completions for this repository.** \ No newline at end of file diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 2b79e2dd..bc9b9ea1 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -39,7 +39,7 @@ jobs: bun-version: latest - name: 💾 Cache Bun dependencies - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} @@ -53,7 +53,7 @@ jobs: bun run submodule-reload - name: 💾 Cache Gradle global - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | ~/.gradle/caches @@ -70,7 +70,7 @@ jobs: fi - name: 💾 Cache project Gradle (.gradle) - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: android/.gradle key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index b7486da5..98b587b3 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -44,7 +44,7 @@ jobs: bun-version: latest - name: 💾 Cache Bun dependencies - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} diff --git a/.github/workflows/check-lockfile.yml b/.github/workflows/check-lockfile.yml index 6134ad5f..cc606b19 100644 --- a/.github/workflows/check-lockfile.yml +++ b/.github/workflows/check-lockfile.yml @@ -32,7 +32,7 @@ jobs: bun-version: latest - name: 💾 Cache Bun dependencies - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | ~/.bun/install/cache diff --git a/.github/workflows/ci-codeql.yml b/.github/workflows/ci-codeql.yml index 763728ce..024cc6e9 100644 --- a/.github/workflows/ci-codeql.yml +++ b/.github/workflows/ci-codeql.yml @@ -31,13 +31,13 @@ jobs: fetch-depth: 0 - name: 🏁 Initialize CodeQL - uses: github/codeql-action/init@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0 + uses: github/codeql-action/init@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4 with: languages: ${{ matrix.language }} queries: +security-extended,security-and-quality - name: 🛠️ Autobuild - uses: github/codeql-action/autobuild@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0 + uses: github/codeql-action/autobuild@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4 - name: 🧪 Perform CodeQL Analysis - uses: github/codeql-action/analyze@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0 + uses: github/codeql-action/analyze@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4 diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index aab34a05..9d09d751 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -65,6 +65,7 @@ jobs: expo-doctor: name: 🚑 Expo Doctor Check + if: false runs-on: ubuntu-24.04 steps: - name: 🛒 Checkout repository @@ -106,7 +107,7 @@ jobs: fetch-depth: 0 - name: "🟢 Setup Node.js" - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: '24.x' diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 2e52296f..936e110d 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -15,7 +15,7 @@ jobs: steps: - name: 🔄 Mark/Close Stale Issues - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 + uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 with: # Global settings repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/update-issue-form.yml b/.github/workflows/update-issue-form.yml index 8b4f42ff..34680b2e 100644 --- a/.github/workflows/update-issue-form.yml +++ b/.github/workflows/update-issue-form.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: "🟢 Setup Node.js" - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: '24.x' cache: 'npm' diff --git a/app.json b/app.json index 2a6131a8..853f5481 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.35.0", + "version": "0.36.0", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", @@ -37,7 +37,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 66, + "versionCode": 69, "adaptiveIcon": { "foregroundImage": "./assets/images/icon-android-plain.png", "monochromeImage": "./assets/images/icon-android-themed.png", @@ -49,6 +49,7 @@ "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK", "android.permission.WRITE_SETTINGS" ], + "blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"], "googleServicesFile": "./google-services.json" }, "plugins": [ @@ -122,7 +123,7 @@ [ "expo-splash-screen", { - "backgroundColor": "#2e2e2e", + "backgroundColor": "#010101", "image": "./assets/images/icon-ios-plain.png", "imageWidth": 100 } diff --git a/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx b/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx index da4dfef5..e1c8b56b 100644 --- a/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx @@ -12,7 +12,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; export default function page() { - const [settings, updateSettings, pluginSettings] = useSettings(null); + const { settings, updateSettings, pluginSettings } = useSettings(); const user = useAtomValue(userAtom); const api = useAtomValue(apiAtom); diff --git a/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx index 3e4e410e..86222f93 100644 --- a/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx @@ -3,7 +3,7 @@ import { JellyseerrSettings } from "@/components/settings/Jellyseerr"; import { useSettings } from "@/utils/atoms/settings"; export default function page() { - const [_settings, _updateSettings, pluginSettings] = useSettings(null); + const { pluginSettings } = useSettings(); return ( (settings?.marlinServerUrl || ""); diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/company/[companyId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/company/[companyId].tsx index 4e7ba94b..cd9cc3cc 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/company/[companyId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/company/[companyId].tsx @@ -15,7 +15,7 @@ import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Dis export default function page() { const local = useLocalSearchParams(); - const { jellyseerrApi } = useJellyseerr(); + const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr(); const { companyId, image, type } = local as unknown as { companyId: string; @@ -53,7 +53,10 @@ export default function page() { uniqBy( data?.pages ?.filter((p) => p?.results.length) - .flatMap((p) => p?.results ?? []), + .flatMap( + (p) => + p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [], + ), "id", ) ?? [], [data], @@ -98,9 +101,7 @@ export default function page() { }} /> } - renderItem={(item, _index) => ( - - )} + renderItem={(item, _index) => } /> ); } diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/genre/[genreId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/genre/[genreId].tsx index 5482c45d..7ea00808 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/genre/[genreId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/genre/[genreId].tsx @@ -8,14 +8,10 @@ import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow"; import JellyseerrPoster from "@/components/posters/JellyseerrPoster"; import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; -import { - type MovieResult, - type TvResult, -} from "@/utils/jellyseerr/server/models/Search"; export default function page() { const local = useLocalSearchParams(); - const { jellyseerrApi } = useJellyseerr(); + const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr(); const { genreId, name, type } = local as unknown as { genreId: string; @@ -51,7 +47,10 @@ export default function page() { uniqBy( data?.pages ?.filter((p) => p?.results.length) - .flatMap((p) => p?.results ?? []), + .flatMap( + (p) => + p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [], + ), "id", ) ?? [], [data], @@ -62,7 +61,7 @@ export default function page() { jellyseerrApi ? flatData.map((r) => jellyseerrApi.imageProxy( - (r as TvResult | MovieResult).backdropPath, + r.backdropPath, "w1920_and_h800_multi_faces", ), ) @@ -92,9 +91,7 @@ export default function page() { {name} } - renderItem={(item, _index) => ( - - )} + renderItem={(item, _index) => } /> ); } diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx index 7113c24f..3b076235 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx @@ -139,7 +139,15 @@ const Page: React.FC = () => { } requestMedia(mediaTitle, body, refetch); - }, [details, result, requestMedia, hasAdvancedRequestPermission]); + }, [ + details, + result, + requestMedia, + hasAdvancedRequestPermission, + mediaTitle, + refetch, + mediaType, + ]); const isAnime = useMemo( () => @@ -277,12 +285,16 @@ const Page: React.FC = () => { - - )} - - - ); -}; diff --git a/components/downloads/DownloadCard.tsx b/components/downloads/DownloadCard.tsx new file mode 100644 index 00000000..5ffce752 --- /dev/null +++ b/components/downloads/DownloadCard.tsx @@ -0,0 +1,198 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useQueryClient } from "@tanstack/react-query"; +import { Image } from "expo-image"; +import { useRouter } from "expo-router"; +import { t } from "i18next"; +import { useMemo } from "react"; +import { + ActivityIndicator, + TouchableOpacity, + type TouchableOpacityProps, + View, +} from "react-native"; +import { toast } from "sonner-native"; +import { Text } from "@/components/common/Text"; +import { useDownload } from "@/providers/DownloadProvider"; +import { JobStatus } from "@/providers/Downloads/types"; +import { storage } from "@/utils/mmkv"; +import { formatTimeString } from "@/utils/time"; +import { Button } from "../Button"; + +const bytesToMB = (bytes: number) => { + return bytes / 1024 / 1024; +}; + +interface DownloadCardProps extends TouchableOpacityProps { + process: JobStatus; +} + +export const DownloadCard = ({ process, ...props }: DownloadCardProps) => { + const { startDownload, pauseDownload, resumeDownload, removeProcess } = + useDownload(); + const router = useRouter(); + const queryClient = useQueryClient(); + + const handlePause = async (id: string) => { + try { + await pauseDownload(id); + toast.success(t("home.downloads.toasts.download_paused")); + } catch (error) { + console.error("Error pausing download:", error); + toast.error(t("home.downloads.toasts.could_not_pause_download")); + } + }; + + const handleResume = async (id: string) => { + try { + await resumeDownload(id); + toast.success(t("home.downloads.toasts.download_resumed")); + } catch (error) { + console.error("Error resuming download:", error); + toast.error(t("home.downloads.toasts.could_not_resume_download")); + } + }; + + const handleDelete = async (id: string) => { + try { + await removeProcess(id); + toast.success(t("home.downloads.toasts.download_deleted")); + queryClient.invalidateQueries({ queryKey: ["downloads"] }); + } catch (error) { + console.error("Error deleting download:", error); + toast.error(t("home.downloads.toasts.could_not_delete_download")); + } + }; + + const eta = (p: JobStatus) => { + if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null; + + const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0); + if (bytesRemaining <= 0) return null; + + const secondsRemaining = bytesRemaining / p.speed; + + return formatTimeString(secondsRemaining, "s"); + }; + + const base64Image = useMemo(() => { + return storage.getString(process.item.Id!); + }, []); + + // Sanitize progress to ensure it's within valid bounds + const sanitizedProgress = useMemo(() => { + if ( + typeof process.progress !== "number" || + Number.isNaN(process.progress) + ) { + return 0; + } + return Math.max(0, Math.min(100, process.progress)); + }, [process.progress]); + + return ( + router.push(`/(auth)/items/page?id=${process.item.Id}`)} + className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden' + {...props} + > + {process.status === "downloading" && ( + 0 + ? `${Math.max(5, sanitizedProgress)}%` + : "5%", + }} + /> + )} + + {/* Action buttons in top right corner */} + + {process.status === "downloading" && ( + handlePause(process.id)} + className='p-1' + > + + + )} + {process.status === "paused" && ( + handleResume(process.id)} + className='p-1' + > + + + )} + handleDelete(process.id)} + className='p-1' + > + + + + + + + {base64Image && ( + + + + )} + + {process.item.Type} + {process.item.Name} + + {process.item.ProductionYear} + + + {sanitizedProgress === 0 ? ( + + ) : ( + {sanitizedProgress.toFixed(0)}% + )} + {process.speed && process.speed > 0 && ( + + {bytesToMB(process.speed).toFixed(2)} MB/s + + )} + {eta(process) && ( + + {t("home.downloads.eta", { eta: eta(process) })} + + )} + + + + {process.status} + + + + {process.status === "completed" && ( + + + + )} + + + ); +}; diff --git a/components/home/LargeMovieCarousel.tsx b/components/home/LargeMovieCarousel.tsx index de1d7de2..1431fce3 100644 --- a/components/home/LargeMovieCarousel.tsx +++ b/components/home/LargeMovieCarousel.tsx @@ -21,12 +21,12 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; -import { itemRouter } from "../common/TouchableItemRouter"; +import { getItemNavigation } from "../common/TouchableItemRouter"; interface Props extends ViewProps {} export const LargeMovieCarousel: React.FC = ({ ...props }) => { - const [settings] = useSettings(null); + const { settings } = useSettings(); const ref = React.useRef(null); const progress = useSharedValue(0); @@ -146,16 +146,15 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => { }, [item]); const segments = useSegments(); - const from = segments[2] || "(home)"; + const from = (segments as string[])[2] || "(home)"; const opacity = useSharedValue(1); const handleRoute = useCallback(() => { if (!from) return; - const url = itemRouter(item, from); lightHapticFeedback(); - // @ts-expect-error - if (url) router.push(url); + const navigation = getItemNavigation(item, from); + router.push(navigation as any); }, [item, from]); const tap = Gesture.Tap() diff --git a/components/inputs/PinInput.tsx b/components/inputs/PinInput.tsx new file mode 100644 index 00000000..abc32365 --- /dev/null +++ b/components/inputs/PinInput.tsx @@ -0,0 +1,131 @@ +import { BottomSheetTextInput } from "@gorhom/bottom-sheet"; +import React, { useCallback, useImperativeHandle, useRef } from "react"; +import { + type StyleProp, + StyleSheet, + Text, + type TextInputProps, + View, + type ViewStyle, +} from "react-native"; + +interface PinInputProps + extends Omit { + value: string; + onChangeText: (text: string) => void; + length?: number; + autoFocus?: boolean; + style?: StyleProp; +} + +export interface PinInputRef { + focus: () => void; +} + +const PinInputComponent = React.forwardRef( + (props, ref) => { + const { + value, + onChangeText, + length = 6, + style, + autoFocus, + ...rest + } = props; + + const inputRef = useRef(null); + const activeIndex = value.length; + + const handlePress = useCallback(() => { + inputRef.current?.focus(); + }, []); + + useImperativeHandle( + ref, + () => ({ + focus: () => inputRef.current?.focus(), + }), + [], + ); + + return ( + + + + {Array(length) + .fill(0) + .map((_, i) => ( + + {value[i]} + {i === activeIndex && } + + ))} + + + ); + }, +); + +PinInputComponent.displayName = "PinInput"; + +export const PinInput = PinInputComponent; + +const styles = StyleSheet.create({ + container: { + width: "100%", + }, + hiddenInput: { + position: "absolute", + width: 1, + height: 1, + opacity: 0, + }, + cells: { + flexDirection: "row", + justifyContent: "space-between", + width: "100%", + }, + cell: { + width: 40, + height: 48, + borderWidth: 1, + borderColor: "#374151", + borderRadius: 8, + alignItems: "center", + justifyContent: "center", + backgroundColor: "#1F2937", + }, + activeCell: { + borderColor: "#6366F1", + }, + filledCell: { + borderColor: "#4B5563", + }, + digit: { + fontSize: 24, + color: "white", + fontWeight: "500", + }, + cursor: { + position: "absolute", + width: 2, + height: 24, + backgroundColor: "#6366F1", + }, +}); diff --git a/components/jellyseerr/PersonPoster.tsx b/components/jellyseerr/PersonPoster.tsx index 436f6ebc..70e44d35 100644 --- a/components/jellyseerr/PersonPoster.tsx +++ b/components/jellyseerr/PersonPoster.tsx @@ -22,7 +22,7 @@ const PersonPoster: React.FC = ({ const { jellyseerrApi } = useJellyseerr(); const router = useRouter(); const segments = useSegments(); - const from = segments[2] || "(home)"; + const from = (segments as string[])[2] || "(home)"; if (from === "(home)" || from === "(search)" || from === "(libraries)") return ( diff --git a/components/jellyseerr/discover/CompanySlide.tsx b/components/jellyseerr/discover/CompanySlide.tsx index 419aad47..9643f48e 100644 --- a/components/jellyseerr/discover/CompanySlide.tsx +++ b/components/jellyseerr/discover/CompanySlide.tsx @@ -16,13 +16,12 @@ const CompanySlide: React.FC< > = ({ slide, data, ...props }) => { const segments = useSegments(); const { jellyseerrApi } = useJellyseerr(); - const from = segments[2] || "(home)"; + const from = (segments as string[])[2] || "(home)"; const navigate = useCallback( ({ id, image, name }: Network | Studio) => router.push({ - // @ts-expect-error - Dynamic pathname for jellyseerr routing - pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}`, + pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}` as any, params: { id, image, name, type: slide.type }, }), [slide], diff --git a/components/jellyseerr/discover/GenreSlide.tsx b/components/jellyseerr/discover/GenreSlide.tsx index a09d1051..8edaf4c3 100644 --- a/components/jellyseerr/discover/GenreSlide.tsx +++ b/components/jellyseerr/discover/GenreSlide.tsx @@ -13,13 +13,12 @@ import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/consta const GenreSlide: React.FC = ({ slide, ...props }) => { const segments = useSegments(); const { jellyseerrApi } = useJellyseerr(); - const from = segments[2] || "(home)"; + const from = (segments as string[])[2] || "(home)"; const navigate = useCallback( (genre: GenreSliderItem) => router.push({ - // @ts-expect-error - Dynamic pathname for jellyseerr routing - pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}`, + pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}` as any, params: { type: slide.type, name: genre.name }, }), [slide], diff --git a/components/jellyseerr/discover/MovieTvSlide.tsx b/components/jellyseerr/discover/MovieTvSlide.tsx index 38974b5f..a82f48a3 100644 --- a/components/jellyseerr/discover/MovieTvSlide.tsx +++ b/components/jellyseerr/discover/MovieTvSlide.tsx @@ -11,16 +11,12 @@ import { useJellyseerr, } from "@/hooks/useJellyseerr"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; -import type { - MovieResult, - TvResult, -} from "@/utils/jellyseerr/server/models/Search"; const MovieTvSlide: React.FC = ({ slide, ...props }) => { - const { jellyseerrApi } = useJellyseerr(); + const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr(); const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({ queryKey: ["jellyseerr", "discover", slide.id], @@ -69,7 +65,9 @@ const MovieTvSlide: React.FC = ({ uniqBy( data?.pages ?.filter((p) => p?.results.length) - .flatMap((p) => p?.results), + .flatMap((p) => + p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)), + ), "id", ), [data], @@ -86,12 +84,7 @@ const MovieTvSlide: React.FC = ({ onEndReached={() => { if (hasNextPage) fetchNextPage(); }} - renderItem={(item) => ( - - )} + renderItem={(item) => } /> ) ); diff --git a/components/jellyseerr/discover/RecentRequestsSlide.tsx b/components/jellyseerr/discover/RecentRequestsSlide.tsx index 73198439..71b5fc21 100644 --- a/components/jellyseerr/discover/RecentRequestsSlide.tsx +++ b/components/jellyseerr/discover/RecentRequestsSlide.tsx @@ -8,7 +8,14 @@ import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest"; import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common"; -const RequestCard: React.FC<{ request: MediaRequest }> = ({ request }) => { +type ExtendedMediaRequest = NonFunctionProperties & { + profileName: string; + canRemove: boolean; +}; + +const RequestCard: React.FC<{ request: ExtendedMediaRequest }> = ({ + request, +}) => { const { jellyseerrApi } = useJellyseerr(); const { data: details } = useQuery({ @@ -67,9 +74,15 @@ const RecentRequestsSlide: React.FC = ({ ({ + ...item, + profileName: item.profileName ?? "Unknown", + canRemove: Boolean(item.canRemove), + })) as ExtendedMediaRequest[] + } keyExtractor={(item) => item.id.toString()} - renderItem={(item: NonFunctionProperties) => ( + renderItem={(item: ExtendedMediaRequest) => ( )} /> diff --git a/components/library/LibraryItemCard.tsx b/components/library/LibraryItemCard.tsx index f7d3fb9b..efb29013 100644 --- a/components/library/LibraryItemCard.tsx +++ b/components/library/LibraryItemCard.tsx @@ -41,7 +41,7 @@ const icons: Record = { export const LibraryItemCard: React.FC = ({ library, ...props }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); - const [settings] = useSettings(null); + const { settings } = useSettings(); const { t } = useTranslation(); diff --git a/components/medialists/MediaListSection.tsx b/components/medialists/MediaListSection.tsx index da5ed8bf..7eb750fa 100644 --- a/components/medialists/MediaListSection.tsx +++ b/components/medialists/MediaListSection.tsx @@ -12,7 +12,7 @@ import { useAtom } from "jotai"; import { useCallback } from "react"; import { View, type ViewProps } from "react-native"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { InfiniteHorizontalScroll } from "../common/InfiniteHorrizontalScroll"; +import { InfiniteHorizontalScroll } from "../common/InfiniteHorizontalScroll"; import { Text } from "../common/Text"; import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { ItemCardText } from "../ItemCardText"; diff --git a/components/posters/JellyseerrPoster.tsx b/components/posters/JellyseerrPoster.tsx index 49605078..d7b1fcdb 100644 --- a/components/posters/JellyseerrPoster.tsx +++ b/components/posters/JellyseerrPoster.tsx @@ -20,6 +20,7 @@ import { MediaStatus } from "@/utils/jellyseerr/server/constants/media"; import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest"; import type { DownloadingItem } from "@/utils/jellyseerr/server/lib/downloadtracker"; import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; +import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person"; import type { MovieResult, TvResult, @@ -27,7 +28,7 @@ import type { import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; interface Props extends ViewProps { - item?: MovieResult | TvResult | MovieDetails | TvDetails; + item?: MovieResult | TvResult | MovieDetails | TvDetails | PersonCreditCast; horizontal?: boolean; showDownloadInfo?: boolean; mediaRequest?: MediaRequest; diff --git a/components/series/CastAndCrew.tsx b/components/series/CastAndCrew.tsx index 4263cd37..037206e9 100644 --- a/components/series/CastAndCrew.tsx +++ b/components/series/CastAndCrew.tsx @@ -10,9 +10,8 @@ import { useTranslation } from "react-i18next"; import { TouchableOpacity, View, type ViewProps } from "react-native"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; -import { HorizontalScroll } from "../common/HorrizontalScroll"; +import { HorizontalScroll } from "../common/HorizontalScroll"; import { Text } from "../common/Text"; -import { itemRouter } from "../common/TouchableItemRouter"; import Poster from "../posters/Poster"; interface Props extends ViewProps { @@ -24,19 +23,21 @@ export const CastAndCrew: React.FC = ({ item, loading, ...props }) => { const [api] = useAtom(apiAtom); const segments = useSegments(); const { t } = useTranslation(); - const from = segments[2]; + const from = (segments as string[])[2]; const destinctPeople = useMemo(() => { - const people: BaseItemPerson[] = []; + const people: Record = {}; item?.People?.forEach((person) => { - const existingPerson = people.find((p) => p.Id === person.Id); + if (!person.Id) return; + + const existingPerson = people[person.Id]; if (existingPerson) { existingPerson.Role = `${existingPerson.Role}, ${person.Role}`; } else { - people.push(person); + people[person.Id] = person; } }); - return people; + return Object.values(people); }, [item?.People]); if (!from) return null; @@ -54,9 +55,12 @@ export const CastAndCrew: React.FC = ({ item, loading, ...props }) => { renderItem={(i) => ( { - const url = itemRouter(i, from); - // @ts-expect-error - router.push(url); + if (i.Id) { + router.push({ + pathname: "/persons/[personId]", + params: { personId: i.Id }, + }); + } }} className='flex flex-col w-28' > diff --git a/components/series/CurrentSeries.tsx b/components/series/CurrentSeries.tsx index c1a4dede..c3d0e0d9 100644 --- a/components/series/CurrentSeries.tsx +++ b/components/series/CurrentSeries.tsx @@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next"; import { TouchableOpacity, View, type ViewProps } from "react-native"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById"; -import { HorizontalScroll } from "../common/HorrizontalScroll"; +import { HorizontalScroll } from "../common/HorizontalScroll"; import { Text } from "../common/Text"; import Poster from "../posters/Poster"; diff --git a/components/series/JellyseerrSeasons.tsx b/components/series/JellyseerrSeasons.tsx index 25cece2f..e339e84b 100644 --- a/components/series/JellyseerrSeasons.tsx +++ b/components/series/JellyseerrSeasons.tsx @@ -11,7 +11,7 @@ import { orderBy } from "lodash"; import type React from "react"; import { useCallback, useMemo, useState } from "react"; import { Alert, TouchableOpacity, View } from "react-native"; -import { HorizontalScroll } from "@/components/common/HorrizontalScroll"; +import { HorizontalScroll } from "@/components/common/HorizontalScroll"; import { Text } from "@/components/common/Text"; import { Tags } from "@/components/GenreTags"; import { dateOpts } from "@/components/jellyseerr/DetailFacts"; diff --git a/components/series/SeasonEpisodesCarousel.tsx b/components/series/SeasonEpisodesCarousel.tsx index 5a3304ad..e8242872 100644 --- a/components/series/SeasonEpisodesCarousel.tsx +++ b/components/series/SeasonEpisodesCarousel.tsx @@ -1,18 +1,17 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { router } from "expo-router"; import { useAtom } from "jotai"; import { useEffect, useMemo, useRef } from "react"; import { TouchableOpacity, type ViewProps } from "react-native"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import ContinueWatchingPoster from "../ContinueWatchingPoster"; import { HorizontalScroll, type HorizontalScrollRef, -} from "../common/HorrizontalScroll"; +} from "../common/HorizontalScroll"; import { ItemCardText } from "../ItemCardText"; interface Props extends ViewProps { @@ -42,11 +41,7 @@ export const SeasonEpisodesCarousel: React.FC = ({ return item?.SeasonId; }, [item]); - const { - data: episodes, - isLoading, - isFetching, - } = useQuery({ + const { data: episodes, isPending } = useQuery({ queryKey: ["episodes", seasonId, isOffline], queryFn: async () => { if (isOffline) { @@ -61,6 +56,7 @@ export const SeasonEpisodesCarousel: React.FC = ({ userId: user.Id, seasonId: seasonId || undefined, seriesId: item.SeriesId, + enableUserData: true, fields: [ "ItemCounts", "PrimaryImageAspectRatio", @@ -74,48 +70,6 @@ export const SeasonEpisodesCarousel: React.FC = ({ enabled: !!api && !!user?.Id && !!seasonId, }); - /** - * Prefetch previous and next episode - */ - const queryClient = useQueryClient(); - useEffect(() => { - if (!item?.Id || !item.IndexNumber || !episodes || episodes.length === 0) { - return; - } - - const previousId = episodes?.find( - (ep) => ep.IndexNumber === item.IndexNumber! - 1, - )?.Id; - if (previousId) { - queryClient.prefetchQuery({ - queryKey: ["item", previousId], - queryFn: async () => - await getUserItemData({ - api, - userId: user?.Id, - itemId: previousId, - }), - staleTime: 60 * 1000 * 5, - }); - } - - const nextId = episodes?.find( - (ep) => ep.IndexNumber === item.IndexNumber! + 1, - )?.Id; - if (nextId) { - queryClient.prefetchQuery({ - queryKey: ["item", nextId], - queryFn: async () => - await getUserItemData({ - api, - userId: user?.Id, - itemId: nextId, - }), - staleTime: 60 * 1000 * 5, - }); - } - }, [episodes, api, user?.Id, item]); - useEffect(() => { if (item?.Type === "Episode" && item.Id) { const index = episodes?.findIndex((ep) => ep.Id === item.Id); @@ -132,7 +86,7 @@ export const SeasonEpisodesCarousel: React.FC = ({ ref={scrollRef} data={episodes} extraData={item} - loading={loading || isLoading || isFetching} + loading={loading || isPending} renderItem={(_item, _idx) => ( = ({ item }) => { return season.Id!; }, [seasons, seasonIndex]); - const { data: episodes, isFetching } = useQuery({ + const { data: episodes, isPending } = useQuery({ queryKey: ["episodes", item.Id, selectedSeasonId], queryFn: async () => { if (!api || !user?.Id || !item.Id || !selectedSeasonId) { @@ -87,7 +86,7 @@ export const SeasonPicker: React.FC = ({ item }) => { seasonId: selectedSeasonId, enableUserData: true, // Note: Including trick play is necessary to enable trick play downloads - fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"], + fields: ["Overview", "Trickplay"], }); if (res.data.TotalRecordCount === 0) @@ -101,25 +100,6 @@ export const SeasonPicker: React.FC = ({ item }) => { enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId, }); - const queryClient = useQueryClient(); - useEffect(() => { - for (const e of episodes || []) { - queryClient.prefetchQuery({ - queryKey: ["item", e.Id], - queryFn: async () => { - if (!e.Id) return; - const res = await getUserItemData({ - api, - userId: user?.Id, - itemId: e.Id, - }); - return res; - }, - staleTime: 60 * 5 * 1000, - }); - } - }, [episodes]); - // Used for height calculation const [nrOfEpisodes, setNrOfEpisodes] = useState(0); useEffect(() => { @@ -165,7 +145,7 @@ export const SeasonPicker: React.FC = ({ item }) => { ) : null} - {isFetching ? ( + {isPending ? ( = () => { const isTv = Platform.isTV; - const [settings, updateSettings] = useSettings(null); + const { settings, updateSettings } = useSettings(); const { t } = useTranslation(); if (isTv) return null; diff --git a/components/settings/AudioToggles.tsx b/components/settings/AudioToggles.tsx index 29f851c9..82ff1157 100644 --- a/components/settings/AudioToggles.tsx +++ b/components/settings/AudioToggles.tsx @@ -17,7 +17,7 @@ export const AudioToggles: React.FC = ({ ...props }) => { const isTv = Platform.isTV; const media = useMedia(); - const [_, __, pluginSettings] = useSettings(null); + const { pluginSettings } = useSettings(); const { settings, updateSettings } = media; const cultures = media.cultures; const { t } = useTranslation(); diff --git a/components/settings/ChromecastSettings.tsx b/components/settings/ChromecastSettings.tsx index 096da489..4da06332 100644 --- a/components/settings/ChromecastSettings.tsx +++ b/components/settings/ChromecastSettings.tsx @@ -4,7 +4,7 @@ import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; export const ChromecastSettings: React.FC = ({ ...props }) => { - const [settings, updateSettings] = useSettings(null); + const { settings, updateSettings } = useSettings(); return ( diff --git a/components/settings/Dashboard.tsx b/components/settings/Dashboard.tsx index 0404f535..768c6e0b 100644 --- a/components/settings/Dashboard.tsx +++ b/components/settings/Dashboard.tsx @@ -7,7 +7,7 @@ import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; export const Dashboard = () => { - const [settings, _updateSettings] = useSettings(null); + const { settings } = useSettings(); const { sessions = [] } = useSessions({} as useSessionsProps); const router = useRouter(); diff --git a/components/settings/DownloadSettings.tsx b/components/settings/DownloadSettings.tsx index 3d609610..6c12440c 100644 --- a/components/settings/DownloadSettings.tsx +++ b/components/settings/DownloadSettings.tsx @@ -7,13 +7,13 @@ import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; export default function DownloadSettings({ ...props }) { - const [settings, updateSettings, pluginSettings] = useSettings(null); + const { settings, updateSettings, pluginSettings } = useSettings(); const { t } = useTranslation(); const allDisabled = useMemo( () => pluginSettings?.remuxConcurrentLimit?.locked === true && - pluginSettings?.autoDownload.locked === true, + pluginSettings?.autoDownload?.locked === true, [pluginSettings], ); diff --git a/components/settings/GestureControls.tsx b/components/settings/GestureControls.tsx index bb77af99..55aadaa1 100644 --- a/components/settings/GestureControls.tsx +++ b/components/settings/GestureControls.tsx @@ -13,7 +13,7 @@ interface Props extends ViewProps {} export const GestureControls: React.FC = ({ ...props }) => { const { t } = useTranslation(); - const [settings, updateSettings, pluginSettings] = useSettings(null); + const { settings, updateSettings, pluginSettings } = useSettings(); const disabled = useMemo( () => diff --git a/components/settings/HomeIndex.tsx b/components/settings/HomeIndex.tsx index 5278c17f..489cb0a1 100644 --- a/components/settings/HomeIndex.tsx +++ b/components/settings/HomeIndex.tsx @@ -64,13 +64,7 @@ export const HomeIndex = () => { const user = useAtomValue(userAtom); const [loading, setLoading] = useState(false); - const [ - settings, - _updateSettings, - _pluginSettings, - _setPluginSettings, - refreshStreamyfinPluginSettings, - ] = useSettings(null); + const { settings, refreshStreamyfinPluginSettings } = useSettings(); const navigation = useNavigation(); @@ -80,7 +74,12 @@ export const HomeIndex = () => { const { getDownloadedItems, cleanCacheDirectory } = useDownload(); const prevIsConnected = useRef(false); - const { isConnected, loading: retryLoading, retryCheck } = useNetworkStatus(); + const { + isConnected, + serverConnected, + loading: retryLoading, + retryCheck, + } = useNetworkStatus(); const invalidateCache = useInvalidatePlaybackProgressCache(); useEffect(() => { // Only invalidate cache when transitioning from offline to online @@ -126,7 +125,7 @@ export const HomeIndex = () => { const segments = useSegments(); useEffect(() => { const unsubscribe = eventBus.on("scrollToTop", () => { - if (segments[2] === "(home)") + if ((segments as string[])[2] === "(home)") scrollViewRef.current?.scrollTo({ y: -152, animated: true }); }); @@ -314,10 +313,10 @@ export const HomeIndex = () => { if (!api || !user?.Id || !settings?.home?.sections) return []; const ss: Section[] = []; for (const [index, section] of settings.home.sections.entries()) { - const id = section.items?.title || `section-${index}`; + const id = section.title || `section-${index}`; ss.push({ title: t(`${id}`), - queryKey: ["home", id], + queryKey: ["home", "custom", String(index), section.title ?? null], queryFn: async () => { if (section.items) { const response = await getItemsApi(api).getItems({ @@ -364,13 +363,28 @@ export const HomeIndex = () => { const sections = settings?.home?.sections ? customSections : defaultSections; - if (isConnected === false) { + if (!isConnected || serverConnected !== true) { + let title = ""; + let subtitle = ""; + + if (!isConnected) { + // No network connection + title = t("home.no_internet"); + subtitle = t("home.no_internet_message"); + } else if (serverConnected === null) { + // Network is up, but server is being checked + title = t("home.checking_server_connection"); + subtitle = t("home.checking_server_connection_message"); + } else if (!serverConnected) { + // Network is up, but server is unreachable + title = t("home.server_unreachable"); + subtitle = t("home.server_unreachable_message"); + } return ( - {t("home.no_internet")} - - {t("home.no_internet_message")} - + {title} + {subtitle} + {!Platform.isTV && ( )} + diff --git a/components/settings/Jellyseerr.tsx b/components/settings/Jellyseerr.tsx index 87a99964..470d40a2 100644 --- a/components/settings/Jellyseerr.tsx +++ b/components/settings/Jellyseerr.tsx @@ -20,7 +20,7 @@ export const JellyseerrSettings = () => { const { t } = useTranslation(); const [user] = useAtom(userAtom); - const [settings, updateSettings, _pluginSettings] = useSettings(null); + const { settings, updateSettings } = useSettings(); const [jellyseerrPassword, setJellyseerrPassword] = useState< string | undefined diff --git a/components/settings/MediaContext.tsx b/components/settings/MediaContext.tsx index 4f363694..c6c7856e 100644 --- a/components/settings/MediaContext.tsx +++ b/components/settings/MediaContext.tsx @@ -28,7 +28,7 @@ export const useMedia = () => { }; export const MediaProvider = ({ children }: { children: ReactNode }) => { - const [settings, updateSettings] = useSettings(null); + const { settings, updateSettings } = useSettings(); const api = useAtomValue(apiAtom); const queryClient = useQueryClient(); diff --git a/components/settings/MediaToggles.tsx b/components/settings/MediaToggles.tsx index 1715446a..c01a6d13 100644 --- a/components/settings/MediaToggles.tsx +++ b/components/settings/MediaToggles.tsx @@ -13,7 +13,7 @@ interface Props extends ViewProps {} export const MediaToggles: React.FC = ({ ...props }) => { const { t } = useTranslation(); - const [settings, updateSettings, pluginSettings] = useSettings(null); + const { settings, updateSettings, pluginSettings } = useSettings(); const disabled = useMemo( () => diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx index cfeb10f7..e0ca1147 100644 --- a/components/settings/OtherSettings.tsx +++ b/components/settings/OtherSettings.tsx @@ -23,7 +23,7 @@ import { ListItem } from "../list/ListItem"; export const OtherSettings: React.FC = () => { const router = useRouter(); - const [settings, updateSettings, pluginSettings] = useSettings(null); + const { settings, updateSettings, pluginSettings } = useSettings(); const { t } = useTranslation(); diff --git a/components/settings/PluginSettings.tsx b/components/settings/PluginSettings.tsx index c830f9e3..9d8c64dd 100644 --- a/components/settings/PluginSettings.tsx +++ b/components/settings/PluginSettings.tsx @@ -6,7 +6,7 @@ import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; export const PluginSettings = () => { - const [settings, _updateSettings] = useSettings(null); + const { settings } = useSettings(); const router = useRouter(); diff --git a/components/settings/QuickConnect.tsx b/components/settings/QuickConnect.tsx index b61500ce..d0cbf85e 100644 --- a/components/settings/QuickConnect.tsx +++ b/components/settings/QuickConnect.tsx @@ -2,7 +2,6 @@ import { BottomSheetBackdrop, type BottomSheetBackdropProps, BottomSheetModal, - BottomSheetTextInput, BottomSheetView, } from "@gorhom/bottom-sheet"; import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; @@ -10,17 +9,19 @@ import { useAtom } from "jotai"; import type React from "react"; import { useCallback, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Alert, View, type ViewProps } from "react-native"; +import { Alert, Platform, View, type ViewProps } from "react-native"; import { useHaptic } from "@/hooks/useHaptic"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { Button } from "../Button"; import { Text } from "../common/Text"; +import { PinInput } from "../inputs/PinInput"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; interface Props extends ViewProps {} export const QuickConnect: React.FC = ({ ...props }) => { + const isTv = Platform.isTV; const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const [quickConnectCode, setQuickConnectCode] = useState(); @@ -73,11 +74,17 @@ export const QuickConnect: React.FC = ({ ...props }) => { } }, [api, user, quickConnectCode]); + if (isTv) return null; + return ( bottomSheetModalRef?.current?.present()} + onPress={() => { + // Reset the code when opening the sheet + setQuickConnectCode(""); + bottomSheetModalRef?.current?.present(); + }} title={t("home.settings.quick_connect.authorize_button")} textColor='blue' /> @@ -93,6 +100,9 @@ export const QuickConnect: React.FC = ({ ...props }) => { backgroundColor: "#171717", }} backdropComponent={renderBackdrop} + keyboardBehavior='interactive' + keyboardBlurBehavior='restore' + android_keyboardInputMode='adjustResize' > @@ -102,16 +112,17 @@ export const QuickConnect: React.FC = ({ ...props }) => { - - + + {t( "home.settings.quick_connect.enter_the_quick_connect_code", )} - placeholderTextColor='#9CA3AF' - value={quickConnectCode} + + diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx index 60cd96e7..59ec1570 100644 --- a/components/settings/SubtitleToggles.tsx +++ b/components/settings/SubtitleToggles.tsx @@ -20,7 +20,7 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { const isTv = Platform.isTV; const media = useMedia(); - const [_, __, pluginSettings] = useSettings(null); + const { pluginSettings } = useSettings(); const { settings, updateSettings } = media; const cultures = media.cultures; const { t } = useTranslation(); diff --git a/components/stacks/NestedTabPageStack.tsx b/components/stacks/NestedTabPageStack.tsx index 8e66e0bf..6c151b0f 100644 --- a/components/stacks/NestedTabPageStack.tsx +++ b/components/stacks/NestedTabPageStack.tsx @@ -17,7 +17,7 @@ export const commonScreenOptions: ICommonScreenOptions = { headerLeft: () => , }; -const routes = ["actors/[actorId]", "items/page", "series/[id]"]; +const routes = ["persons/[personId]", "items/page", "series/[id]"]; export const nestedTabPageScreenOptions: Record = Object.fromEntries(routes.map((route) => [route, commonScreenOptions])); diff --git a/components/video-player/controls/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx index 3ff5987b..59da6f7b 100644 --- a/components/video-player/controls/BottomControls.tsx +++ b/components/video-player/controls/BottomControls.tsx @@ -88,7 +88,7 @@ export const BottomControls: FC = ({ trickplayInfo, time, }) => { - const [settings] = useSettings(null); + const { settings } = useSettings(); const insets = useSafeAreaInsets(); return ( diff --git a/components/video-player/controls/CenterControls.tsx b/components/video-player/controls/CenterControls.tsx index b00d9333..47659746 100644 --- a/components/video-player/controls/CenterControls.tsx +++ b/components/video-player/controls/CenterControls.tsx @@ -30,7 +30,7 @@ export const CenterControls: FC = ({ handleSkipBackward, handleSkipForward, }) => { - const [settings] = useSettings(null); + const { settings } = useSettings(); const insets = useSafeAreaInsets(); return ( diff --git a/components/video-player/controls/ContinueWatchingOverlay.tsx b/components/video-player/controls/ContinueWatchingOverlay.tsx index 829c4d83..4c353dee 100644 --- a/components/video-player/controls/ContinueWatchingOverlay.tsx +++ b/components/video-player/controls/ContinueWatchingOverlay.tsx @@ -16,7 +16,7 @@ export interface ContinueWatchingOverlayProps { const ContinueWatchingOverlay: React.FC = ({ goToNextItem, }) => { - const [settings] = useSettings(null); + const { settings } = useSettings(); const router = useRouter(); return settings.autoPlayEpisodeCount >= diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 7ca768af..db1f2ed4 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -116,7 +116,7 @@ export const Controls: FC = ({ api = null, downloadedFiles = undefined, }) => { - const [settings, updateSettings] = useSettings(api); + const { settings, updateSettings } = useSettings(); const router = useRouter(); const lightHapticFeedback = useHaptic("light"); @@ -379,8 +379,7 @@ export const Controls: FC = ({ console.log("queryParams", queryParams); - // @ts-expect-error - router.replace(`player/direct-player?${queryParams}`); + router.replace(`player/direct-player?${queryParams}` as any); }, [settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router], ); diff --git a/components/video-player/controls/EpisodeList.tsx b/components/video-player/controls/EpisodeList.tsx index 3d3d51c8..f39b0d7e 100644 --- a/components/video-player/controls/EpisodeList.tsx +++ b/components/video-player/controls/EpisodeList.tsx @@ -11,7 +11,7 @@ import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import { HorizontalScroll, type HorizontalScrollRef, -} from "@/components/common/HorrizontalScroll"; +} from "@/components/common/HorizontalScroll"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; import { diff --git a/components/video-player/controls/GestureOverlay.tsx b/components/video-player/controls/GestureOverlay.tsx index 1f6fe0ad..e4ca20e6 100644 --- a/components/video-player/controls/GestureOverlay.tsx +++ b/components/video-player/controls/GestureOverlay.tsx @@ -31,7 +31,7 @@ export const GestureOverlay = ({ onSkipForward, onSkipBackward, }: Props) => { - const [settings] = useSettings(null); + const { settings } = useSettings(); const lightHaptic = useHaptic("light"); const [feedback, setFeedback] = useState({ diff --git a/components/video-player/controls/HeaderControls.tsx b/components/video-player/controls/HeaderControls.tsx index 534e366d..07232014 100644 --- a/components/video-player/controls/HeaderControls.tsx +++ b/components/video-player/controls/HeaderControls.tsx @@ -70,7 +70,7 @@ export const HeaderControls: FC = ({ setVideoAspectRatio, setVideoScaleFactor, }) => { - const [settings] = useSettings(null); + const { settings } = useSettings(); const router = useRouter(); const insets = useSafeAreaInsets(); const { width: screenWidth } = useWindowDimensions(); diff --git a/components/video-player/controls/contexts/VideoContext.tsx b/components/video-player/controls/contexts/VideoContext.tsx index 3940d520..a5fffd48 100644 --- a/components/video-player/controls/contexts/VideoContext.tsx +++ b/components/video-player/controls/contexts/VideoContext.tsx @@ -95,8 +95,7 @@ export const VideoProvider: React.FC = ({ playbackPosition: playbackPosition, }).toString(); - //@ts-expect-error - router.replace(`player/direct-player?${queryParams}`); + router.replace(`player/direct-player?${queryParams}` as any); }; const setTrackParams = ( diff --git a/components/video-player/controls/dropdown/DropdownView.tsx b/components/video-player/controls/dropdown/DropdownView.tsx index 91c84d60..c6c8f82f 100644 --- a/components/video-player/controls/dropdown/DropdownView.tsx +++ b/components/video-player/controls/dropdown/DropdownView.tsx @@ -51,8 +51,7 @@ const DropdownView = () => { bitrateValue: bitrate.toString(), playbackPosition: playbackPosition, }).toString(); - // @ts-expect-error - router.replace(`player/direct-player?${queryParams}`); + router.replace(`player/direct-player?${queryParams}` as any); }, [item, mediaSource, subtitleIndex, audioIndex, playbackPosition], ); diff --git a/components/video-player/controls/hooks/useVideoNavigation.ts b/components/video-player/controls/hooks/useVideoNavigation.ts index 524bdd7f..0573d6e4 100644 --- a/components/video-player/controls/hooks/useVideoNavigation.ts +++ b/components/video-player/controls/hooks/useVideoNavigation.ts @@ -20,7 +20,7 @@ export function useVideoNavigation({ seek, play, }: UseVideoNavigationProps) { - const [settings] = useSettings(null); + const { settings } = useSettings(); const lightHapticFeedback = useHaptic("light"); const wasPlayingRef = useRef(false); diff --git a/eas.json b/eas.json index a9aef4c6..9173f4ea 100644 --- a/eas.json +++ b/eas.json @@ -26,13 +26,6 @@ "EXPO_PUBLIC_WRITE_DEBUG": "1" } }, - "preview": { - "environment": "development", - "distribution": "internal", - "env": { - "EXPO_PUBLIC_WRITE_DEBUG": "1" - } - }, "development-simulator": { "environment": "development", "developmentClient": true, @@ -44,16 +37,22 @@ "EXPO_PUBLIC_WRITE_DEBUG": "1" } }, + "preview": { + "distribution": "internal", + "env": { + "EXPO_PUBLIC_WRITE_DEBUG": "1" + } + }, "production": { "environment": "production", - "channel": "0.35.0", + "channel": "0.36.0", "android": { "image": "latest" } }, "production-apk": { "environment": "production", - "channel": "0.35.0", + "channel": "0.36.0", "android": { "buildType": "apk", "image": "latest" @@ -61,7 +60,7 @@ }, "production-apk-tv": { "environment": "production", - "channel": "0.35.0", + "channel": "0.36.0", "android": { "buildType": "apk", "image": "latest" diff --git a/hooks/useHaptic.ts b/hooks/useHaptic.ts index 8afeffcf..42ae274e 100644 --- a/hooks/useHaptic.ts +++ b/hooks/useHaptic.ts @@ -14,7 +14,7 @@ export type HapticFeedbackType = | "error"; export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => { - const [settings] = useSettings(null); + const { settings } = useSettings(); const isTv = Platform.isTV; const isDisabled = isTv || diff --git a/hooks/useJellyseerr.ts b/hooks/useJellyseerr.ts index 1c99351b..34cc4dfe 100644 --- a/hooks/useJellyseerr.ts +++ b/hooks/useJellyseerr.ts @@ -14,7 +14,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { t } from "i18next"; import { useCallback, useMemo } from "react"; import { toast } from "sonner-native"; -import { defaultValues, Settings } from "@/utils/atoms/settings"; +import { useSettings } from "@/utils/atoms/settings"; import type { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes"; import { IssueStatus, @@ -40,6 +40,7 @@ import type { UserResultsResponse } from "@/utils/jellyseerr/server/interfaces/a import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; import type { CombinedCredit, + PersonCreditCast, PersonDetails, } from "@/utils/jellyseerr/server/models/Person"; import type { @@ -386,7 +387,7 @@ export class JellyseerrApi { `Jellyseerr response error\nerror: ${error.toString()}\nurl: ${error?.config?.url}`, error.response?.data, ); - if (error.status === 403) { + if (error.response?.status === 403) { clearJellyseerrStorageData(); } return Promise.reject(error); @@ -416,10 +417,8 @@ export class JellyseerrApi { const jellyseerrUserAtom = atom(storage.get(JELLYSEERR_USER)); -export const useJellyseerr = ( - settings: Settings = defaultValues, - updateSettings: (update: Partial) => void = () => {}, -) => { +export const useJellyseerr = () => { + const { settings, updateSettings } = useSettings(); const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom); const queryClient = useQueryClient(); @@ -468,54 +467,52 @@ export const useJellyseerr = ( [jellyseerrApi], ); - const isJellyseerrResult = ( + const isJellyseerrMovieOrTvResult = ( items: any | null | undefined, - ): items is Results => { + ): items is MovieResult | TvResult => { return ( items && Object.hasOwn(items, "mediaType") && - Object.values(MediaType).includes(items.mediaType as MediaType) + (items.mediaType === MediaType.MOVIE || items.mediaType === MediaType.TV) ); }; const getTitle = ( - item?: TvResult | TvDetails | MovieResult | MovieDetails, + item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast, ) => { - return isJellyseerrResult(item) + return isJellyseerrMovieOrTvResult(item) ? item.mediaType === MediaType.MOVIE ? item?.title : item?.name - : item?.mediaInfo.mediaType === MediaType.MOVIE + : item?.mediaInfo?.mediaType === MediaType.MOVIE ? (item as MovieDetails)?.title : (item as TvDetails)?.name; }; const getYear = ( - item?: TvResult | TvDetails | MovieResult | MovieDetails, + item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast, ) => { return new Date( - (isJellyseerrResult(item) + (isJellyseerrMovieOrTvResult(item) ? item.mediaType === MediaType.MOVIE ? item?.releaseDate : item?.firstAirDate - : item?.mediaInfo.mediaType === MediaType.MOVIE + : item?.mediaInfo?.mediaType === MediaType.MOVIE ? (item as MovieDetails)?.releaseDate : (item as TvDetails)?.firstAirDate) || "", )?.getFullYear?.(); }; const getMediaType = ( - item?: TvResult | TvDetails | MovieResult | MovieDetails, + item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast, ): MediaType => { - return isJellyseerrResult(item) - ? item.mediaType === "movie" - ? MediaType.MOVIE - : MediaType.TV + return isJellyseerrMovieOrTvResult(item) + ? (item.mediaType as MediaType) : item?.mediaInfo?.mediaType; }; const jellyseerrRegion = useMemo( - () => jellyseerrUser?.settings?.region || "US", + () => jellyseerrUser?.settings?.discoverRegion || "US", [jellyseerrUser], ); @@ -528,7 +525,7 @@ export const useJellyseerr = ( jellyseerrUser, setJellyseerrUser, clearAllJellyseerData, - isJellyseerrResult, + isJellyseerrMovieOrTvResult, getTitle, getYear, getMediaType, diff --git a/hooks/useNetworkStatus.ts b/hooks/useNetworkStatus.ts index aac70ee4..5acee27e 100644 --- a/hooks/useNetworkStatus.ts +++ b/hooks/useNetworkStatus.ts @@ -1,30 +1,58 @@ import NetInfo from "@react-native-community/netinfo"; +import { useAtom } from "jotai"; import { useCallback, useEffect, useState } from "react"; +import { apiAtom } from "@/providers/JellyfinProvider"; + +async function checkApiReachable(basePath?: string): Promise { + if (!basePath) return false; + try { + const response = await fetch(basePath, { method: "HEAD" }); + return response.ok; + } catch { + return false; + } +} export function useNetworkStatus() { const [isConnected, setIsConnected] = useState(false); + const [serverConnected, setServerConnected] = useState(null); const [loading, setLoading] = useState(false); + const [api] = useAtom(apiAtom); + + const validateConnection = useCallback(async () => { + if (!api?.basePath) return false; + const reachable = await checkApiReachable(api.basePath); + setServerConnected(reachable); + return reachable; + }, [api?.basePath]); - // Manual check (optional) const retryCheck = useCallback(async () => { setLoading(true); - const state = await NetInfo.fetch(); - setIsConnected(!!state.isConnected && !!state.isInternetReachable); + await validateConnection(); setLoading(false); - }, []); + }, [validateConnection]); useEffect(() => { - const unsubscribe = NetInfo.addEventListener((state) => { - setIsConnected(!!state.isConnected && !!state.isInternetReachable); + const unsubscribe = NetInfo.addEventListener(async (state) => { + setIsConnected(!!state.isConnected); + if (state.isConnected) { + await validateConnection(); + } else { + setServerConnected(false); + } }); - // Initial state + // Initial check: wait for NetInfo first NetInfo.fetch().then((state) => { - setIsConnected(!!state.isConnected && !!state.isInternetReachable); + if (state.isConnected) { + validateConnection(); + } else { + setServerConnected(false); + } }); return () => unsubscribe(); - }, []); + }, [validateConnection]); - return { isConnected, loading, retryCheck }; + return { isConnected, serverConnected, loading, retryCheck }; } diff --git a/hooks/usePlaybackManager.ts b/hooks/usePlaybackManager.ts index 0b70752b..5ea237cf 100644 --- a/hooks/usePlaybackManager.ts +++ b/hooks/usePlaybackManager.ts @@ -1,4 +1,7 @@ -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import type { + BaseItemDto, + PlaybackProgressInfo, +} from "@jellyfin/sdk/lib/generated-client"; import { getPlaystateApi, getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; @@ -141,13 +144,10 @@ export const usePlaybackManager = ({ * @param positionTicks The current playback position in ticks. */ const reportPlaybackProgress = async ( - itemId: string, - positionTicks: number, - metadata?: { - AudioStreamIndex: number; - SubtitleStreamIndex: number; - }, + playbackProgressInfo: PlaybackProgressInfo, ) => { + const positionTicks = playbackProgressInfo.PositionTicks || 0; + const itemId = playbackProgressInfo.ItemId!; const localItem = getDownloadedItemById(itemId); // Handle local state update for downloaded items @@ -192,14 +192,7 @@ export const usePlaybackManager = ({ if (isOnline && api) { try { await getPlaystateApi(api).reportPlaybackProgress({ - playbackProgressInfo: { - ItemId: itemId, - PositionTicks: Math.floor(positionTicks), - ...(metadata && { AudioStreamIndex: metadata.AudioStreamIndex }), - ...(metadata && { - SubtitleStreamIndex: metadata.SubtitleStreamIndex, - }), - }, + playbackProgressInfo, }); } catch (error) { console.error("Failed to report playback progress", error); diff --git a/package.json b/package.json index f07f8703..ac09afc5 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "android:tv": "cross-env EXPO_TV=1 expo run:android", "build:android:local": "cd android && cross-env NODE_ENV=production ./gradlew assembleRelease", "prepare": "husky", - "typecheck": "tsc -p tsconfig.json --noEmit | grep -v \"utils/jellyseerr\"", + "typecheck": "node scripts/typecheck.js", "check": "biome check . --max-diagnostics 1000", "lint": "biome check --write --unsafe --max-diagnostics 1000", "format": "biome format --write .", @@ -105,7 +105,7 @@ }, "devDependencies": { "@babel/core": "^7.20.0", - "@biomejs/biome": "^2.2.2", + "@biomejs/biome": "^2.2.4", "@react-native-community/cli": "^20.0.0", "@react-native-tvos/config-tv": "^0.1.1", "@types/jest": "^29.5.12", diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 7b76d934..c3180c0a 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -4,6 +4,7 @@ import type { } from "@jellyfin/sdk/lib/generated-client/models"; import * as Application from "expo-application"; import * as FileSystem from "expo-file-system"; +import * as Notifications from "expo-notifications"; import { router } from "expo-router"; import { atom, useAtom } from "jotai"; import { throttle } from "lodash"; @@ -87,9 +88,72 @@ function useDownloadProvider() { const { saveSeriesPrimaryImage } = useDownloadHelper(); const { saveImage } = useImageStorage(); const [processes, setProcesses] = useAtom(processesAtom); - const [settings] = useSettings(null); + const { settings } = useSettings(); const successHapticFeedback = useHaptic("success"); + // Generate notification content based on item type + const getNotificationContent = useCallback( + (item: BaseItemDto, isSuccess: boolean) => { + if (item.Type === "Episode") { + const season = item.ParentIndexNumber + ? String(item.ParentIndexNumber).padStart(2, "0") + : "??"; + const episode = item.IndexNumber + ? String(item.IndexNumber).padStart(2, "0") + : "??"; + const subtitle = `${item.Name} - [S${season}E${episode}] (${item.SeriesName})`; + + return { + title: isSuccess ? "Download complete" : "Download failed", + body: subtitle, + }; + } else if (item.Type === "Movie") { + const year = item.ProductionYear ? ` (${item.ProductionYear})` : ""; + const subtitle = `${item.Name}${year}`; + + return { + title: isSuccess ? "Download complete" : "Download failed", + body: subtitle, + }; + } else { + // Fallback for other types + return { + title: isSuccess + ? t("home.downloads.toasts.download_completed_for_item", { + item: item.Name, + }) + : t("home.downloads.toasts.download_failed_for_item", { + item: item.Name, + }), + body: item.Name || "Unknown item", + }; + } + }, + [t], + ); + + // Send local notification for download events + const sendDownloadNotification = useCallback( + async (title: string, body: string, data?: Record) => { + if (Platform.isTV) return; + + try { + await Notifications.scheduleNotificationAsync({ + content: { + title, + body, + data, + ...(Platform.OS === "android" && { channelId: "downloads" }), + }, + trigger: null, // Show immediately + }); + } catch (error) { + console.error("Failed to send notification:", error); + } + }, + [], + ); + /// Cant use the background downloader callback. As its not triggered if size is unknown. const updateProgress = async () => { const tasks = await BackGroundDownloader.checkForExistingDownloads(); @@ -418,6 +482,21 @@ function useDownloadProvider() { } await saveDownloadsDatabase(db); + // Send native notification for successful download + const successNotification = getNotificationContent( + process.item, + true, + ); + await sendDownloadNotification( + successNotification.title, + successNotification.body, + { + itemId: process.item.Id, + itemName: process.item.Name, + type: "download_completed", + }, + ); + toast.success( t("home.downloads.toasts.download_completed_for_item", { item: process.item.Name, @@ -425,8 +504,25 @@ function useDownloadProvider() { ); removeProcess(process.id); }) - .error((error: any) => { + .error(async (error: any) => { console.error("Download error:", error); + + // Send native notification for failed download + const failureNotification = getNotificationContent( + process.item, + false, + ); + await sendDownloadNotification( + failureNotification.title, + failureNotification.body, + { + itemId: process.item.Id, + itemName: process.item.Name, + type: "download_failed", + error: error?.message || "Unknown error", + }, + ); + toast.error( t("home.downloads.toasts.download_failed_for_item", { item: process.item.Name, @@ -435,7 +531,7 @@ function useDownloadProvider() { removeProcess(process.id); }); }, - [authHeader], + [authHeader, sendDownloadNotification, getNotificationContent], ); const manageDownloadQueue = useCallback(() => { diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index b3b935d5..864a0311 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ setJellyfin( () => new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.35.0" }, + clientInfo: { name: "Streamyfin", version: "0.36.0" }, deviceInfo: { name: deviceName, id, @@ -79,24 +79,15 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const [user, setUser] = useAtom(userAtom); const [isPolling, setIsPolling] = useState(false); const [secret, setSecret] = useState(null); - const [ - _settings, - _updateSettings, - _pluginSettings, - setPluginSettings, - refreshStreamyfinPluginSettings, - ] = useSettings(api); - const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr( - _settings || {}, - _updateSettings, - ); + const { setPluginSettings, refreshStreamyfinPluginSettings } = useSettings(); + const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr(); const headers = useMemo(() => { if (!deviceId) return {}; return { authorization: `MediaBrowser Client="Streamyfin", Device=${ Platform.OS === "android" ? "Android" : "iOS" - }, DeviceId="${deviceId}", Version="0.35.0"`, + }, DeviceId="${deviceId}", Version="0.36.0"`, }; }, [deviceId]); @@ -383,7 +374,7 @@ function useProtectedRoute(user: UserDto | null, loaded = false) { useEffect(() => { if (loaded === false) return; - const inAuthGroup = segments[0] === "(auth)"; + const inAuthGroup = segments.length > 1 && segments[0] === "(auth)"; if (!user?.Id && inAuthGroup) { console.log("Redirected to login"); diff --git a/scripts/typecheck.js b/scripts/typecheck.js new file mode 100644 index 00000000..ea1f4bea --- /dev/null +++ b/scripts/typecheck.js @@ -0,0 +1,256 @@ +const { execFileSync } = require("node:child_process"); +const process = require("node:process"); + +// Enhanced ANSI color codes and styles +const colors = { + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + magenta: "\x1b[35m", + cyan: "\x1b[36m", + white: "\x1b[37m", + gray: "\x1b[90m", + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + underline: "\x1b[4m", + bg: { + red: "\x1b[41m", + green: "\x1b[42m", + yellow: "\x1b[43m", + blue: "\x1b[44m", + }, +}; + +const border = "━".repeat(80); + +// Center the title within the border +const title = "🔥 STREAMYFIN TYPESCRIPT CHECK"; +const titlePadding = Math.floor((80 - title.length) / 2); +const centeredTitle = " ".repeat(titlePadding) + title; + +const useColor = process.stdout.isTTY && !process.env.NO_COLOR; + +function log(message, color = "") { + if (useColor && color) { + console.log(`${color}${message}${colors.reset}`); + } else { + console.log(String(message)); + } +} + +function formatError(errorLine) { + if (!useColor) return errorLine; + + // Color file paths in cyan + let formatted = errorLine.replace( + /^([^(]+\([^)]+\):)/, + `${colors.cyan}$1${colors.reset}`, + ); + + // Color error codes in red bold + formatted = formatted.replace( + /(error TS\d+:)/g, + `${colors.red}${colors.bold}$1${colors.reset}`, + ); + + // Color type names in yellow + formatted = formatted.replace( + /(Type '[^']*')/g, + `${colors.yellow}$1${colors.reset}`, + ); + + // Color property names in magenta + formatted = formatted.replace( + /(Property '[^']*')/g, + `${colors.magenta}$1${colors.reset}`, + ); + + return formatted; +} + +function parseErrorsAndCreateSummary(errorOutput) { + const lines = errorOutput.split("\n").filter((line) => line.trim()); + const errorsByFile = new Map(); + const formattedErrors = []; + + let currentError = []; + + for (const line of lines) { + const trimmedLine = line.trim(); + if (!trimmedLine) continue; + + // Check if this is the start of a new error (has file path and error code) + const errorMatch = line.match(/^([^(]+\([^)]+\):)\s*(error TS\d+:)/); + + if (errorMatch) { + // If we have a previous error, add it to the list + if (currentError.length > 0) { + formattedErrors.push(currentError.join("\n")); + currentError = []; + } + + // Extract file info for summary + const filePath = errorMatch[1].split("(")[0]; + if (!errorsByFile.has(filePath)) { + errorsByFile.set(filePath, 0); + } + errorsByFile.set(filePath, errorsByFile.get(filePath) + 1); + + // Start new error + currentError.push(formatError(line)); + } else if (currentError.length > 0) { + // This is a continuation of the current error + currentError.push(` ${colors.gray}${line}${colors.reset}`); + } else if (line.match(/Found \d+ errors? in \d+ files?/)) { + // Skip the summary line; no action needed for this line + } else { + // Standalone line + formattedErrors.push(formatError(line)); + } + } + + // Add the last error if exists + if (currentError.length > 0) { + formattedErrors.push(currentError.join("\n")); + } + + return { formattedErrors, errorsByFile }; +} + +function createErrorSummaryTable(errorsByFile) { + if (errorsByFile.size === 0) return ""; + + const sortedFiles = Array.from(errorsByFile.entries()).sort( + (a, b) => b[1] - a[1], + ); // Sort by error count descending + + let table = `\n${colors.gray}${colors.bold}Errors Files${colors.reset}\n`; + + for (const [file, count] of sortedFiles) { + const paddedCount = String(count).padStart(6); + table += `${colors.red}${paddedCount}${colors.reset} ${colors.cyan}${file}${colors.reset}\n`; + } + + return table; +} + +function runTypeCheck() { + const extraArgs = process.argv.slice(2); + + // Prefer local TypeScript binary when available + const runnerArgs = ["-p", "tsconfig.json", "--noEmit", ...extraArgs]; + let execArgs = null; + try { + const tscBin = require.resolve("typescript/bin/tsc"); + execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] }; + } catch { + // fallback to PATH tsc + execArgs = { + cmd: "tsc", + args: ["-p", "tsconfig.json", "--noEmit", ...extraArgs], + }; + } + + try { + log( + `🔍 ${colors.bold}Running TypeScript type check...${colors.reset} ${colors.gray}${extraArgs.join(" ")}${colors.reset}`.trim(), + colors.blue, + ); + + const MAX_BUFFER_SIZE = 64 * 1024 * 1024; // 64MB + + execFileSync(execArgs.cmd, execArgs.args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: MAX_BUFFER_SIZE, + env: { ...process.env, FORCE_COLOR: "0" }, + }); + + log( + `✅ ${colors.bold}TypeScript check passed${colors.reset} - no errors found!`, + colors.green, + ); + return { ok: true }; + } catch (error) { + const errorOutput = (error && (error.stderr || error.stdout)) || ""; + + // Filter out jellyseerr utils errors - this is a third-party git submodule + // that generates a large volume of known type errors + const filteredLines = errorOutput.split("\n").filter((line) => { + const trimmedLine = line.trim(); + return trimmedLine && !trimmedLine.includes("utils/jellyseerr"); + }); + + if (filteredLines.length > 0) { + // Count TypeScript error occurrences (TS####) + const remainingMatches = ( + filteredLines.join("\n").match(/\berror\s+TS\d+:/gi) || [] + ).length; + + // Parse errors and create formatted output with summary + const { formattedErrors, errorsByFile } = parseErrorsAndCreateSummary( + filteredLines.join("\n"), + ); + + // Enhanced error header + log( + `\n${colors.bg.red} ERROR ${colors.reset} ${colors.red}${colors.bold}TypeScript errors found:${colors.reset}`, + ); + console.log(); + + // Display errors with spacing between each error + for (let i = 0; i < formattedErrors.length; i++) { + console.log(formattedErrors[i]); + + // Add spacing between errors (but not after the last one) + if (i < formattedErrors.length - 1) { + console.log(); // Empty line between errors + } + } + + // Create and display summary table + const summaryTable = createErrorSummaryTable(errorsByFile); + if (summaryTable) { + console.log(summaryTable); + } + + // Clean summary - just the error count + const errorIcon = "🚨"; + log( + `${errorIcon} ${colors.red}${colors.bold}${remainingMatches} TypeScript error${remainingMatches !== 1 ? "s" : ""}${colors.reset}`, + "", + ); + + return { ok: false }; + } + + log( + `✅ ${colors.bold}TypeScript check passed${colors.reset} ${colors.gray}(jellyseerr utils errors ignored)${colors.reset}`, + colors.green, + ); + return { ok: true }; + } +} + +// Enhanced header +console.log(`${colors.blue}${colors.bold}${border}${colors.reset}`); +console.log(`${colors.blue}${colors.bold}${centeredTitle}${colors.reset}`); +console.log(`${colors.blue}${colors.bold}${border}${colors.reset}`); +console.log(); + +// Main execution +const result = runTypeCheck(); + +console.log(); +if (!result.ok) { + log( + `${colors.red}${colors.bold}🔥 Typecheck failed - please fix the errors above${colors.reset}`, + ); + process.exitCode = 1; +} else { + log( + `${colors.green}${colors.bold}🎉 All checks passed! Ready to ship 🚀${colors.reset}`, + ); +} diff --git a/translations/en.json b/translations/en.json index b1ccbb80..834fd183 100644 --- a/translations/en.json +++ b/translations/en.json @@ -1,44 +1,49 @@ { "login": { - "username_required": "Username is required", + "username_required": "Username Is Required", "error_title": "Error", - "login_title": "Log in", + "login_title": "Log In", "login_to_title": "Log in to", "username_placeholder": "Username", "password_placeholder": "Password", - "login_button": "Log in", + "login_button": "Log In", "quick_connect": "Quick Connect", "enter_code_to_login": "Enter code {{code}} to login", "failed_to_initiate_quick_connect": "Failed to initiate Quick Connect", - "got_it": "Got it", - "connection_failed": "Connection failed", + "got_it": "Got It", + "connection_failed": "Connection Failed", "could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.", - "an_unexpected_error_occured": "An unexpected error occurred", - "change_server": "Change server", - "invalid_username_or_password": "Invalid username or password", + "an_unexpected_error_occured": "An Unexpected Error Occurred", + "change_server": "Change Server", + "invalid_username_or_password": "Invalid Username or Password", "user_does_not_have_permission_to_log_in": "User does not have permission to log in", "server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later", "server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.", "there_is_a_server_error": "There is a server error", "an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?", - "too_old_server_text": "Unsupported jellyfin server discovered", - "too_old_server_description": "Please update jellyfin to the latest version" + "too_old_server_text": "Unsupported Jellyfin Server Discovered", + "too_old_server_description": "Please update Jellyfin to the latest version" }, "server": { "enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server", "server_url_placeholder": "http(s)://your-server.com", "connect_button": "Connect", - "previous_servers": "previous servers", + "previous_servers": "Previous Servers", "clear_button": "Clear", - "search_for_local_servers": "Search for local servers", + "search_for_local_servers": "Search for Local Servers", "searching": "Searching...", "servers": "Servers" }, "home": { + "checking_server_connection": "Checking server connection...", "no_internet": "No Internet", - "no_items": "No items", + "no_items": "No Items", "no_internet_message": "No worries, you can still watch\ndownloaded content.", - "go_to_downloads": "Go to downloads", + "checking_server_connection_message": "Checking connection to server", + "go_to_downloads": "Go to Downloads", + "retry": "Retry", + "server_unreachable": "Server Unreachable", + "server_unreachable_message": "Could not reach the server.\nPlease check your network connection.", "oops": "Oops!", "error_message": "Something went wrong.\nPlease log out and in again.", "continue_watching": "Continue Watching", @@ -48,7 +53,7 @@ "suggested_episodes": "Suggested Episodes", "intro": { "welcome_to_streamyfin": "Welcome to Streamyfin", - "a_free_and_open_source_client_for_jellyfin": "A free and open-source client for Jellyfin.", + "a_free_and_open_source_client_for_jellyfin": "A Free and Open-Source Client for Jellyfin.", "features_title": "Features", "features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:", "jellyseerr_feature_description": "Connect to your Jellyseerr instance and request movies directly in the app.", @@ -58,12 +63,12 @@ "centralised_settings_plugin_title": "Centralised Settings Plugin", "centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.", "done_button": "Done", - "go_to_settings_button": "Go to settings", - "read_more": "Read more" + "go_to_settings_button": "Go to Settings", + "read_more": "Read More" }, "settings": { "settings_title": "Settings", - "log_out_button": "Log out", + "log_out_button": "Log Out", "user_info": { "user_info_title": "User Info", "user": "User", @@ -76,41 +81,41 @@ "authorize_button": "Authorize Quick Connect", "enter_the_quick_connect_code": "Enter the quick connect code...", "success": "Success", - "quick_connect_autorized": "Quick Connect authorized", + "quick_connect_autorized": "Quick Connect Authorized", "error": "Error", - "invalid_code": "Invalid code", + "invalid_code": "Invalid Code", "authorize": "Authorize" }, "media_controls": { "media_controls_title": "Media Controls", - "forward_skip_length": "Forward skip length", - "rewind_length": "Rewind length", + "forward_skip_length": "Forward Skip Length", + "rewind_length": "Rewind Length", "seconds_unit": "s" }, "gesture_controls": { "gesture_controls_title": "Gesture Controls", - "horizontal_swipe_skip": "Horizontal swipe to skip", + "horizontal_swipe_skip": "Horizontal Swipe to Skip", "horizontal_swipe_skip_description": "Swipe left/right when controls are hidden to skip", - "left_side_brightness": "Left side brightness control", + "left_side_brightness": "Left Side Brightness Control", "left_side_brightness_description": "Swipe up/down on left side to adjust brightness", - "right_side_volume": "Right side volume control", + "right_side_volume": "Right Side Volume Control", "right_side_volume_description": "Swipe up/down on right side to adjust volume" }, "audio": { "audio_title": "Audio", "set_audio_track": "Set Audio Track From Previous Item", - "audio_language": "Audio language", + "audio_language": "Audio Language", "audio_hint": "Choose a default audio language.", "none": "None", "language": "Language" }, "subtitles": { "subtitle_title": "Subtitles", - "subtitle_language": "Subtitle language", + "subtitle_language": "Subtitle Language", "subtitle_mode": "Subtitle Mode", "set_subtitle_track": "Set Subtitle Track From Previous Item", "subtitle_size": "Subtitle Size", - "subtitle_hint": "Configure subtitle preference.", + "subtitle_hint": "Configure Subtitle Preference.", "none": "None", "language": "Language", "loading": "Loading", @@ -119,13 +124,13 @@ "Smart": "Smart", "Always": "Always", "None": "None", - "OnlyForced": "OnlyForced" + "OnlyForced": "Only Forced" } }, "other": { "other_title": "Other", - "follow_device_orientation": "Auto rotate", - "video_orientation": "Video orientation", + "follow_device_orientation": "Auto Rotate", + "video_orientation": "Video Orientation", "orientation": "Orientation", "orientations": { "DEFAULT": "Default", @@ -139,8 +144,8 @@ "OTHER": "Other", "UNKNOWN": "Unknown" }, - "safe_area_in_controls": "Safe area in controls", - "video_player": "Video player", + "safe_area_in_controls": "Safe Area in Controls", + "video_player": "Video Player", "video_players": { "VLC_3": "VLC 3", "VLC_4": "VLC 4 (Experimental + PiP)" @@ -149,24 +154,13 @@ "hide_libraries": "Hide Libraries", "select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.", "disable_haptic_feedback": "Disable Haptic Feedback", - "default_quality": "Default quality", - "max_auto_play_episode_count": "Max auto play episode count", + "default_quality": "Default Quality", + "max_auto_play_episode_count": "Max Auto Play Episode Count", "disabled": "Disabled" }, "downloads": { "downloads_title": "Downloads", - "download_method": "Download method", - "remux_max_download": "Remux max download", - "auto_download": "Auto download", - "optimized_versions_server": "Optimized versions server", - "save_button": "Save", - "optimized_server": "Optimized Server", - "optimized": "Optimized", - "default": "Default", - "optimized_version_hint": "Enter the URL for the optimize server. The URL should include http or https and optionally the port.", - "read_more_about_optimized_server": "Read more about the optimize server.", - "url": "URL", - "server_url_placeholder": "http(s)://domain.org:port" + "remux_max_download": "Remux Max Download" }, "plugins": { "plugins_title": "Plugins", @@ -177,17 +171,15 @@ "server_url_placeholder": "Jellyseerr URL...", "password": "Password", "password_placeholder": "Enter password for Jellyfin user {{username}}", - "save_button": "Save", - "clear_button": "Clear", "login_button": "Login", - "total_media_requests": "Total media requests", - "movie_quota_limit": "Movie quota limit", - "movie_quota_days": "Movie quota days", - "tv_quota_limit": "TV quota limit", - "tv_quota_days": "TV quota days", - "reset_jellyseerr_config_button": "Reset Jellyseerr config", + "total_media_requests": "Total Media Requests", + "movie_quota_limit": "Movie Quota Limit", + "movie_quota_days": "Movie Quota Days", + "tv_quota_limit": "TV Quota Limit", + "tv_quota_days": "TV Quota Days", + "reset_jellyseerr_config_button": "Reset Jellyseerr Config", "unlimited": "Unlimited", - "plus_n_more": "+{{n}} more", + "plus_n_more": "+{{n}} More", "order_by": { "DEFAULT": "Default", "VOTE_COUNT_AND_AVERAGE": "Vote count and average", @@ -199,7 +191,7 @@ "url": "URL", "server_url_placeholder": "http(s)://domain.org:port", "marlin_search_hint": "Enter the URL for the Marlin server. The URL should include http or https and optionally the port.", - "read_more_about_marlin": "Read more about Marlin.", + "read_more_about_marlin": "Read More About Marlin.", "save_button": "Save", "toasts": { "saved": "Saved" @@ -210,39 +202,33 @@ "storage_title": "Storage", "app_usage": "App {{usedSpace}}%", "device_usage": "Device {{availableSpace}}%", - "size_used": "{{used}} of {{total}} used", + "size_used": "{{used}} of {{total}} Used", "delete_all_downloaded_files": "Delete All Downloaded Files" }, "intro": { - "show_intro": "Show intro", - "reset_intro": "Reset intro" + "show_intro": "Show Intro", + "reset_intro": "Reset Intro" }, "logs": { "logs_title": "Logs", - "export_logs": "Export logs", - "click_for_more_info": "Click for more info", + "export_logs": "Export Logs", + "click_for_more_info": "Click for More Info", "level": "Level", - "no_logs_available": "No logs available", - "delete_all_logs": "Delete all logs" + "no_logs_available": "No Logs Available", + "delete_all_logs": "Delete All Logs" }, "languages": { "title": "Languages", - "app_language": "App language", - "app_language_description": "Select the language for the app.", + "app_language": "App Language", "system": "System" }, "toasts": { - "error_deleting_files": "Error deleting files", - "background_downloads_enabled": "Background downloads enabled", - "background_downloads_disabled": "Background downloads disabled", - "connected": "Connected", - "could_not_connect": "Could not connect", - "invalid_url": "Invalid URL" + "error_deleting_files": "Error Deleting Files" } }, "sessions": { "title": "Sessions", - "no_active_sessions": "No active sessions" + "no_active_sessions": "No Active Sessions" }, "downloads": { "downloads_title": "Downloads", @@ -250,60 +236,48 @@ "movies": "Movies", "queue": "Queue", "queue_hint": "Queue and downloads will be lost on app restart", - "no_items_in_queue": "No items in queue", - "no_downloaded_items": "No downloaded items", - "delete_all_movies_button": "Delete all Movies", - "delete_all_tvseries_button": "Delete all TV-Series", - "delete_all_button": "Delete all", - "active_download": "Active download", - "no_active_downloads": "No active downloads", - "active_downloads": "Active downloads", + "no_items_in_queue": "No Items in Queue", + "no_downloaded_items": "No Downloaded Items", + "delete_all_movies_button": "Delete All Movies", + "delete_all_tvseries_button": "Delete All TV-Series", + "delete_all_button": "Delete All", + "active_download": "Active Download", + "no_active_downloads": "No Active Downloads", + "active_downloads": "Active Downloads", "new_app_version_requires_re_download": "New app version requires re-download", "new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.", "back": "Back", "delete": "Delete", - "something_went_wrong": "Something went wrong", + "something_went_wrong": "Something Went Wrong", "could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin", "eta": "ETA {{eta}}", - "methods": "Methods", "toasts": { "you_are_not_allowed_to_download_files": "You are not allowed to download files.", - "deleted_all_movies_successfully": "Deleted all movies successfully!", - "failed_to_delete_all_movies": "Failed to delete all movies", - "deleted_all_tvseries_successfully": "Deleted all TV-Series successfully!", - "failed_to_delete_all_tvseries": "Failed to delete all TV-Series", - "download_deleted": "Download deleted", - "could_not_delete_download": "Could not delete download", - "download_paused": "Download paused", - "could_not_pause_download": "Could not pause download", - "download_resumed": "Download resumed", - "could_not_resume_download": "Could not resume download", - "download_completed": "Download completed", - "download_started_for": "Download started for {{item}}", - "item_is_ready_to_be_downloaded": "{{item}} is ready to be downloaded", - "download_stated_for_item": "Download started for {{item}}", + "deleted_all_movies_successfully": "Deleted All Movies Successfully!", + "failed_to_delete_all_movies": "Failed to Delete All Movies", + "deleted_all_tvseries_successfully": "Deleted All TV-Series Successfully!", + "failed_to_delete_all_tvseries": "Failed to Delete All TV-Series", + "download_deleted": "Download Deleted", + "could_not_delete_download": "Could Not Delete Download", + "download_paused": "Download Paused", + "could_not_pause_download": "Could Not Pause Download", + "download_resumed": "Download Resumed", + "could_not_resume_download": "Could Not Resume Download", + "download_completed": "Download Completed", "download_failed_for_item": "Download failed for {{item}} - {{error}}", - "download_completed_for_item": "Download completed for {{item}}", - "queued_item_for_optimization": "Queued {{item}} for optimization", - "failed_to_start_download_for_item": "Failed to start downloading for {{item}}: {{message}}", - "server_responded_with_status_code": "Server responded with status {{statusCode}}", - "no_response_received_from_server": "No response received from the server", - "error_setting_up_the_request": "Error setting up the request", - "failed_to_start_download_for_item_unexpected_error": "Failed to start downloading for {{item}}: Unexpected error", + "download_completed_for_item": "Download Completed for {{item}}", "all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully", - "an_error_occured_while_deleting_files_and_jobs": "An error occurred while deleting files and jobs", - "go_to_downloads": "Go to downloads" + "go_to_downloads": "Go to Downloads" } } }, "search": { - "search_here": "Search here...", "search": "Search...", - "x_items": "{{count}} items", + "x_items": "{{count}} Items", "library": "Library", "discover": "Discover", - "no_results": "No results", - "no_results_found_for": "No results found for", + "no_results": "No Results", + "no_results_found_for": "No Results Found For", "movies": "Movies", "series": "Series", "episodes": "Episodes", @@ -334,32 +308,29 @@ "tmdb_tv_streaming_services": "TMDB TV Streaming Services" }, "library": { - "no_items_found": "No items found", - "no_results": "No results", - "no_libraries_found": "No libraries found", + "no_results": "No Results", + "no_libraries_found": "No Libraries Found", "item_types": { - "movies": "movies", - "series": "series", - "boxsets": "box sets", - "items": "items" + "movies": "Movies", + "series": "Series", + "boxsets": "Box Sets", + "items": "Items" }, "options": { "display": "Display", "row": "Row", "list": "List", - "image_style": "Image style", + "image_style": "Image Style", "poster": "Poster", "cover": "Cover", - "show_titles": "Show titles", - "show_stats": "Show stats" + "show_titles": "Show Titles", + "show_stats": "Show Stats" }, "filters": { "genres": "Genres", "years": "Years", "sort_by": "Sort By", "sort_order": "Sort Order", - "asc": "Ascending", - "desc": "Descending", "tags": "Tags" } }, @@ -370,34 +341,30 @@ "videos": "Videos", "boxsets": "Boxsets", "playlists": "Playlists", - "noDataTitle": "No favorites yet", + "noDataTitle": "No Favorites Yet", "noData": "Mark items as favorites to see them appear here for quick access." }, "custom_links": { - "no_links": "No links" + "no_links": "No Links" }, "player": { "error": "Error", "failed_to_get_stream_url": "Failed to get the stream URL", "an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.", - "client_error": "Client error", + "client_error": "Client Error", "could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast", - "message_from_server": "Message from server: {{message}}", - "video_has_finished_playing": "Video has finished playing!", - "no_video_source": "No video source...", + "message_from_server": "Message from Server: {{message}}", "next_episode": "Next Episode", "refresh_tracks": "Refresh Tracks", - "subtitle_tracks": "Subtitle Tracks:", "audio_tracks": "Audio Tracks:", "playback_state": "Playback State:", - "no_data_available": "No data available", "index": "Index:", "continue_watching": "Continue Watching", - "go_back": "Go back" + "go_back": "Go Back" }, "item_card": { - "next_up": "Next up", - "no_items_to_display": "No items to display", + "next_up": "Next Up", + "no_items_to_display": "No Items to Display", "cast_and_crew": "Cast & Crew", "series": "Series", "seasons": "Seasons", @@ -405,36 +372,33 @@ "no_episodes_for_this_season": "No episodes for this season", "overview": "Overview", "more_with": "More with {{name}}", - "similar_items": "Similar items", - "no_similar_items_found": "No similar items found", + "similar_items": "Similar Items", + "no_similar_items_found": "No Similar Items Found", "video": "Video", - "more_details": "More details", + "more_details": "More Details", "quality": "Quality", "audio": "Audio", "subtitles": "Subtitle", - "show_more": "Show more", - "show_less": "Show less", - "appeared_in": "Appeared in", - "could_not_load_item": "Could not load item", + "show_more": "Show More", + "show_less": "Show Less", + "appeared_in": "Appeared In", + "could_not_load_item": "Could Not Load Item", "none": "None", "download": { "download_season": "Download Season", "download_series": "Download Series", "download_episode": "Download Episode", "download_movie": "Download Movie", - "download_x_item": "Download {{item_count}} items", + "download_x_item": "Download {{item_count}} Items", "download_unwatched_only": "Unwatched Only", - "download_button": "Download", - "using_optimized_server": "Using optimized server", - "using_default_method": "Using default method" + "download_button": "Download" } }, "live_tv": { "next": "Next", "previous": "Previous", - "live_tv": "Live TV", - "coming_soon": "Coming soon", - "on_now": "On now", + "coming_soon": "Coming Soon", + "on_now": "On Now", "shows": "Shows", "movies": "Movies", "sports": "Sports", @@ -445,16 +409,16 @@ "confirm": "Confirm", "cancel": "Cancel", "yes": "Yes", - "whats_wrong": "What's wrong?", - "issue_type": "Issue type", - "select_an_issue": "Select an issue", + "whats_wrong": "What's Wrong?", + "issue_type": "Issue Type", + "select_an_issue": "Select an Issue", "types": "Types", - "describe_the_issue": "(optional) Describe the issue...", + "describe_the_issue": "(Optional) Describe the Issue...", "submit_button": "Submit", - "report_issue_button": "Report issue", + "report_issue_button": "Report Issue", "request_button": "Request", "are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?", - "failed_to_login": "Failed to login", + "failed_to_login": "Failed to Login", "cast": "Cast", "details": "Details", "status": "Status", @@ -469,13 +433,13 @@ "production_country": "Production Country", "studios": "Studios", "network": "Network", - "currently_streaming_on": "Currently Streaming on", + "currently_streaming_on": "Currently Streaming On", "advanced": "Advanced", "request_as": "Request As", "tags": "Tags", "quality_profile": "Quality Profile", "root_folder": "Root Folder", - "season_all": "Season (all)", + "season_all": "Season (All)", "season_number": "Season {{season_number}}", "number_episodes": "{{episode_number}} Episodes", "born": "Born", @@ -484,7 +448,7 @@ "jellyseer_does_not_meet_requirements": "Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0", "jellyseerr_test_failed": "Jellyseerr test failed. Please try again.", "failed_to_test_jellyseerr_server_url": "Failed to test jellyseerr server url", - "issue_submitted": "Issue submitted!", + "issue_submitted": "Issue Submitted!", "requested_item": "Requested {{item}}!", "you_dont_have_permission_to_request": "You don't have permission to request!", "something_went_wrong_requesting_media": "Something went wrong requesting media!" diff --git a/utils/_jellyseerr/useJellyseerrCanRequest.ts b/utils/_jellyseerr/useJellyseerrCanRequest.ts index c8872769..2cd67a33 100644 --- a/utils/_jellyseerr/useJellyseerrCanRequest.ts +++ b/utils/_jellyseerr/useJellyseerrCanRequest.ts @@ -9,6 +9,7 @@ import { hasPermission, Permission, } from "@/utils/jellyseerr/server/lib/permissions"; +import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person"; import type { MovieResult, TvResult, @@ -18,7 +19,7 @@ import type { MovieDetails } from "../jellyseerr/server/models/Movie"; import type { TvDetails } from "../jellyseerr/server/models/Tv"; export const useJellyseerrCanRequest = ( - item?: MovieResult | TvResult | MovieDetails | TvDetails, + item?: MovieResult | TvResult | MovieDetails | TvDetails | PersonCreditCast, ) => { const { jellyseerrUser } = useJellyseerr(); diff --git a/utils/atoms/queue.ts b/utils/atoms/queue.ts index 516b951f..3b787e22 100644 --- a/utils/atoms/queue.ts +++ b/utils/atoms/queue.ts @@ -56,7 +56,7 @@ export const useJobProcessor = () => { const [queue, setQueue] = useAtom(queueAtom); const [running, setRunning] = useAtom(runningAtom); const [processes] = useAtom(processesAtom); - const [settings] = useSettings(null); + const { settings } = useSettings(); useEffect(() => { if ( diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 40e4b022..c0780462 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -1,4 +1,3 @@ -import type { Api } from "@jellyfin/sdk"; import { type BaseItemKind, type CultureDto, @@ -7,11 +6,12 @@ import { type SortOrder, SubtitlePlaybackMode, } from "@jellyfin/sdk/lib/generated-client"; -import { atom, useAtom } from "jotai"; +import { atom, useAtom, useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo } from "react"; import { Platform } from "react-native"; import { BITRATES, type Bitrate } from "@/components/BitrateSelector"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; +import { apiAtom } from "@/providers/JellyfinProvider"; import { writeInfoLog } from "@/utils/log"; import { storage } from "../mmkv"; @@ -88,6 +88,7 @@ export type Home = { }; export type HomeSection = { + title?: string; orientation?: "horizontal" | "vertical"; items?: HomeSectionItemResolver; nextUp?: HomeSectionNextUpResolver; @@ -278,7 +279,8 @@ export const pluginSettingsAtom = atom( loadPluginSettings(), ); -export const useSettings = (api: Api | null) => { +export const useSettings = () => { + const api = useAtomValue(apiAtom); const [_settings, setSettings] = useAtom(settingsAtom); const [pluginSettings, _setPluginSettings] = useAtom(pluginSettingsAtom); @@ -302,11 +304,11 @@ export const useSettings = (api: Api | null) => { return; } const settings = await api.getStreamyfinPluginConfig().then( - ({ data }: any) => { + ({ data }) => { writeInfoLog("Got plugin settings", data?.settings); return data?.settings; }, - (_err: any) => undefined, + (_err) => undefined, ); setPluginSettings(settings); return settings; @@ -367,11 +369,11 @@ export const useSettings = (api: Api | null) => { }; }, [_settings, pluginSettings]); - return [ + return { settings, updateSettings, pluginSettings, setPluginSettings, refreshStreamyfinPluginSettings, - ] as const; + }; }; diff --git a/utils/jellyseerr b/utils/jellyseerr index 4401b164..fc6a9e95 160000 --- a/utils/jellyseerr +++ b/utils/jellyseerr @@ -1 +1 @@ -Subproject commit 4401b16414af604a7372dacac326c38b18ad8555 +Subproject commit fc6a9e952ca524fcc2252d4a6eb4f08bb767a9a3