diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index ca68dc43..bfee8601 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -43,6 +43,7 @@ body: label: Version description: What version of Streamyfin are you running? options: + - 0.28.1 - 0.28.0 - 0.27.0 - 0.26.1 diff --git a/.github/workflows/build-android_Miron.yml b/.github/workflows/build-android_Miron.yml index 40478857..a7e67bd6 100644 --- a/.github/workflows/build-android_Miron.yml +++ b/.github/workflows/build-android_Miron.yml @@ -30,7 +30,7 @@ jobs: - name: 🍞 Setup Bun uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 with: - bun-version: '1.2.15' + bun-version: '1.2.17' - name: β˜• Setup JDK uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index bbcba767..354b9b32 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -30,7 +30,7 @@ jobs: - name: 🍞 Setup Bun uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 with: - bun-version: '1.2.15' + bun-version: '1.2.17' - name: πŸ’Ύ Cache Bun dependencies uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 diff --git a/.github/workflows/check-lockfile.yml b/.github/workflows/check-lockfile.yml index b3f15f5b..42cf3d1f 100644 --- a/.github/workflows/check-lockfile.yml +++ b/.github/workflows/check-lockfile.yml @@ -29,7 +29,7 @@ jobs: - name: 🍞 Setup Bun uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 with: - bun-version: '1.2.15' + bun-version: '1.2.17' - name: πŸ’Ύ Cache Bun dependencies uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 diff --git a/.github/workflows/ci-codeql.yml b/.github/workflows/ci-codeql.yml index d99599fb..a65bf666 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@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 + uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 with: languages: ${{ matrix.language }} queries: +security-extended,security-and-quality - name: πŸ› οΈ Autobuild - uses: github/codeql-action/autobuild@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 + uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 - name: πŸ§ͺ Perform CodeQL Analysis - uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 + uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 50312623..df021a59 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -22,7 +22,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2 + - uses: marocchino/sticky-pull-request-comment@d2ad0de260ae8b0235ce059e63f2949ba9e05943 # v2.9.3 if: always() && (steps.lint_pr_title.outputs.error_message != null) with: header: pr-title-lint-error @@ -36,7 +36,7 @@ jobs: ``` - if: ${{ steps.lint_pr_title.outputs.error_message == null }} - uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2 + uses: marocchino/sticky-pull-request-comment@d2ad0de260ae8b0235ce059e63f2949ba9e05943 # v2.9.3 with: header: pr-title-lint-error delete: true @@ -86,7 +86,7 @@ jobs: - name: "🍞 Setup Bun" uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 with: - bun-version: '1.2.15' + bun-version: '1.2.17' - name: "πŸ“¦ Install dependencies" run: bun install --frozen-lockfile diff --git a/.vscode/settings.json b/.vscode/settings.json index b200b485..2986a116 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,7 +4,7 @@ "editor.formatOnSave": true }, "[typescriptreact]": { - "editor.defaultFormatter": "biomejs.biome", + "editor.defaultFormatter": "vscode.typescript-language-features", "editor.formatOnSave": true }, "prettier.printWidth": 120, diff --git a/README.md b/README.md index 579aba54..01f56fb4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Buy Me A Coffee -Welcome to Streamyfin, a simple and user-friendly Jellyfin video streaming client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox. +A simple and user-friendly Jellyfin video streaming client built with Expo. If you are looking for an alternative to other Jellyfin clients, we hope you find Streamyfin a useful addition to your media streaming toolbox.
@@ -23,17 +23,17 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin video streaming clien ## πŸ§ͺ Experimental Features -Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These are still in development, and we appreciate your patience and feedback as we work to improve them. +Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These features are still in development, and your patience and feedback are much appreciated as we work to improve them. -### Downloading +### πŸ“₯ Downloading Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode. -### Chromecast +### πŸŽ₯ Chromecast Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos, but we're working on adding support for subtitles and other features. -### Streamyfin Plugin +### 🧩 Streamyfin Plugin The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example: @@ -41,21 +41,21 @@ The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that ho - Choose the default languages - Set download method and search provider - Customize home screen -- And more... +- And much more... [Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin) -### Jellysearch +### πŸ” Jellysearch [Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! πŸš€ > A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients. -## Roadmap for V1 +## πŸ›£οΈ Roadmap for V1 -Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open for feedback and suggestions, so please let us know if you have any ideas or feature requests. +Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests. -## Get it now +## πŸ“₯ Get it now
Get Streamyfin on App Store @@ -64,7 +64,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android. -### Beta testing +### πŸ§ͺ Beta testing To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the ⁠πŸ§ͺ-public-beta channel on Discord and I'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that I can manually add you. @@ -81,7 +81,7 @@ To access the Streamyfin beta, you need to subscribe to the Member tier (or high We welcome any help to make Streamyfin better. If you'd like to contribute, please fork the repository and submit a pull request. For major changes, it's best to open an issue first to discuss your ideas. -### Development info +### πŸ‘¨β€πŸ’» Development info 1. Use node `>20` 2. Install dependencies `bun i && bun run submodule-reload` @@ -118,7 +118,7 @@ If you have questions or need support, feel free to reach out: - GitHub Issues: Report bugs or request features here. - Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com) -## FAQ +## ❓ FAQ 1. Q: Why can't I see my libraries in Streamyfin? A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only. @@ -135,7 +135,7 @@ We would like to thank the Jellyfin team for their great software and awesome su Special shoutout to the JF official clients for being an inspiration to ours. -### Core Developers +### πŸ† Core Developers Thanks to the following contributors for their significant contributions: @@ -220,6 +220,12 @@ I'd also like to thank the following people and projects for their contributions - [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project. - The Jellyfin devs for always being helpful in the Discord. -## Star History +## ⭐ Star History [![Star History Chart](https://api.star-history.com/svg?repos=streamyfin/streamyfin&type=Date)](https://star-history.com/#streamyfin/streamyfin&Date) + +## ⚠️ Disclaimer +Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions or support requests related to piracy are strictly prohibited across all our channels. + +## 🀝 Sponsorship +VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) diff --git a/app.json b/app.json index 621bac09..823f085f 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.28.0", + "version": "0.28.1", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index 86ae2cbe..22a9d7da 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -64,12 +64,6 @@ export default function IndexLayout() { title: t("home.settings.settings_title"), }} /> - Promise, + router: any, + t: any, +) { + Alert.alert( + t("home.downloads.new_app_version_requires_re_download"), + undefined, + [ + { + text: t("common.cancel"), + onPress: () => router.back(), + style: "cancel", + }, + { + text: t("common.continue"), + onPress: () => deleteAllFiles(), + }, + ], + ); +} export default function page() { const navigation = useNavigation(); const { t } = useTranslation(); const [queue, setQueue] = useAtom(queueAtom); - const { removeProcess, downloadedFiles, deleteFileByType } = useDownload(); + const { deleteFileByType, downloadedFiles, removeProcess, deleteAllFiles } = useDownload(); const router = useRouter(); const [settings] = useSettings(); const bottomSheetModalRef = useRef(null); const movies = useMemo(() => { - try { - return downloadedFiles?.filter((f) => f.item.Type === "Movie") || []; - } catch { - migration_20241124(); - return []; - } + return downloadedFiles?.filter((f) => f.item.Type === "Movie") || []; }, [downloadedFiles]); const groupedBySeries = useMemo(() => { @@ -54,12 +72,12 @@ export default function page() { }); return Object.values(series); } catch { - migration_20241124(); + migration_20241124(deleteAllFiles, router, t); return []; } - }, [downloadedFiles]); + }, [downloadedFiles, deleteAllFiles, router, t]); - const insets = useSafeAreaInsets(); + const _insets = useSafeAreaInsets(); useEffect(() => { navigation.setOptions({ @@ -98,16 +116,10 @@ export default function page() { return ( <> - - - - {settings?.downloadMethod === DownloadMethod.Remux && ( + + + + {t("home.downloads.queue")} @@ -151,70 +163,74 @@ export default function page() { )} - )} - - - - {movies.length > 0 && ( - - - - {t("home.downloads.movies")} - - - {movies?.length} - - - - - {movies?.map((item) => ( - - - - ))} - - + - )} - {groupedBySeries.length > 0 && ( - - - - {t("home.downloads.tvseries")} - - - - {groupedBySeries?.length} + + {movies.length > 0 && ( + + + + {t("home.downloads.movies")} + + {movies?.length} + + + + {movies?.map((item) => ( + + + + ))} + + - - - {groupedBySeries?.map((items) => ( - - i.item)} - key={items[0].item.SeriesId} - /> - - ))} + )} + {groupedBySeries.length > 0 && ( + + + + {t("home.downloads.tvseries")} + + + + {groupedBySeries?.length} + + - - - )} - {downloadedFiles?.length === 0 && ( - - - {t("home.downloads.no_downloaded_items")} - - - )} - - + + + {groupedBySeries?.map((items) => ( + + i.item)} + key={items[0].item.SeriesId} + /> + + ))} + + + + )} + {downloadedFiles?.length === 0 && ( + + + {t("home.downloads.no_downloaded_items")} + + + )} + + + ); } - -function migration_20241124() { - const router = useRouter(); - const { deleteAllFiles } = useDownload(); - Alert.alert( - t("home.downloads.new_app_version_requires_re_download"), - t("home.downloads.new_app_version_requires_re_download_description"), - [ - { - text: t("home.downloads.back"), - onPress: () => router.back(), - }, - { - text: t("home.downloads.delete"), - style: "destructive", - onPress: async () => await deleteAllFiles(), - }, - ], - ); -} diff --git a/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx b/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx deleted file mode 100644 index 9fbc0a23..00000000 --- a/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { Text } from "@/components/common/Text"; -import DisabledSetting from "@/components/settings/DisabledSetting"; -import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { useSettings } from "@/utils/atoms/settings"; -import { getOrSetDeviceId } from "@/utils/device"; -import { getStatistics } from "@/utils/optimize-server"; -import { useMutation } from "@tanstack/react-query"; -import { useNavigation } from "expo-router"; -import { useAtom } from "jotai"; -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { ActivityIndicator, TouchableOpacity, View } from "react-native"; -import { toast } from "sonner-native"; - -export default function page() { - const navigation = useNavigation(); - - const { t } = useTranslation(); - - const [api] = useAtom(apiAtom); - const [settings, updateSettings, pluginSettings] = useSettings(); - - const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] = - useState(settings?.optimizedVersionsServerUrl || ""); - - const saveMutation = useMutation({ - mutationFn: async (newVal: string) => { - if (newVal.length === 0 || !newVal.startsWith("http")) { - toast.error(t("home.settings.toasts.invalid_url")); - return; - } - - const updatedUrl = newVal.endsWith("/") ? newVal : `${newVal}/`; - - updateSettings({ - optimizedVersionsServerUrl: updatedUrl, - }); - - return await getStatistics({ - url: updatedUrl, - authHeader: api?.accessToken, - deviceId: getOrSetDeviceId(), - }); - }, - onSuccess: (data) => { - if (data) { - toast.success(t("home.settings.toasts.connected")); - } else { - toast.error(t("home.settings.toasts.could_not_connect")); - } - }, - onError: () => { - toast.error(t("home.settings.toasts.could_not_connect")); - }, - }); - - const onSave = (newVal: string) => { - saveMutation.mutate(newVal); - }; - - useEffect(() => { - if (!pluginSettings?.optimizedVersionsServerUrl?.locked) { - navigation.setOptions({ - title: t("home.settings.downloads.optimized_server"), - headerRight: () => - saveMutation.isPending ? ( - - ) : ( - onSave(optimizedVersionsServerUrl)} - > - - {t("home.settings.downloads.save_button")} - - - ), - }); - } - }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]); - - return ( - - - - ); -} diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx index b7c39f9f..81f123bf 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx @@ -1,10 +1,4 @@ -import { ItemContent } from "@/components/ItemContent"; -import { Text } from "@/components/common/Text"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; -import { useQuery } from "@tanstack/react-query"; import { useLocalSearchParams } from "expo-router"; -import { useAtom } from "jotai"; import type React from "react"; import { useEffect } from "react"; import { useTranslation } from "react-i18next"; @@ -15,29 +9,18 @@ import Animated, { useSharedValue, withTiming, } from "react-native-reanimated"; +import { Text } from "@/components/common/Text"; +import { ItemContent } from "@/components/ItemContent"; +import { useItemQuery } from "@/hooks/useItemQuery"; const Page: React.FC = () => { - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); const { id } = useLocalSearchParams() as { id: string }; const { t } = useTranslation(); - const { data: item, isError } = useQuery({ - queryKey: ["item", id], - queryFn: async () => { - if (!api || !user || !id) return; - const res = await getUserLibraryApi(api).getItem({ - itemId: id, - userId: user?.Id, - }); + const { offline } = useLocalSearchParams() as { offline?: string }; + const isOffline = offline === "true"; - return res.data; - }, - staleTime: 0, - refetchOnMount: true, - refetchOnWindowFocus: true, - refetchOnReconnect: true, - }); + const { data: item, isError } = useItemQuery(id, isOffline); const opacity = useSharedValue(1); const animatedStyle = useAnimatedStyle(() => { @@ -107,7 +90,7 @@ const Page: React.FC = () => { - {item && } + {item && } ); }; diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx index cce1e2af..ef6bca92 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx @@ -69,10 +69,16 @@ const page: React.FC = () => { seriesId: item?.Id!, userId: user?.Id!, enableUserData: true, - fields: ["MediaSources", "MediaStreams", "Overview"], + // Note: Including trick play is necessary to enable trick play downloads + fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"], }); return res?.data.Items || []; }, + select: (data) => + // This needs to be sorted by parent index number and then index number, that way we can download the episodes in the correct order. + [...(data || [])].sort( + (a, b) => (a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) || (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0) + ), staleTime: 60, enabled: !!api && !!user?.Id && !!item?.Id, }); @@ -136,7 +142,7 @@ const page: React.FC = () => { resizeMode: "contain", }} /> - ) : null + ) : undefined } > diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 929c098a..63dcf453 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -367,15 +367,7 @@ const Page = () => { className='mr-1' id={libraryId} queryKey='sortBy' - queryFn={async () => - sortOptions - .filter( - (s) => - library?.CollectionType !== "movies" || - s.key !== SortByOption.DateLastContentAdded, - ) - .map((s) => s.key) - } + queryFn={async () => sortOptions.map((s) => s.key)} set={setSortBy} values={sortBy} title={t("library.filters.sort_by")} diff --git a/app/(auth)/player/_layout.tsx b/app/(auth)/player/_layout.tsx index 19e9a8d0..3c29bff4 100644 --- a/app/(auth)/player/_layout.tsx +++ b/app/(auth)/player/_layout.tsx @@ -1,33 +1,8 @@ -import * as ScreenOrientation from "@/packages/expo-screen-orientation"; -import { useSettings } from "@/utils/atoms/settings"; import { Stack } from "expo-router"; -import React, { useLayoutEffect } from "react"; -import { Platform } from "react-native"; +import React from "react"; import { SystemBars } from "react-native-edge-to-edge"; export default function Layout() { - const [settings] = useSettings(); - - useLayoutEffect(() => { - if (Platform.isTV) return; - - if (!settings.followDeviceOrientation && settings.defaultVideoOrientation) { - ScreenOrientation.lockAsync(settings.defaultVideoOrientation); - } - - return () => { - if (Platform.isTV) return; - - if (settings.followDeviceOrientation === true) { - ScreenOrientation.unlockAsync(); - } else { - ScreenOrientation.lockAsync( - ScreenOrientation.OrientationLock.PORTRAIT_UP, - ); - } - }; - }); - return ( <> + - - - {usingOptimizedServer - ? t("item_card.download.using_optimized_server") - : t("item_card.download.using_default_method")} - - diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 33fd305a..b53417f9 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -1,25 +1,3 @@ -import { AudioTrackSelector } from "@/components/AudioTrackSelector"; -import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector"; -import { DownloadSingleItem } from "@/components/DownloadItem"; -import { OverviewText } from "@/components/OverviewText"; -import { ParallaxScrollView } from "@/components/ParallaxPage"; -// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null; -import { PlayButton } from "@/components/PlayButton"; -import { PlayedStatus } from "@/components/PlayedStatus"; -import { SimilarItems } from "@/components/SimilarItems"; -import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector"; -import { ItemImage } from "@/components/common/ItemImage"; -import { CastAndCrew } from "@/components/series/CastAndCrew"; -import { CurrentSeries } from "@/components/series/CurrentSeries"; -import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel"; -import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; -import { useImageColors } from "@/hooks/useImageColors"; -import { useOrientation } from "@/hooks/useOrientation"; -import * as ScreenOrientation from "@/packages/expo-screen-orientation"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { userAtom } from "@/providers/JellyfinProvider"; -import { useSettings } from "@/utils/atoms/settings"; -import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import type { BaseItemDto, MediaSourceInfo, @@ -30,12 +8,35 @@ import { useAtom } from "jotai"; import React, { useEffect, useMemo, useState } from "react"; import { Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { AudioTrackSelector } from "@/components/AudioTrackSelector"; +import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector"; +import { ItemImage } from "@/components/common/ItemImage"; +import { DownloadSingleItem } from "@/components/DownloadItem"; +import { OverviewText } from "@/components/OverviewText"; +import { ParallaxScrollView } from "@/components/ParallaxPage"; +// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null; +import { PlayButton } from "@/components/PlayButton"; +import { PlayedStatus } from "@/components/PlayedStatus"; +import { SimilarItems } from "@/components/SimilarItems"; +import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector"; +import { CastAndCrew } from "@/components/series/CastAndCrew"; +import { CurrentSeries } from "@/components/series/CurrentSeries"; +import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel"; +import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; +import { useImageColors } from "@/hooks/useImageColors"; +import { useItemQuery } from "@/hooks/useItemQuery"; +import { useOrientation } from "@/hooks/useOrientation"; +import * as ScreenOrientation from "@/packages/expo-screen-orientation"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { AddToFavorites } from "./AddToFavorites"; import { ItemHeader } from "./ItemHeader"; import { ItemTechnicalDetails } from "./ItemTechnicalDetails"; import { MediaSourceSelector } from "./MediaSourceSelector"; import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; import { PlayInRemoteSessionButton } from "./PlayInRemoteSession"; + const Chromecast = !Platform.isTV ? require("./Chromecast") : null; export type SelectedOptions = { @@ -45,8 +46,13 @@ export type SelectedOptions = { subtitleIndex: number; }; -export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( - ({ item }) => { +interface ItemContentProps { + item: BaseItemDto; + isOffline: boolean; +} + +export const ItemContent: React.FC = React.memo( + ({ item, isOffline }) => { const [api] = useAtom(apiAtom); const [settings] = useSettings(); const { orientation } = useOrientation(); @@ -68,66 +74,75 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( defaultBitrate, defaultMediaSource, defaultSubtitleIndex, - } = useDefaultPlaySettings(item, settings); + } = useDefaultPlaySettings(item!, settings); + + const logoUrl = useMemo( + () => (item ? getLogoImageUrlById({ api, item }) : null), + [api, item], + ); + + const loading = useMemo(() => { + return Boolean(logoUrl && loadingLogo); + }, [loadingLogo, logoUrl]); // Needs to automatically change the selected to the default values for default indexes. useEffect(() => { - setSelectedOptions(() => ({ - bitrate: defaultBitrate, - mediaSource: defaultMediaSource, - subtitleIndex: defaultSubtitleIndex ?? -1, - audioIndex: defaultAudioIndex, - })); + if (item) { + setSelectedOptions(() => ({ + bitrate: defaultBitrate, + mediaSource: defaultMediaSource, + subtitleIndex: defaultSubtitleIndex ?? -1, + audioIndex: defaultAudioIndex, + })); + } }, [ defaultAudioIndex, defaultBitrate, defaultSubtitleIndex, defaultMediaSource, + item, ]); - if (!Platform.isTV) { - useEffect(() => { - navigation.setOptions({ - headerRight: () => - item && ( - - - {item.Type !== "Program" && ( - - {!Platform.isTV && ( - - )} - {user?.Policy?.IsAdministrator && ( - - )} - - - - - )} - - ), - }); - }, [item]); - } + useEffect(() => { + if (Platform.isTV) { + return; + } + navigation.setOptions({ + headerRight: () => + item && ( + + + {item.Type !== "Program" && ( + + {!Platform.isTV && !isOffline && ( + + )} + {user?.Policy?.IsAdministrator && !isOffline && ( + + )} + + {!isOffline && } + + )} + + ), + }); + }, [item, navigation, isOffline, user]); useEffect(() => { - if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP) - setHeaderHeight(230); - else if (item.Type === "Movie") setHeaderHeight(500); - else setHeaderHeight(350); - }, [item.Type, orientation]); + if (item) { + if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP) + setHeaderHeight(230); + else if (item.Type === "Movie") setHeaderHeight(500); + else setHeaderHeight(350); + } + }, [item, orientation]); - const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]); - - const loading = useMemo(() => { - return Boolean(logoUrl && loadingLogo); - }, [loadingLogo, logoUrl]); - if (!selectedOptions) return null; + if (!item || !selectedOptions) return null; return ( = React.memo( onLoad={() => setLoadingLogo(false)} onError={() => setLoadingLogo(false)} /> - ) : null + ) : undefined } > - {item.Type !== "Program" && !Platform.isTV && ( + {item.Type !== "Program" && !Platform.isTV && !isOffline && ( = React.memo( className='grow' selectedOptions={selectedOptions} item={item} + isOffline={isOffline} /> {item.Type === "Episode" && ( - + )} - + {!isOffline && ( + + )} {item.Type !== "Program" && ( <> - {item.Type === "Episode" && ( + {item.Type === "Episode" && !isOffline && ( )} - + {!isOffline && ( + + )} - {item.People && item.People.length > 0 && ( + {item.People && item.People.length > 0 && !isOffline && ( {item.People.slice(0, 3).map((person, idx) => ( = React.memo( )} - + {!isOffline && } )} diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index fb706fff..e013a826 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -1,13 +1,3 @@ -import { useHaptic } from "@/hooks/useHaptic"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; -import { useSettings } from "@/utils/atoms/settings"; -import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; -import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; -import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; -import { chromecast } from "@/utils/profiles/chromecast"; -import { chromecasth265 } from "@/utils/profiles/chromecasth265"; -import { runtimeTicksToMinutes } from "@/utils/time"; import { useActionSheet } from "@expo/react-native-action-sheet"; import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; @@ -15,7 +5,6 @@ import { useRouter } from "expo-router"; import { useAtom, useAtomValue } from "jotai"; import { useCallback, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { Platform, Pressable } from "react-native"; import { Alert, TouchableOpacity, View } from "react-native"; import CastContext, { CastButton, @@ -33,12 +22,23 @@ import Animated, { useSharedValue, withTiming, } from "react-native-reanimated"; +import { useHaptic } from "@/hooks/useHaptic"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; +import { useSettings } from "@/utils/atoms/settings"; +import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; +import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; +import { chromecast } from "@/utils/profiles/chromecast"; +import { chromecasth265 } from "@/utils/profiles/chromecasth265"; +import { runtimeTicksToMinutes } from "@/utils/time"; import type { Button } from "./Button"; import type { SelectedOptions } from "./ItemContent"; interface Props extends React.ComponentProps { item: BaseItemDto; selectedOptions: SelectedOptions; + isOffline?: boolean; } const ANIMATION_DURATION = 500; @@ -47,6 +47,7 @@ const MIN_PLAYBACK_WIDTH = 15; export const PlayButton: React.FC = ({ item, selectedOptions, + isOffline, ...props }: Props) => { const { showActionSheetWithOptions } = useActionSheet(); @@ -76,7 +77,7 @@ export const PlayButton: React.FC = ({ } router.push(`/player/direct-player?${q}`); }, - [router], + [router, isOffline], ); const onPress = useCallback(async () => { @@ -91,6 +92,8 @@ export const PlayButton: React.FC = ({ subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "", mediaSourceId: selectedOptions.mediaSource?.Id ?? "", bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "", + playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0", + offline: isOffline ? "true" : "false", }); const queryString = queryParams.toString(); diff --git a/components/PlayedStatus.tsx b/components/PlayedStatus.tsx index 00beb18f..93b91ae9 100644 --- a/components/PlayedStatus.tsx +++ b/components/PlayedStatus.tsx @@ -1,50 +1,22 @@ -import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { useQueryClient } from "@tanstack/react-query"; import type React from "react"; import { View, type ViewProps } from "react-native"; +import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; import { RoundButton } from "./RoundButton"; interface Props extends ViewProps { items: BaseItemDto[]; + isOffline?: boolean; size?: "default" | "large"; } -export const PlayedStatus: React.FC = ({ items, ...props }) => { - const queryClient = useQueryClient(); - - const invalidateQueries = () => { - items.forEach((item) => { - queryClient.invalidateQueries({ - queryKey: ["item", item.Id], - }); - }); - queryClient.invalidateQueries({ - queryKey: ["resumeItems"], - }); - queryClient.invalidateQueries({ - queryKey: ["continueWatching"], - }); - queryClient.invalidateQueries({ - queryKey: ["nextUp-all"], - }); - queryClient.invalidateQueries({ - queryKey: ["nextUp"], - }); - queryClient.invalidateQueries({ - queryKey: ["episodes"], - }); - queryClient.invalidateQueries({ - queryKey: ["seasons"], - }); - queryClient.invalidateQueries({ - queryKey: ["home"], - }); - }; - +export const PlayedStatus: React.FC = ({ + items, + isOffline = false, + ...props +}) => { const allPlayed = items.every((item) => item.UserData?.Played); - - const markAsPlayedStatus = useMarkAsPlayed(items); + const toggle = useMarkAsPlayed(items, isOffline); return ( @@ -52,8 +24,7 @@ export const PlayedStatus: React.FC = ({ items, ...props }) => { fillColor={allPlayed ? "primary" : undefined} icon={allPlayed ? "checkmark" : "checkmark"} onPress={async () => { - console.log(allPlayed); - await markAsPlayedStatus(!allPlayed); + await toggle(!allPlayed); }} size={props.size} /> diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index 1e74bbb6..25d60ce9 100644 --- a/components/SubtitleTrackSelector.tsx +++ b/components/SubtitleTrackSelector.tsx @@ -18,7 +18,7 @@ export const SubtitleTrackSelector: React.FC = ({ selected, ...props }) => { - if (Platform.isTV) return null; + const { t } = useTranslation(); const subtitleStreams = useMemo(() => { return source?.MediaStreams?.filter((x) => x.Type === "Subtitle"); }, [source]); @@ -28,9 +28,7 @@ export const SubtitleTrackSelector: React.FC = ({ [subtitleStreams, selected], ); - if (subtitleStreams?.length === 0) return null; - - const { t } = useTranslation(); + if (Platform.isTV || subtitleStreams?.length === 0) return null; return ( = ({ item }) => { + const progress = useMemo(() => { + if (item.Type === "Program") { + if (!item.StartDate || !item.EndDate) { + return 0; + } + const startDate = new Date(item.StartDate); + const endDate = new Date(item.EndDate); + const now = new Date(); + const total = endDate.getTime() - startDate.getTime(); + if (total <= 0) { + return 0; + } + const elapsed = now.getTime() - startDate.getTime(); + return (elapsed / total) * 100; + } + return item.UserData?.PlayedPercentage || 0; + }, [item]); + + if (progress <= 0) { + return null; + } + + return ( + <> + + + + ); +}; diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx index 23cb6dd7..5c44ab4f 100644 --- a/components/common/TouchableItemRouter.tsx +++ b/components/common/TouchableItemRouter.tsx @@ -1,5 +1,3 @@ -import { useFavorite } from "@/hooks/useFavorite"; -import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; import { useActionSheet } from "@expo/react-native-action-sheet"; import type { BaseItemDto, @@ -8,9 +6,12 @@ import type { import { useRouter, useSegments } from "expo-router"; import { type PropsWithChildren, useCallback } from "react"; import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; +import { useFavorite } from "@/hooks/useFavorite"; +import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; interface Props extends TouchableOpacityProps { item: BaseItemDto; + isOffline?: boolean; } export const itemRouter = ( @@ -50,6 +51,7 @@ export const itemRouter = ( export const TouchableItemRouter: React.FC> = ({ item, + isOffline = false, children, ...props }) => { @@ -105,7 +107,10 @@ export const TouchableItemRouter: React.FC> = ({ { - const url = itemRouter(item, from); + let url = itemRouter(item, from); + if (isOffline) { + url += `&offline=true`; + } // @ts-expect-error router.push(url); }} @@ -114,4 +119,6 @@ export const TouchableItemRouter: React.FC> = ({ {children} ); + + return null; }; diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index 3f19f7cd..c922b0e8 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -1,9 +1,3 @@ -import { Text } from "@/components/common/Text"; -import { useDownload } from "@/providers/DownloadProvider"; -import { DownloadMethod, useSettings } from "@/utils/atoms/settings"; -import { storage } from "@/utils/mmkv"; -import type { JobStatus } from "@/utils/optimize-server"; -import { formatTimeString } from "@/utils/time"; import { Ionicons } from "@expo/vector-icons"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Image } from "expo-image"; @@ -19,12 +13,22 @@ import { type ViewProps, } 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 BackGroundDownloader = !Platform.isTV ? require("@kesha-antonov/react-native-background-downloader") : null; -interface Props extends ViewProps {} +interface Props extends ViewProps { } + +const bytesToMB = (bytes: number) => { + return bytes / 1024 / 1024; +}; export const ActiveDownloads: React.FC = ({ ...props }) => { const { processes } = useDownload(); @@ -59,32 +63,18 @@ interface DownloadCardProps extends TouchableOpacityProps { } const DownloadCard = ({ process, ...props }: DownloadCardProps) => { - const { processes, startDownload } = useDownload(); + const { startDownload, removeProcess } = useDownload(); const router = useRouter(); - const { removeProcess, setProcesses } = useDownload(); - const [settings] = useSettings(); const queryClient = useQueryClient(); const cancelJobMutation = useMutation({ mutationFn: async (id: string) => { if (!process) throw new Error("No active download"); - - try { - const tasks = await BackGroundDownloader.checkForExistingDownloads(); - for (const task of tasks) { - if (task.id === id) { - task.stop(); - } - } - } finally { - await removeProcess(id); - if (settings?.downloadMethod === DownloadMethod.Optimized) { - await queryClient.refetchQueries({ queryKey: ["jobs"] }); - } - } + removeProcess(id); }, onSuccess: () => { toast.success(t("home.downloads.toasts.download_cancelled")); + queryClient.invalidateQueries({ queryKey: ["downloads"] }); }, onError: (e) => { console.error(e); @@ -93,11 +83,14 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => { }); const eta = (p: JobStatus) => { - if (!p.speed || !p.progress) return null; + if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null; - const length = p?.item?.RunTimeTicks || 0; - const timeLeft = (length - length * (p.progress / 100)) / p.speed; - return formatTimeString(timeLeft, "tick"); + const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0); + if (bytesRemaining <= 0) return null; + + const secondsRemaining = bytesRemaining / p.speed; + + return formatTimeString(secondsRemaining, "s"); }; const base64Image = useMemo(() => { @@ -110,8 +103,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => { className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden' {...props} > - {(process.status === "optimizing" || - process.status === "downloading") && ( + {process.status === "downloading" && ( { ) : ( {process.progress.toFixed(0)}% )} - {process.speed && ( - {process.speed?.toFixed(2)}x + {process.speed && process.speed > 0 && ( + + {bytesToMB(process.speed).toFixed(2)} MB/s + )} {eta(process) && ( diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx index 97a9308f..7811954e 100644 --- a/components/downloads/EpisodeCard.tsx +++ b/components/downloads/EpisodeCard.tsx @@ -1,25 +1,16 @@ -import { useHaptic } from "@/hooks/useHaptic"; import { ActionSheetProvider, useActionSheet, } from "@expo/react-native-action-sheet"; -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models/base-item-dto"; import type React from "react"; -import { useCallback, useMemo } from "react"; -import { - TouchableOpacity, - type TouchableOpacityProps, - View, -} from "react-native"; - +import { useCallback } from "react"; +import { type TouchableOpacityProps, View } from "react-native"; import { Text } from "@/components/common/Text"; import { DownloadSize } from "@/components/downloads/DownloadSize"; -import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener"; +import { useHaptic } from "@/hooks/useHaptic"; import { useDownload } from "@/providers/DownloadProvider"; -import { storage } from "@/utils/mmkv"; import { runtimeTicksToSeconds } from "@/utils/time"; -import { Ionicons } from "@expo/vector-icons"; -import { Image } from "expo-image"; import ContinueWatchingPoster from "../ContinueWatchingPoster"; import { TouchableItemRouter } from "../common/TouchableItemRouter"; @@ -27,26 +18,17 @@ interface EpisodeCardProps extends TouchableOpacityProps { item: BaseItemDto; } -export const EpisodeCard: React.FC = ({ item, ...props }) => { +export const EpisodeCard: React.FC = ({ item }) => { const { deleteFile } = useDownload(); - const { openFile } = useDownloadedFileOpener(); const { showActionSheetWithOptions } = useActionSheet(); const successHapticFeedback = useHaptic("success"); - const base64Image = useMemo(() => { - return storage.getString(item.Id!); - }, [item]); - - const handleOpenFile = useCallback(() => { - openFile(item); - }, [item, openFile]); - /** * Handles deleting the file with haptic feedback. */ const handleDeleteFile = useCallback(() => { if (item.Id) { - deleteFile(item.Id); + deleteFile(item.Id, "Episode"); successHapticFeedback(); } }, [deleteFile, item.Id]); @@ -77,10 +59,10 @@ export const EpisodeCard: React.FC = ({ item, ...props }) => { }, [showActionSheetWithOptions, handleDeleteFile]); return ( - @@ -104,7 +86,7 @@ export const EpisodeCard: React.FC = ({ item, ...props }) => { {item.Overview} - + ); }; diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx index e15fd003..c193d562 100644 --- a/components/downloads/MovieCard.tsx +++ b/components/downloads/MovieCard.tsx @@ -1,19 +1,18 @@ -import { useHaptic } from "@/hooks/useHaptic"; import { ActionSheetProvider, useActionSheet, } from "@expo/react-native-action-sheet"; +import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; import type React from "react"; import { useCallback, useMemo } from "react"; -import { TouchableOpacity, View } from "react-native"; - +import { View } from "react-native"; import { DownloadSize } from "@/components/downloads/DownloadSize"; -import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener"; import { useDownload } from "@/providers/DownloadProvider"; import { storage } from "@/utils/mmkv"; -import { Ionicons } from "@expo/vector-icons"; -import { Image } from "expo-image"; +import { ProgressBar } from "../common/ProgressBar"; +import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { ItemCardText } from "../ItemCardText"; interface MovieCardProps { @@ -27,16 +26,10 @@ interface MovieCardProps { */ export const MovieCard: React.FC = ({ item }) => { const { deleteFile } = useDownload(); - const { openFile } = useDownloadedFileOpener(); const { showActionSheetWithOptions } = useActionSheet(); - const successHapticFeedback = useHaptic("success"); - - const handleOpenFile = useCallback(() => { - openFile(item); - }, [item, openFile]); const base64Image = useMemo(() => { - return storage.getString(item.Id!); + return storage.getString(item?.Id!); }, []); /** @@ -44,8 +37,7 @@ export const MovieCard: React.FC = ({ item }) => { */ const handleDeleteFile = useCallback(() => { if (item.Id) { - deleteFile(item.Id); - successHapticFeedback(); + deleteFile(item.Id, "Movie"); } }, [deleteFile, item.Id]); @@ -75,9 +67,9 @@ export const MovieCard: React.FC = ({ item }) => { }, [showActionSheetWithOptions, handleDeleteFile]); return ( - + {base64Image ? ( - + = ({ item }) => { resizeMode: "cover", }} /> + ) : ( - + + )} - + ); }; diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx index a77085de..dc18b464 100644 --- a/components/home/ScrollingCollectionList.tsx +++ b/components/home/ScrollingCollectionList.tsx @@ -1,5 +1,3 @@ -import { Text } from "@/components/common/Text"; -import MoviePoster from "@/components/posters/MoviePoster"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { type QueryFunction, @@ -8,9 +6,11 @@ import { } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { ScrollView, View, type ViewProps } from "react-native"; +import { Text } from "@/components/common/Text"; +import MoviePoster from "@/components/posters/MoviePoster"; import ContinueWatchingPoster from "../ContinueWatchingPoster"; -import { ItemCardText } from "../ItemCardText"; import { TouchableItemRouter } from "../common/TouchableItemRouter"; +import { ItemCardText } from "../ItemCardText"; import SeriesPoster from "../posters/SeriesPoster"; interface Props extends ViewProps { @@ -20,6 +20,7 @@ interface Props extends ViewProps { queryKey: QueryKey; queryFn: QueryFunction; hideIfEmpty?: boolean; + isOffline?: boolean; } export const ScrollingCollectionList: React.FC = ({ @@ -29,6 +30,7 @@ export const ScrollingCollectionList: React.FC = ({ queryFn, queryKey, hideIfEmpty = false, + isOffline = false, ...props }) => { const { data, isLoading } = useQuery({ @@ -90,6 +92,7 @@ export const ScrollingCollectionList: React.FC = ({ = ({ item }) => { width: "100%", }} /> - {} ); }; diff --git a/components/series/SeasonEpisodesCarousel.tsx b/components/series/SeasonEpisodesCarousel.tsx index 1c1ec80b..092472a0 100644 --- a/components/series/SeasonEpisodesCarousel.tsx +++ b/components/series/SeasonEpisodesCarousel.tsx @@ -1,30 +1,35 @@ -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; 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 { router } from "expo-router"; import { useAtom } from "jotai"; import { useEffect, useMemo, useRef } from "react"; -import { TouchableOpacity, View, type ViewProps } from "react-native"; +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 { ItemCardText } from "../ItemCardText"; import { HorizontalScroll, type HorizontalScrollRef, } from "../common/HorrizontalScroll"; +import { ItemCardText } from "../ItemCardText"; interface Props extends ViewProps { item?: BaseItemDto | null; loading?: boolean; + isOffline?: boolean; } export const SeasonEpisodesCarousel: React.FC = ({ item, loading, + isOffline, ...props }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); + const { downloadedFiles } = useDownload(); const scrollRef = useRef(null); @@ -41,24 +46,28 @@ export const SeasonEpisodesCarousel: React.FC = ({ isLoading, isFetching, } = useQuery({ - queryKey: ["episodes", seasonId], + queryKey: ["episodes", seasonId, isOffline], queryFn: async () => { - if (!api || !user?.Id) return []; - const response = await api.axiosInstance.get( - `${api.basePath}/Shows/${item?.Id}/Episodes`, - { - params: { - userId: user?.Id, - seasonId, - Fields: - "ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount,Overview", - }, - headers: { - Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`, - }, - }, - ); - + if (isOffline) { + return downloadedFiles + ?.filter( + (f) => f.item.Type === "Episode" && f.item.SeasonId === seasonId, + ) + .map((f) => f.item); + } + if (!api || !user?.Id || !item?.SeriesId) return []; + const response = await getTvShowsApi(api).getEpisodes({ + userId: user.Id, + seasonId: seasonId || undefined, + seriesId: item.SeriesId, + fields: [ + "ItemCounts", + "PrimaryImageAspectRatio", + "CanDelete", + "MediaSourceCount", + "Overview", + ], + }); return response.data.Items as BaseItemDto[]; }, enabled: !!api && !!user?.Id && !!seasonId, @@ -123,7 +132,7 @@ export const SeasonEpisodesCarousel: React.FC = ({ data={episodes} extraData={item} loading={loading || isLoading || isFetching} - renderItem={(_item, idx) => ( + renderItem={(_item, _idx) => ( { diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx index f6f697aa..8459fa3f 100644 --- a/components/series/SeasonPicker.tsx +++ b/components/series/SeasonPicker.tsx @@ -86,7 +86,8 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { userId: user.Id, seasonId: selectedSeasonId, enableUserData: true, - fields: ["MediaSources", "MediaStreams", "Overview"], + // Note: Including trick play is necessary to enable trick play downloads + fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"], }); if (res.data.TotalRecordCount === 0) @@ -97,6 +98,10 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { return res.data.Items; }, + select: (data) => + [...(data || [])].sort( + (a, b) => (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0), + ), enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId, }); diff --git a/components/settings/DownloadSettings.tsx b/components/settings/DownloadSettings.tsx index ca0c655b..0cbbc89d 100644 --- a/components/settings/DownloadSettings.tsx +++ b/components/settings/DownloadSettings.tsx @@ -1,32 +1,20 @@ import { Stepper } from "@/components/inputs/Stepper"; -import { useDownload } from "@/providers/DownloadProvider"; import { - DownloadMethod, type Settings, useSettings, } from "@/utils/atoms/settings"; -import { Ionicons } from "@expo/vector-icons"; -import { useQueryClient } from "@tanstack/react-query"; -import { useRouter } from "expo-router"; import React, { useMemo } from "react"; -import { Platform, Switch, TouchableOpacity } from "react-native"; -const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; import DisabledSetting from "@/components/settings/DisabledSetting"; import { useTranslation } from "react-i18next"; -import { Text } from "../common/Text"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; export default function DownloadSettings({ ...props }) { const [settings, updateSettings, pluginSettings] = useSettings(); - const { setProcesses } = useDownload(); - const router = useRouter(); - const queryClient = useQueryClient(); const { t } = useTranslation(); const allDisabled = useMemo( () => - pluginSettings?.downloadMethod?.locked === true && pluginSettings?.remuxConcurrentLimit?.locked === true && pluginSettings?.autoDownload.locked === true, [pluginSettings], @@ -37,69 +25,10 @@ export default function DownloadSettings({ ...props }) { return ( - - - - - - {settings.downloadMethod === DownloadMethod.Remux - ? t("home.settings.downloads.default") - : t("home.settings.downloads.optimized")} - - - - - - - {t("home.settings.downloads.download_method")} - - { - updateSettings({ downloadMethod: DownloadMethod.Remux }); - setProcesses([]); - }} - > - - {t("home.settings.downloads.default")} - - - { - updateSettings({ downloadMethod: DownloadMethod.Optimized }); - setProcesses([]); - queryClient.invalidateQueries({ queryKey: ["search"] }); - }} - > - - {t("home.settings.downloads.optimized")} - - - - - - - - - updateSettings({ autoDownload: value })} - /> - - - router.push("/settings/optimized-server/page")} - showArrow - title={t("home.settings.downloads.optimized_versions_server")} - /> ); diff --git a/components/settings/HomeIndex.tsx b/components/settings/HomeIndex.tsx index 4915c3b9..6efedd0c 100644 --- a/components/settings/HomeIndex.tsx +++ b/components/settings/HomeIndex.tsx @@ -1,15 +1,3 @@ -import { Button } from "@/components/Button"; -import { Loader } from "@/components/Loader"; -import { Text } from "@/components/common/Text"; -import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel"; -import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; -import { MediaListSection } from "@/components/medialists/MediaListSection"; -import { Colors } from "@/constants/Colors"; -import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; -import { useDownload } from "@/providers/DownloadProvider"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { useSettings } from "@/utils/atoms/settings"; -import { eventBus } from "@/utils/eventBus"; import { Feather, Ionicons } from "@expo/vector-icons"; import type { Api } from "@jellyfin/sdk"; import type { @@ -25,12 +13,7 @@ import { } from "@jellyfin/sdk/lib/utils/api"; import NetInfo from "@react-native-community/netinfo"; import { type QueryFunction, useQuery } from "@tanstack/react-query"; -import { - useNavigation, - usePathname, - useRouter, - useSegments, -} from "expo-router"; +import { useNavigation, useRouter, useSegments } from "expo-router"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -43,6 +26,18 @@ import { View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel"; +import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; +import { Loader } from "@/components/Loader"; +import { MediaListSection } from "@/components/medialists/MediaListSection"; +import { Colors } from "@/constants/Colors"; +import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; +import { useDownload } from "@/providers/DownloadProvider"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { eventBus } from "@/utils/eventBus"; type ScrollingCollectionListSection = { type: "ScrollingCollectionList"; @@ -71,9 +66,9 @@ export const HomeIndex = () => { const [loading, setLoading] = useState(false); const [ settings, - updateSettings, - pluginSettings, - setPluginSettings, + _updateSettings, + _pluginSettings, + _setPluginSettings, refreshStreamyfinPluginSettings, ] = useSettings(); @@ -87,6 +82,17 @@ export const HomeIndex = () => { const scrollViewRef = useRef(null); const { downloadedFiles, cleanCacheDirectory } = useDownload(); + const prevIsConnected = useRef(false); + const invalidateCache = useInvalidatePlaybackProgressCache(); + useEffect(() => { + // Only invalidate cache when transitioning from offline to online + if (isConnected && !prevIsConnected.current) { + invalidateCache(); + } + // Update the ref to the current state for the next render + prevIsConnected.current = isConnected; + }, [isConnected, invalidateCache]); + useEffect(() => { if (Platform.isTV) { navigation.setOptions({ @@ -114,7 +120,7 @@ export const HomeIndex = () => { }, [downloadedFiles, navigation, router]); useEffect(() => { - cleanCacheDirectory().catch((e) => + cleanCacheDirectory().catch((_e) => console.error("Something went wrong cleaning cache directory"), ); }, []); @@ -149,10 +155,6 @@ export const HomeIndex = () => { setIsConnected(state.isConnected); }); - // cleanCacheDirectory().catch((e) => - // console.error("Something went wrong cleaning cache directory") - // ); - return () => { unsubscribe(); }; @@ -193,8 +195,6 @@ export const HomeIndex = () => { ); }, [userViews]); - const invalidateCache = useInvalidatePlaybackProgressCache(); - const refetch = async () => { setLoading(true); await refreshStreamyfinPluginSettings(); @@ -213,209 +213,187 @@ export const HomeIndex = () => { queryKey, queryFn: async () => { if (!api) return []; - - const response = await getItemsApi(api).getItems({ - userId: user?.Id, - limit: 40, - recursive: true, - includeItemTypes, - sortBy: ["DateCreated"], - sortOrder: ["Descending"], - fields: ["PrimaryImageAspectRatio", "Path"], - parentId, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], - }); - - let items = response.data.Items || []; - - if (includeItemTypes.includes("Episode")) { - // Removes individual episodes from the list if they are part of a series - // and only keeps the series item - // Note: The 'Latest' API endpoint does not work well with combining batch episode imports - // and will either only show the series or the episodes, not both. - // This is a workaround to filter out the episodes from the list - const seriesIds = new Set( - items.filter((i) => i.Type === "Series").map((i) => i.Id), - ); - - items = items.filter( - (i) => - i.Type === "Series" || - (i.Type === "Episode" && !seriesIds.has(i.SeriesId!)), - ); - } - - if (items.length > 20) { - items = items.slice(0, 20); - } - - return items; + return ( + ( + await getUserLibraryApi(api).getLatestMedia({ + userId: user?.Id, + limit: 20, + fields: ["PrimaryImageAspectRatio", "Path"], + imageTypeLimit: 1, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes, + parentId, + }) + ).data || [] + ); }, type: "ScrollingCollectionList", }), [api, user?.Id], ); - let sections: Section[] = []; - if (!settings?.home || !settings?.home?.sections) { - sections = useMemo(() => { - if (!api || !user?.Id) return []; + const defaultSections = useMemo(() => { + if (!api || !user?.Id) return []; - const latestMediaViews = collections.map((c) => { - const includeItemTypes: BaseItemKind[] = - c.CollectionType === "tvshows" ? ["Episode", "Series"] : ["Movie"]; - const title = t("home.recently_added_in", { libraryName: c.Name }); - const queryKey = [ - "home", - `recentlyAddedIn${c.CollectionType}`, - user?.Id!, - c.Id!, - ]; - return createCollectionConfig( - title || "", - queryKey, - includeItemTypes, - c.Id, - ); - }); - - const ss: Section[] = [ - { - title: t("home.continue_watching"), - queryKey: ["home", "resumeItems"], - queryFn: async () => - ( - await getItemsApi(api).getResumeItems({ - userId: user.Id, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], - includeItemTypes: ["Movie", "Series", "Episode"], - }) - ).data.Items || [], - type: "ScrollingCollectionList", - orientation: "horizontal", - }, - { - title: t("home.next_up"), - queryKey: ["home", "nextUp-all"], - queryFn: async () => - ( - await getTvShowsApi(api).getNextUp({ - userId: user?.Id, - fields: ["MediaSourceCount"], - limit: 20, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], - enableResumable: false, - }) - ).data.Items || [], - type: "ScrollingCollectionList", - orientation: "horizontal", - }, - ...latestMediaViews, - // ...(mediaListCollections?.map( - // (ml) => - // ({ - // title: ml.Name, - // queryKey: ["home", "mediaList", ml.Id!], - // queryFn: async () => ml, - // type: "MediaListSection", - // orientation: "vertical", - // } as Section) - // ) || []), - { - title: t("home.suggested_movies"), - queryKey: ["home", "suggestedMovies", user?.Id], - queryFn: async () => - ( - await getSuggestionsApi(api).getSuggestions({ - userId: user?.Id, - limit: 10, - mediaType: ["Video"], - type: ["Movie"], - }) - ).data.Items || [], - type: "ScrollingCollectionList", - orientation: "vertical", - }, - { - title: t("home.suggested_episodes"), - queryKey: ["home", "suggestedEpisodes", user?.Id], - queryFn: async () => { - try { - const suggestions = await getSuggestions(api, user.Id); - const nextUpPromises = suggestions.map((series) => - getNextUp(api, user.Id, series.Id), - ); - const nextUpResults = await Promise.all(nextUpPromises); - - return nextUpResults.filter((item) => item !== null) || []; - } catch (error) { - console.error("Error fetching data:", error); - return []; - } - }, - type: "ScrollingCollectionList", - orientation: "horizontal", - }, + const latestMediaViews = collections.map((c) => { + const includeItemTypes: BaseItemKind[] = + c.CollectionType === "tvshows" ? ["Series"] : ["Movie"]; + const title = t("home.recently_added_in", { libraryName: c.Name }); + const queryKey = [ + "home", + `recentlyAddedIn${c.CollectionType}`, + user?.Id!, + c.Id!, ]; - return ss; - }, [api, user?.Id, collections]); - } else { - sections = useMemo(() => { - if (!api || !user?.Id) return []; - const ss: Section[] = []; + return createCollectionConfig( + title || "", + queryKey, + includeItemTypes, + c.Id, + ); + }); - for (const key in settings.home?.sections) { - // @ts-expect-error - const section = settings.home?.sections[key]; - const id = section.title || key; - ss.push({ - title: id, - queryKey: ["home", id], - queryFn: async () => { - if (section.items) { - const response = await getItemsApi(api).getItems({ - userId: user?.Id, - limit: section.items?.limit || 25, - recursive: true, - includeItemTypes: section.items?.includeItemTypes, - sortBy: section.items?.sortBy, - sortOrder: section.items?.sortOrder, - filters: section.items?.filters, - parentId: section.items?.parentId, - }); - return response.data.Items || []; - } - if (section.nextUp) { - const response = await getTvShowsApi(api).getNextUp({ - userId: user?.Id, - fields: ["MediaSourceCount"], - limit: section.nextUp?.limit || 25, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], - enableResumable: section.nextUp?.enableResumable, - enableRewatching: section.nextUp?.enableRewatching, - }); - return response.data.Items || []; - } + const ss: Section[] = [ + { + title: t("home.continue_watching"), + queryKey: ["home", "resumeItems"], + queryFn: async () => + ( + await getItemsApi(api).getResumeItems({ + userId: user.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes: ["Movie", "Series", "Episode"], + }) + ).data.Items || [], + type: "ScrollingCollectionList", + orientation: "horizontal", + }, + { + title: t("home.next_up"), + queryKey: ["home", "nextUp-all"], + queryFn: async () => + ( + await getTvShowsApi(api).getNextUp({ + userId: user?.Id, + fields: ["MediaSourceCount"], + limit: 20, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableResumable: false, + }) + ).data.Items || [], + type: "ScrollingCollectionList", + orientation: "horizontal", + }, + ...latestMediaViews, + // ...(mediaListCollections?.map( + // (ml) => + // ({ + // title: ml.Name, + // queryKey: ["home", "mediaList", ml.Id!], + // queryFn: async () => ml, + // type: "MediaListSection", + // orientation: "vertical", + // } as Section) + // ) || []), + { + title: t("home.suggested_movies"), + queryKey: ["home", "suggestedMovies", user?.Id], + queryFn: async () => + ( + await getSuggestionsApi(api).getSuggestions({ + userId: user?.Id, + limit: 10, + mediaType: ["Video"], + type: ["Movie"], + }) + ).data.Items || [], + type: "ScrollingCollectionList", + orientation: "vertical", + }, + { + title: t("home.suggested_episodes"), + queryKey: ["home", "suggestedEpisodes", user?.Id], + queryFn: async () => { + try { + const suggestions = await getSuggestions(api, user.Id); + const nextUpPromises = suggestions.map((series) => + getNextUp(api, user.Id, series.Id), + ); + const nextUpResults = await Promise.all(nextUpPromises); - if (section.latest) { - const response = await getUserLibraryApi(api).getLatestMedia({ - userId: user?.Id, - includeItemTypes: section.latest?.includeItemTypes, - limit: section.latest?.limit || 25, - isPlayed: section.latest?.isPlayed, - groupItems: section.latest?.groupItems, - }); - return response.data || []; - } + return nextUpResults.filter((item) => item !== null) || []; + } catch (error) { + console.error("Error fetching data:", error); return []; - }, - type: "ScrollingCollectionList", - orientation: section?.orientation || "vertical", - }); - } - return ss; - }, [api, user?.Id, settings.home?.sections]); - } + } + }, + type: "ScrollingCollectionList", + orientation: "horizontal", + }, + ]; + return ss; + }, [api, user?.Id, collections, createCollectionConfig, t]); + + const customSections = useMemo(() => { + if (!api || !user?.Id) return []; + const ss: Section[] = []; + + for (const key in settings.home?.sections) { + // @ts-expect-error + const section = settings.home?.sections[key]; + const id = section.title || key; + ss.push({ + title: id, + queryKey: ["home", id], + queryFn: async () => { + if (section.items) { + const response = await getItemsApi(api).getItems({ + userId: user?.Id, + limit: section.items?.limit || 25, + recursive: true, + includeItemTypes: section.items?.includeItemTypes, + sortBy: section.items?.sortBy, + sortOrder: section.items?.sortOrder, + filters: section.items?.filters, + parentId: section.items?.parentId, + }); + return response.data.Items || []; + } + if (section.nextUp) { + const response = await getTvShowsApi(api).getNextUp({ + userId: user?.Id, + fields: ["MediaSourceCount"], + limit: section.items?.limit || 25, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableResumable: section.items?.enableResumable, + enableRewatching: section.items?.enableRewatching, + }); + return response.data.Items || []; + } + + if (section.latest) { + const response = await getUserLibraryApi(api).getLatestMedia({ + userId: user?.Id, + includeItemTypes: section.latest?.includeItemTypes, + limit: section.latest?.limit || 25, + isPlayed: section.latest?.isPlayed, + groupItems: section.latest?.groupItems, + }); + return response.data || []; + } + return []; + }, + type: "ScrollingCollectionList", + orientation: section?.orientation || "vertical", + }); + } + return ss; + }, [api, user?.Id, settings.home?.sections]); + + const sections: Section[] = + !settings?.home || !settings?.home?.sections + ? defaultSections + : customSections; if (isConnected === false) { return ( diff --git a/components/settings/OptimizedServerForm.tsx b/components/settings/OptimizedServerForm.tsx deleted file mode 100644 index f4f75791..00000000 --- a/components/settings/OptimizedServerForm.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Linking, TextInput, View } from "react-native"; -import { Text } from "../common/Text"; - -interface Props { - value: string; - onChangeValue: (value: string) => void; -} - -export const OptimizedServerForm: React.FC = ({ - value, - onChangeValue, -}) => { - const handleOpenLink = () => { - Linking.openURL("https://github.com/streamyfin/optimized-versions-server"); - }; - - const { t } = useTranslation(); - - return ( - - - - {t("home.settings.downloads.url")} - onChangeValue(text)} - /> - - - - {t("home.settings.downloads.optimized_version_hint")}{" "} - - {t("home.settings.downloads.read_more_about_optimized_server")} - - - - ); -}; diff --git a/components/settings/StorageSettings.tsx b/components/settings/StorageSettings.tsx index a62c2316..13b906ea 100644 --- a/components/settings/StorageSettings.tsx +++ b/components/settings/StorageSettings.tsx @@ -1,12 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { toast } from "sonner-native"; import { Text } from "@/components/common/Text"; import { Colors } from "@/constants/Colors"; import { useHaptic } from "@/hooks/useHaptic"; import { useDownload } from "@/providers/DownloadProvider"; -import { useQuery } from "@tanstack/react-query"; -import * as FileSystem from "expo-file-system"; -import { useTranslation } from "react-i18next"; -import { View } from "react-native"; -import { toast } from "sonner-native"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; @@ -17,22 +16,15 @@ export const StorageSettings = () => { const errorHapticFeedback = useHaptic("error"); const { data: size, isLoading: appSizeLoading } = useQuery({ - queryKey: ["appSize", appSizeUsage], - queryFn: async () => { - const app = await appSizeUsage; - - const remaining = await FileSystem.getFreeDiskStorageAsync(); - const total = await FileSystem.getTotalDiskCapacityAsync(); - - return { app, remaining, total, used: (total - remaining) / total }; - }, + queryKey: ["appSize"], + queryFn: appSizeUsage, }); const onDeleteClicked = async () => { try { await deleteAllFiles(); successHapticFeedback(); - } catch (e) { + } catch (_e) { errorHapticFeedback(); toast.error(t("home.settings.toasts.error_deleting_files")); } @@ -67,10 +59,7 @@ export const StorageSettings = () => { /> diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index e02c3975..54248f85 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -1,25 +1,3 @@ -import { Loader } from "@/components/Loader"; -import { Text } from "@/components/common/Text"; -import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay"; -import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes"; -import { useCreditSkipper } from "@/hooks/useCreditSkipper"; -import { useHaptic } from "@/hooks/useHaptic"; -import { useIntroSkipper } from "@/hooks/useIntroSkipper"; -import { useTrickplay } from "@/hooks/useTrickplay"; -import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types"; -import * as ScreenOrientation from "@/packages/expo-screen-orientation"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { VideoPlayer, useSettings } from "@/utils/atoms/settings"; -import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; -import { getItemById } from "@/utils/jellyfin/user-library/getItemById"; -import { writeToLog } from "@/utils/log"; -import { - formatTimeString, - msToTicks, - secondsToMs, - ticksToMs, - ticksToSeconds, -} from "@/utils/time"; import { Ionicons, MaterialIcons } from "@expo/vector-icons"; import type { BaseItemDto, @@ -29,7 +7,7 @@ import { Image } from "expo-image"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useAtom } from "jotai"; import { debounce } from "lodash"; -import React, { +import { type Dispatch, type FC, type MutableRefObject, @@ -42,27 +20,48 @@ import React, { import { Platform, TouchableOpacity, - View, useWindowDimensions, + View, } from "react-native"; import { Slider } from "react-native-awesome-slider"; import { - type SharedValue, runOnJS, + type SharedValue, useAnimatedReaction, useSharedValue, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { Loader } from "@/components/Loader"; +import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay"; +import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes"; +import { useCreditSkipper } from "@/hooks/useCreditSkipper"; +import { useHaptic } from "@/hooks/useHaptic"; +import { useIntroSkipper } from "@/hooks/useIntroSkipper"; +import { useTrickplay } from "@/hooks/useTrickplay"; +import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; +import { getItemById } from "@/utils/jellyfin/user-library/getItemById"; +import { writeToLog } from "@/utils/log"; +import { + formatTimeString, + msToTicks, + secondsToMs, + ticksToMs, + ticksToSeconds, +} from "@/utils/time"; import AudioSlider from "./AudioSlider"; import BrightnessSlider from "./BrightnessSlider"; -import { EpisodeList } from "./EpisodeList"; -import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; -import SkipButton from "./SkipButton"; -import { VideoTouchOverlay } from "./VideoTouchOverlay"; import { ControlProvider } from "./contexts/ControlContext"; import { VideoProvider } from "./contexts/VideoContext"; import DropdownView from "./dropdown/DropdownView"; +import { EpisodeList } from "./EpisodeList"; +import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; +import SkipButton from "./SkipButton"; import { useControlsTimeout } from "./useControlsTimeout"; +import { VideoTouchOverlay } from "./VideoTouchOverlay"; interface Props { item: BaseItemDto; @@ -119,7 +118,6 @@ export const Controls: FC = ({ setSubtitleTrack, setAudioTrack, offline = false, - enableTrickplay = true, isVlc = false, }) => { const [settings, updateSettings] = useSettings(); @@ -134,13 +132,16 @@ export const Controls: FC = ({ const [showAudioSlider, setShowAudioSlider] = useState(false); const { height: screenHeight, width: screenWidth } = useWindowDimensions(); - const { previousItem, nextItem } = useAdjacentItems({ item }); + const { previousItem, nextItem } = useAdjacentItems({ + item, + isOffline: offline, + }); const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo, prefetchAllTrickplayImages, - } = useTrickplay(item, !offline && enableTrickplay); + } = useTrickplay(item); const [currentTime, setCurrentTime] = useState(0); const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY); @@ -175,19 +176,21 @@ export const Controls: FC = ({ }>(); const { showSkipButton, skipIntro } = useIntroSkipper( - offline ? undefined : item.Id, + item?.Id!, currentTime, seek, play, isVlc, + offline, ); const { showSkipCreditButton, skipCredit } = useCreditSkipper( - offline ? undefined : item.Id, + item?.Id!, currentTime, seek, play, isVlc, + offline, ); const goToItemCommon = useCallback( @@ -195,9 +198,7 @@ export const Controls: FC = ({ if (!item || !settings) { return; } - lightHapticFeedback(); - const previousIndexes = { subtitleIndex: subtitleIndex ? Number.parseInt(subtitleIndex) @@ -215,15 +216,18 @@ export const Controls: FC = ({ previousIndexes, mediaSource ?? undefined, ); - const queryParams = new URLSearchParams({ itemId: item.Id ?? "", audioIndex: defaultAudioIndex?.toString() ?? "", subtitleIndex: defaultSubtitleIndex?.toString() ?? "", mediaSourceId: newMediaSource?.Id ?? "", bitrateValue: bitrateValue?.toString(), + playbackPosition: + item.UserData?.PlaybackPositionTicks?.toString() ?? "", }).toString(); + console.log("queryParams", queryParams); + // @ts-expect-error router.replace(`player/direct-player?${queryParams}`); }, @@ -241,7 +245,10 @@ export const Controls: FC = ({ ({ isAutoPlay, resetWatchCount, - }: { isAutoPlay?: boolean; resetWatchCount?: boolean }) => { + }: { + isAutoPlay?: boolean; + resetWatchCount?: boolean; + }) => { if (!nextItem) { return; } @@ -303,10 +310,18 @@ export const Controls: FC = ({ const goToItem = useCallback( async (itemId: string) => { - const gotoItem = await getItemById(api, itemId); - if (!gotoItem) { + if (offline) { + const queryParams = new URLSearchParams({ + itemId: itemId, + playbackPosition: + item.UserData?.PlaybackPositionTicks?.toString() ?? "", + }).toString(); + // @ts-expect-error + router.replace(`player/direct-player?${queryParams}`); return; } + const gotoItem = await getItemById(api, itemId); + if (!gotoItem) return; goToItemCommon(gotoItem); }, [goToItemCommon, api], @@ -522,9 +537,6 @@ export const Controls: FC = ({ const onClose = async () => { lightHapticFeedback(); - await ScreenOrientation.lockAsync( - ScreenOrientation.OrientationLock.PORTRAIT_UP, - ); router.back(); }; @@ -563,8 +575,8 @@ export const Controls: FC = ({ pointerEvents={showControls ? "auto" : "none"} className={"flex flex-row w-full pt-2"} > - {!Platform.isTV && ( - + + {!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && ( = ({ > - - )} + )} + - {!Platform.isTV && - settings.defaultPlayer === VideoPlayer.VLC_4 && ( - - - - )} + {false && ( + + + + )} - {item?.Type === "Episode" && !offline && ( + {item?.Type === "Episode" && ( { switchOnEpisodeMode(); @@ -632,16 +643,14 @@ export const Controls: FC = ({ color='white' /> - {/* )} */} - ({}); export const EpisodeList: React.FC = ({ item, close, goToItem }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); - const insets = useSafeAreaInsets(); // Get safe area insets const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom); const scrollViewRef = useRef(null); // Reference to the HorizontalScroll const scrollToIndex = (index: number) => { scrollViewRef.current?.scrollToIndex(index, 100); }; + const { offline } = useGlobalSearchParams<{ + offline: string; + }>(); + const isOffline = offline === "true"; // Set the initial season index useEffect(() => { @@ -50,23 +57,35 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { } }, []); - const seasonIndex = seasonIndexState[item.SeriesId ?? ""]; - const [seriesItem, setSeriesItem] = useState(null); + const { downloadedFiles } = useDownload(); - // This effect fetches the series item data/ - useEffect(() => { - if (item.SeriesId) { - getUserItemData({ api, userId: user?.Id, itemId: item.SeriesId }).then( - (res) => { - setSeriesItem(res); - }, - ); - } - }, [item.SeriesId]); + const seasonIndex = seasonIndexState[item.SeriesId ?? ""]; + const { data: seriesItem } = useItemQuery(item.SeriesId!, isOffline); const { data: seasons } = useQuery({ queryKey: ["seasons", item.SeriesId], queryFn: async () => { + if (isOffline) { + if (!item.SeriesId) return []; + const seriesEpisodes = downloadedFiles?.filter( + (f: DownloadedItem) => f.item.SeriesId === item.SeriesId, + ); + const seasonNumbers = [ + ...new Set( + seriesEpisodes + ?.map((f: DownloadedItem) => f.item.ParentIndexNumber) + .filter(Boolean), + ), + ]; + // Create fake season objects + return seasonNumbers.map((seasonNumber) => ({ + Id: seasonNumber, + IndexNumber: seasonNumber, + Name: `Season ${seasonNumber}`, + SeriesId: item.SeriesId, + })); + } + if (!api || !user?.Id || !item.SeriesId) return []; const response = await api.axiosInstance.get( `${api.basePath}/Shows/${item.SeriesId}/Seasons`, @@ -93,9 +112,19 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { [seasons, seasonIndex], ); - const { data: episodes, isFetching } = useQuery({ + const { data: episodes } = useQuery({ queryKey: ["episodes", item.SeriesId, selectedSeasonId], queryFn: async () => { + if (isOffline) { + if (!item.SeriesId) return []; + return downloadedFiles + ?.filter( + (f: DownloadedItem) => + f.item.SeriesId === item.SeriesId && + f.item.ParentIndexNumber === seasonIndex, + ) + .map((f: DownloadedItem) => f.item); + } if (!api || !user?.Id || !item.Id || !selectedSeasonId) return []; const res = await getTvShowsApi(api).getEpisodes({ seriesId: item.SeriesId || "", @@ -112,7 +141,7 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { useEffect(() => { if (item?.Type === "Episode" && item.Id) { - const index = episodes?.findIndex((ep) => ep.Id === item.Id); + const index = episodes?.findIndex((ep: BaseItemDto) => ep.Id === item.Id); if (index !== undefined && index !== -1) { setTimeout(() => { scrollToIndex(index); @@ -155,7 +184,7 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { } return ( - = ({ item, close, goToItem }) => { width: "100%", }} > - <> - - {seriesItem && ( - { - setSeasonIndexState((prev) => ({ - ...prev, - [item.SeriesId ?? ""]: season.IndexNumber, - })); - }} - /> - )} - { - close(); + + {seriesItem && ( + { + setSeasonIndexState((prev) => ({ + ...prev, + [item.SeriesId ?? ""]: season.IndexNumber, + })); }} - className='aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2' - > - - - + /> + )} + + + + - ( - ( + + { + goToItem(_item.Id); + }} > - { - goToItem(_item.Id); + + + + - - - - - {_item.Name} - - - {`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`} - - - {runtimeTicksToSeconds(_item.RunTimeTicks)} - - - - - - - {_item.Overview} + {_item.Name} + + + {`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`} + + + {runtimeTicksToSeconds(_item.RunTimeTicks)} - )} - keyExtractor={(e: BaseItemDto) => e.Id ?? ""} - estimatedItemSize={200} - showsHorizontalScrollIndicator={false} - /> - - + + {_item.Overview} + + + )} + keyExtractor={(e: BaseItemDto) => e.Id ?? ""} + estimatedItemSize={200} + showsHorizontalScrollIndicator={false} + /> + ); }; diff --git a/components/video-player/controls/contexts/VideoContext.tsx b/components/video-player/controls/contexts/VideoContext.tsx index a7417c79..ae194669 100644 --- a/components/video-player/controls/contexts/VideoContext.tsx +++ b/components/video-player/controls/contexts/VideoContext.tsx @@ -1,15 +1,16 @@ -import type { TrackInfo } from "@/modules/VlcPlayer.types"; -import { VideoPlayer, useSettings } from "@/utils/atoms/settings"; +import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client"; import { router, useLocalSearchParams } from "expo-router"; import type React from "react"; import { - type ReactNode, createContext, + type ReactNode, useContext, useEffect, useMemo, useState, } from "react"; +import type { TrackInfo } from "@/modules/VlcPlayer.types"; +import { useSettings } from "@/utils/atoms/settings"; import type { Track } from "../types"; import { useControlContext } from "./ControlContext"; @@ -48,7 +49,7 @@ export const VideoProvider: React.FC = ({ }) => { const [audioTracks, setAudioTracks] = useState(null); const [subtitleTracks, setSubtitleTracks] = useState(null); - const [settings] = useSettings(); + const [_settings] = useSettings(); const ControlContext = useControlContext(); const isVideoLoaded = ControlContext?.isVideoLoaded; @@ -57,22 +58,27 @@ export const VideoProvider: React.FC = ({ const allSubs = mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || []; - const { itemId, audioIndex, bitrateValue, subtitleIndex } = + const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } = useLocalSearchParams<{ itemId: string; audioIndex: string; subtitleIndex: string; mediaSourceId: string; bitrateValue: string; + playbackPosition: string; }>(); - const onTextBasedSubtitle = useMemo( - () => + const onTextBasedSubtitle = useMemo(() => { + return ( allSubs.find( - (s) => s.Index?.toString() === subtitleIndex && s.IsTextSubtitleStream, - ) || subtitleIndex === "-1", - [allSubs, subtitleIndex], - ); + (s) => + s.Index?.toString() === subtitleIndex && + (s.DeliveryMethod === SubtitleDeliveryMethod.Embed || + s.DeliveryMethod === SubtitleDeliveryMethod.Hls || + s.DeliveryMethod === SubtitleDeliveryMethod.External), + ) || subtitleIndex === "-1" + ); + }, [allSubs, subtitleIndex]); const setPlayerParams = ({ chosenAudioIndex = audioIndex, @@ -88,6 +94,7 @@ export const VideoProvider: React.FC = ({ subtitleIndex: chosenSubtitleIndex, mediaSourceId: mediaSource?.Id ?? "", bitrateValue: bitrateValue, + playbackPosition: playbackPosition, }).toString(); //@ts-ignore @@ -126,30 +133,32 @@ export const VideoProvider: React.FC = ({ useEffect(() => { const fetchTracks = async () => { if (getSubtitleTracks) { - const subtitleData = await getSubtitleTracks(); + let subtitleData = await getSubtitleTracks(); + // Only FOR VLC 3, If we're transcoding, we need to reverse the subtitle data, because VLC reverses the HLS subtitles. + if ( + mediaSource?.TranscodingUrl && + subtitleData && + subtitleData.length > 1 + ) { + subtitleData = [subtitleData[0], ...subtitleData.slice(1).reverse()]; + } - // Step 1: Move external subs to the end, because VLC puts external subs at the end - const sortedSubs = allSubs.sort( - (a, b) => Number(a.IsExternal) - Number(b.IsExternal), - ); - - // Step 2: Apply VLC indexing logic - let textSubIndex = settings.defaultPlayer === VideoPlayer.VLC_4 ? 0 : 1; - const processedSubs: Track[] = sortedSubs?.map((sub) => { - // Always increment for non-transcoding subtitles - // Only increment for text-based subtitles when transcoding + let embedSubIndex = 1; + const processedSubs: Track[] = allSubs?.map((sub) => { + /** A boolean value determining if we should increment the embedSubIndex, currently only Embed and Hls subtitles are automatically added into VLC Player */ const shouldIncrement = - !mediaSource?.TranscodingUrl || sub.IsTextSubtitleStream; - const vlcIndex = subtitleData?.at(textSubIndex)?.index ?? -1; - const finalIndex = shouldIncrement ? vlcIndex : (sub.Index ?? -1); - - if (shouldIncrement) textSubIndex++; + sub.DeliveryMethod === SubtitleDeliveryMethod.Embed || + sub.DeliveryMethod === SubtitleDeliveryMethod.Hls || + sub.DeliveryMethod === SubtitleDeliveryMethod.External; + /** The index of subtitle inside VLC Player Itself */ + const vlcIndex = subtitleData?.at(embedSubIndex)?.index ?? -1; + if (shouldIncrement) embedSubIndex++; return { name: sub.DisplayTitle || "Undefined Subtitle", index: sub.Index ?? -1, setTrack: () => shouldIncrement - ? setTrackParams("subtitle", finalIndex, sub.Index ?? -1) + ? setTrackParams("subtitle", vlcIndex, sub.Index ?? -1) : setPlayerParams({ chosenSubtitleIndex: sub.Index?.toString(), }), diff --git a/components/video-player/controls/dropdown/DropdownView.tsx b/components/video-player/controls/dropdown/DropdownView.tsx index 3168e942..ac7501c7 100644 --- a/components/video-player/controls/dropdown/DropdownView.tsx +++ b/components/video-player/controls/dropdown/DropdownView.tsx @@ -1,9 +1,11 @@ import { Ionicons } from "@expo/vector-icons"; -import React, { useCallback } from "react"; +import { useCallback } from "react"; import { Platform, TouchableOpacity } from "react-native"; + const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; -import { BITRATES } from "@/components/BitrateSelector"; + import { useLocalSearchParams, useRouter } from "expo-router"; +import { BITRATES } from "@/components/BitrateSelector"; import { useControlContext } from "../contexts/ControlContext"; import { useVideoContext } from "../contexts/VideoContext"; @@ -17,13 +19,18 @@ const DropdownView = () => { ]; const router = useRouter(); - const { subtitleIndex, audioIndex, bitrateValue } = useLocalSearchParams<{ - itemId: string; - audioIndex: string; - subtitleIndex: string; - mediaSourceId: string; - bitrateValue: string; - }>(); + const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } = + useLocalSearchParams<{ + itemId: string; + audioIndex: string; + subtitleIndex: string; + mediaSourceId: string; + bitrateValue: string; + playbackPosition: string; + offline: string; + }>(); + + const isOffline = offline === "true"; const changeBitrate = useCallback( (bitrate: string) => { @@ -33,11 +40,12 @@ const DropdownView = () => { subtitleIndex: subtitleIndex.toString() ?? "", mediaSourceId: mediaSource?.Id ?? "", bitrateValue: bitrate.toString(), + playbackPosition: playbackPosition, }).toString(); // @ts-expect-error router.replace(`player/direct-player?${queryParams}`); }, - [item, mediaSource, subtitleIndex, audioIndex], + [item, mediaSource, subtitleIndex, audioIndex, playbackPosition], ); return ( @@ -56,32 +64,34 @@ const DropdownView = () => { collisionPadding={8} sideOffset={8} > - - - Quality - - - {BITRATES?.map((bitrate, idx: number) => ( - - changeBitrate(bitrate.value?.toString() ?? "") - } - > - - {bitrate.key} - - - ))} - - + {!isOffline && ( + + + Quality + + + {BITRATES?.map((bitrate, idx: number) => ( + + changeBitrate(bitrate.value?.toString() ?? "") + } + > + + {bitrate.key} + + + ))} + + + )} Subtitle diff --git a/eas.json b/eas.json index f0d75187..a51b7f46 100644 --- a/eas.json +++ b/eas.json @@ -47,14 +47,14 @@ }, "production": { "environment": "production", - "channel": "0.28.0", + "channel": "0.28.1", "android": { "image": "latest" } }, "production-apk": { "environment": "production", - "channel": "0.28.0", + "channel": "0.28.1", "android": { "buildType": "apk", "image": "latest" @@ -62,7 +62,7 @@ }, "production-apk-tv": { "environment": "production", - "channel": "0.28.0", + "channel": "0.28.1", "android": { "buildType": "apk", "image": "latest" diff --git a/hooks/useAdjacentEpisodes.ts b/hooks/useAdjacentEpisodes.ts index 4c5a6e2b..0bf3f8fa 100644 --- a/hooks/useAdjacentEpisodes.ts +++ b/hooks/useAdjacentEpisodes.ts @@ -1,21 +1,63 @@ -import { apiAtom } from "@/providers/JellyfinProvider"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { useMemo } from "react"; +import { useDownload } from "@/providers/DownloadProvider"; +import { apiAtom } from "@/providers/JellyfinProvider"; interface AdjacentEpisodesProps { item?: BaseItemDto | null; + isOffline?: boolean; } -export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => { +export const useAdjacentItems = ({ + item, + isOffline = false, +}: AdjacentEpisodesProps) => { const api = useAtomValue(apiAtom); + const { downloadedFiles } = useDownload(); const { data: adjacentItems } = useQuery({ - queryKey: ["adjacentItems", item?.Id, item?.SeriesId], + queryKey: ["adjacentItems", item?.Id, item?.SeriesId, isOffline], queryFn: async (): Promise => { - if (!api || !item || !item.SeriesId) { + if (!item || !item.SeriesId) { + return null; + } + + if (isOffline) { + if (!downloadedFiles) return null; + const seriesEpisodes = downloadedFiles + .filter((f) => f.item.SeriesId === item.SeriesId) + .map((f) => f.item); + + seriesEpisodes.sort((a, b) => { + if (a.ParentIndexNumber !== b.ParentIndexNumber) { + return (a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0); + } + return (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0); + }); + + const currentIndex = seriesEpisodes.findIndex( + (ep) => ep.Id === item.Id, + ); + + if (currentIndex === -1) { + return null; + } + + const result: BaseItemDto[] = []; + if (currentIndex > 0) { + result.push(seriesEpisodes[currentIndex - 1]); + } + result.push(seriesEpisodes[currentIndex]); + if (currentIndex < seriesEpisodes.length - 1) { + result.push(seriesEpisodes[currentIndex + 1]); + } + return result; + } + + if (!api) { return null; } @@ -29,7 +71,7 @@ export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => { return res.data.Items || null; }, enabled: - !!api && + (isOffline || !!api) && !!item?.Id && !!item?.SeriesId && (item?.Type === "Episode" || item?.Type === "Audio"), diff --git a/hooks/useCreditSkipper.ts b/hooks/useCreditSkipper.ts index 0317f66b..ddbd2713 100644 --- a/hooks/useCreditSkipper.ts +++ b/hooks/useCreditSkipper.ts @@ -1,33 +1,16 @@ -import { apiAtom } from "@/providers/JellyfinProvider"; -import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; -import { writeToLog } from "@/utils/log"; -import { msToSeconds, secondsToMs } from "@/utils/time"; -import { useQuery } from "@tanstack/react-query"; -import { useAtom } from "jotai"; import { useCallback, useEffect, useState } from "react"; +import { useSegments } from "@/utils/segments"; +import { msToSeconds, secondsToMs } from "@/utils/time"; import { useHaptic } from "./useHaptic"; -interface CreditTimestamps { - Introduction: { - Start: number; - End: number; - Valid: boolean; - }; - Credits: { - Start: number; - End: number; - Valid: boolean; - }; -} - export const useCreditSkipper = ( - itemId: string | undefined, + itemId: string, currentTime: number, seek: (time: number) => void, play: () => void, isVlc = false, + isOffline = false, ) => { - const [api] = useAtom(apiAtom); const [showSkipCreditButton, setShowSkipCreditButton] = useState(false); const lightHapticFeedback = useHaptic("light"); @@ -43,50 +26,28 @@ export const useCreditSkipper = ( seek(seconds); }; - const { data: creditTimestamps } = useQuery({ - queryKey: ["creditTimestamps", itemId], - queryFn: async () => { - if (!itemId) { - return null; - } - - const res = await api?.axiosInstance.get( - `${api.basePath}/Episode/${itemId}/Timestamps`, - { - headers: getAuthHeaders(api), - }, - ); - - if (res?.status !== 200) { - return null; - } - - return res?.data; - }, - enabled: !!itemId, - retry: false, - }); + const { data: segments } = useSegments(itemId, isOffline); + const creditTimestamps = segments?.creditSegments?.[0]; useEffect(() => { if (creditTimestamps) { setShowSkipCreditButton( - currentTime > creditTimestamps.Credits.Start && - currentTime < creditTimestamps.Credits.End, + currentTime > creditTimestamps.startTime && + currentTime < creditTimestamps.endTime, ); } }, [creditTimestamps, currentTime]); const skipCredit = useCallback(() => { if (!creditTimestamps) return; - console.log(`Skipping credits to ${creditTimestamps.Credits.End}`); try { lightHapticFeedback(); - wrappedSeek(creditTimestamps.Credits.End); + wrappedSeek(creditTimestamps.endTime); setTimeout(() => { play(); }, 200); } catch (error) { - writeToLog("ERROR", "Error skipping intro", error); + console.error("Error skipping credit", error); } }, [creditTimestamps]); diff --git a/hooks/useDefaultPlaySettings.ts b/hooks/useDefaultPlaySettings.ts index 0991def0..c49aff1f 100644 --- a/hooks/useDefaultPlaySettings.ts +++ b/hooks/useDefaultPlaySettings.ts @@ -1,10 +1,7 @@ -import { BITRATES, Bitrate } from "@/components/BitrateSelector"; -import type { Settings } from "@/utils/atoms/settings"; -import { - type BaseItemDto, - MediaSourceInfo, -} from "@jellyfin/sdk/lib/generated-client"; +import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { useMemo } from "react"; +import { BITRATES } from "@/components/BitrateSelector"; +import type { Settings } from "@/utils/atoms/settings"; // Used only for initial play settings. const useDefaultPlaySettings = ( @@ -33,10 +30,10 @@ const useDefaultPlaySettings = ( return { defaultAudioIndex: - preferedAudioIndex || defaultAudioIndex || firstAudioIndex || undefined, - defaultSubtitleIndex: mediaSource?.DefaultSubtitleStreamIndex || -1, - defaultMediaSource: mediaSource || undefined, - defaultBitrate: bitrate || undefined, + preferedAudioIndex ?? defaultAudioIndex ?? firstAudioIndex ?? undefined, + defaultSubtitleIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1, + defaultMediaSource: mediaSource ?? undefined, + defaultBitrate: bitrate ?? undefined, }; }, [ item.MediaSources, diff --git a/hooks/useDownloadedFileOpener.ts b/hooks/useDownloadedFileOpener.ts index 398bf448..732ae36c 100644 --- a/hooks/useDownloadedFileOpener.ts +++ b/hooks/useDownloadedFileOpener.ts @@ -1,31 +1,8 @@ -import { usePlaySettings } from "@/providers/PlaySettingsProvider"; -import { writeToLog } from "@/utils/log"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import * as FileSystem from "expo-file-system"; import { useRouter } from "expo-router"; import { useCallback } from "react"; - -export const getDownloadedFileUrl = async (itemId: string): Promise => { - const directory = FileSystem.documentDirectory; - - if (!directory) { - throw new Error("Document directory is not available"); - } - - if (!itemId) { - throw new Error("Item ID is not available"); - } - - const files = await FileSystem.readDirectoryAsync(directory); - const path = itemId!; - const matchingFile = files.find((file) => file.startsWith(path)); - - if (!matchingFile) { - throw new Error(`No file found for item ${path}`); - } - - return `${directory}${matchingFile}`; -}; +import { usePlaySettings } from "@/providers/PlaySettingsProvider"; +import { writeToLog } from "@/utils/log"; export const useDownloadedFileOpener = () => { const router = useRouter(); @@ -33,9 +10,19 @@ export const useDownloadedFileOpener = () => { const openFile = useCallback( async (item: BaseItemDto) => { + if (!item.Id) { + writeToLog("ERROR", "Attempted to open a file without an ID."); + console.error("Attempted to open a file without an ID."); + return; + } + const queryParams = new URLSearchParams({ + itemId: item.Id, + offline: "true", + playbackPosition: + item.UserData?.PlaybackPositionTicks?.toString() ?? "0", + }); try { - // @ts-expect-error - router.push(`/player/direct-player?offline=true&itemId=${item.Id}`); + router.push(`/player/direct-player?${queryParams.toString()}`); } catch (error) { writeToLog("ERROR", "Error opening file", error); console.error("Error opening file:", error); diff --git a/hooks/useIntroSkipper.ts b/hooks/useIntroSkipper.ts index ab38148c..b9275f51 100644 --- a/hooks/useIntroSkipper.ts +++ b/hooks/useIntroSkipper.ts @@ -1,34 +1,21 @@ -import { apiAtom } from "@/providers/JellyfinProvider"; -import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; -import { writeToLog } from "@/utils/log"; -import { msToSeconds, secondsToMs } from "@/utils/time"; -import { useQuery } from "@tanstack/react-query"; -import { useAtom } from "jotai"; import { useCallback, useEffect, useState } from "react"; +import { useSegments } from "@/utils/segments"; +import { msToSeconds, secondsToMs } from "@/utils/time"; import { useHaptic } from "./useHaptic"; -interface IntroTimestamps { - EpisodeId: string; - HideSkipPromptAt: number; - IntroEnd: number; - IntroStart: number; - ShowSkipPromptAt: number; - Valid: boolean; -} - /** * Custom hook to handle skipping intros in a media player. * * @param {number} currentTime - The current playback time in seconds. */ export const useIntroSkipper = ( - itemId: string | undefined, + itemId: string, currentTime: number, seek: (ticks: number) => void, play: () => void, isVlc = false, + isOffline = false, ) => { - const [api] = useAtom(apiAtom); const [showSkipButton, setShowSkipButton] = useState(false); if (isVlc) { currentTime = msToSeconds(currentTime); @@ -43,35 +30,14 @@ export const useIntroSkipper = ( seek(seconds); }; - const { data: introTimestamps } = useQuery({ - queryKey: ["introTimestamps", itemId], - queryFn: async () => { - if (!itemId) { - return null; - } - - const res = await api?.axiosInstance.get( - `${api.basePath}/Episode/${itemId}/IntroTimestamps`, - { - headers: getAuthHeaders(api), - }, - ); - - if (res?.status !== 200) { - return null; - } - - return res?.data; - }, - enabled: !!itemId, - retry: false, - }); + const { data: segments } = useSegments(itemId, isOffline); + const introTimestamps = segments?.introSegments?.[0]; useEffect(() => { if (introTimestamps) { setShowSkipButton( - currentTime > introTimestamps.ShowSkipPromptAt && - currentTime < introTimestamps.HideSkipPromptAt, + currentTime > introTimestamps.startTime && + currentTime < introTimestamps.endTime, ); } }, [introTimestamps, currentTime]); @@ -80,12 +46,12 @@ export const useIntroSkipper = ( if (!introTimestamps) return; try { lightHapticFeedback(); - wrappedSeek(introTimestamps.IntroEnd); + wrappedSeek(introTimestamps.endTime); setTimeout(() => { play(); }, 200); } catch (error) { - writeToLog("ERROR", "Error skipping intro", error); + console.error("Error skipping intro", error); } }, [introTimestamps]); diff --git a/hooks/useItemQuery.ts b/hooks/useItemQuery.ts new file mode 100644 index 00000000..9448b9b5 --- /dev/null +++ b/hooks/useItemQuery.ts @@ -0,0 +1,30 @@ +import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { useAtom } from "jotai"; +import { useDownload } from "@/providers/DownloadProvider"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; + +export const useItemQuery = (itemId: string, isOffline: boolean) => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const { downloadedFiles } = useDownload(); + + return useQuery({ + queryKey: ["item", itemId], + queryFn: async () => { + if (isOffline) { + const downloadedItem = downloadedFiles?.find((item) => item.item.Id === itemId); + if (downloadedItem) return downloadedItem.item; + return null; + } + if (!api || !user || !itemId) return null; + const res = await getUserLibraryApi(api).getItem({ itemId: itemId, userId: user?.Id }); + return res.data; + }, + staleTime: 0, + refetchOnMount: true, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + networkMode: "always", + }); +}; diff --git a/hooks/useMarkAsPlayed.ts b/hooks/useMarkAsPlayed.ts index 34d1d545..fc19c2dc 100644 --- a/hooks/useMarkAsPlayed.ts +++ b/hooks/useMarkAsPlayed.ts @@ -1,102 +1,39 @@ -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed"; -import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { useQueryClient } from "@tanstack/react-query"; -import { useAtom } from "jotai"; +import { QueryKey, useQueryClient } from "@tanstack/react-query"; import { useHaptic } from "./useHaptic"; +import { usePlaybackManager } from "./usePlaybackManager"; +import { useInvalidatePlaybackProgressCache } from "./useRevalidatePlaybackProgressCache"; export const useMarkAsPlayed = (items: BaseItemDto[]) => { - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); const queryClient = useQueryClient(); const lightHapticFeedback = useHaptic("light"); - - const invalidateQueries = () => { - const queriesToInvalidate = [ - ["resumeItems"], - ["continueWatching"], - ["nextUp-all"], - ["nextUp"], - ["episodes"], - ["seasons"], - ["home"], - ]; - + const { markItemPlayed, markItemUnplayed } = usePlaybackManager(); + const invalidatePlaybackProgressCache = useInvalidatePlaybackProgressCache(); + const invalidateQueries = async () => { + const queriesToInvalidate: QueryKey[] = []; items.forEach((item) => { if (!item.Id) return; queriesToInvalidate.push(["item", item.Id]); }); - - queriesToInvalidate.forEach((queryKey) => { - queryClient.invalidateQueries({ queryKey }); - }); + await Promise.all( + queriesToInvalidate.map((queryKey) => + queryClient.invalidateQueries({ queryKey }), + ), + ); }; - const markAsPlayedStatus = async (played: boolean) => { + const toggle = async (played: boolean) => { lightHapticFeedback(); - - items.forEach((item) => { - // Optimistic update - queryClient.setQueryData( - ["item", item.Id], - (oldData: BaseItemDto | undefined) => { - if (oldData) { - return { - ...oldData, - UserData: { - ...oldData.UserData, - Played: played, - }, - }; - } - return oldData; - }, - ); - }); - - try { - // Process all items - await Promise.all( - items.map((item) => - played - ? markAsPlayed({ api, item, userId: user?.Id }) - : markAsNotPlayed({ api, itemId: item?.Id, userId: user?.Id }), - ), - ); - - // Bulk invalidate - queryClient.invalidateQueries({ - queryKey: [ - "resumeItems", - "continueWatching", - "nextUp-all", - "nextUp", - "episodes", - "seasons", - "home", - ...items.map((item) => ["item", item.Id]), - ].flat(), - }); - } catch (error) { - // Revert all optimistic updates on any failure - items.forEach((item) => { - queryClient.setQueryData( - ["item", item.Id], - (oldData: BaseItemDto | undefined) => - oldData - ? { - ...oldData, - UserData: { ...oldData.UserData, Played: played }, - } - : oldData, - ); - }); - console.error("Error updating played status:", error); - } - + // Process all items + await Promise.all( + items.map((item) => { + if (!item.Id) return Promise.resolve(); + return played ? markItemPlayed(item.Id) : markItemUnplayed(item.Id); + }), + ); + invalidatePlaybackProgressCache(); invalidateQueries(); }; - return markAsPlayedStatus; + return toggle; }; diff --git a/hooks/usePlaybackManager.ts b/hooks/usePlaybackManager.ts new file mode 100644 index 00000000..c51699c4 --- /dev/null +++ b/hooks/usePlaybackManager.ts @@ -0,0 +1,213 @@ +import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api"; +import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api/user-library-api"; +import { useNetInfo } from "@react-native-community/netinfo"; +import { useAtomValue } from "jotai"; +import { useDownload } from "@/providers/DownloadProvider"; +import { DownloadedItem } from "@/providers/Downloads/types"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; + +/** + * A hook to manage playback state, abstracting away the complexities of + * online/offline and local/remote state management. + * + * This provides a simple facade for player components to report playback + * without needing to know the underlying details of data syncing. + */ +export const usePlaybackManager = () => { + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const netInfo = useNetInfo(); + const { getDownloadedItemById, updateDownloadedItem } = useDownload(); + + /** Whether the device is online. actually it's connected to the internet. */ + const isOnline = netInfo.isConnected; + + /** + * Fetches the latest state of an item from the server and updates the local + * downloaded version to match. This ensures the local item has the + * canonical state from the server. + */ + const _syncRemoteToLocal = async (localItem: DownloadedItem) => { + if (!isOnline || !api || !user) return; + + try { + const remoteItem = ( + await getUserLibraryApi(api).getItem({ + itemId: localItem.item.Id!, + userId: user.Id, + }) + ).data; + if (remoteItem) { + updateDownloadedItem(localItem.item.Id!, { + ...localItem, + item: { + ...localItem.item, + UserData: { ...remoteItem.UserData }, + }, + }); + } + } catch (error) { + console.error("Failed to sync remote item state to local", error); + } + }; + + /** + * Reports playback progress. + * + * - If offline and the item is downloaded, updates are saved locally. + * - If online and the item is downloaded, it updates locally and syncs with the server. + * - If online and streaming, it reports directly to the server. + * + * @param itemId The ID of the item. + * @param positionTicks The current playback position in ticks. + */ + const reportPlaybackProgress = async ( + itemId: string, + positionTicks: number, + metadata?: { + AudioStreamIndex: number; + SubtitleStreamIndex: number; + }, + ) => { + const localItem = getDownloadedItemById(itemId); + + // Handle local state update for downloaded items + if (localItem) { + const isItemConsideredPlayed = + (localItem.item.UserData?.PlayedPercentage ?? 0) > 90; + updateDownloadedItem(itemId, { + ...localItem, + item: { + ...localItem.item, + UserData: { + ...localItem.item.UserData, + PlaybackPositionTicks: isItemConsideredPlayed ? 0 : positionTicks, + Played: isItemConsideredPlayed, + LastPlayedDate: new Date().toISOString(), + PlayedPercentage: isItemConsideredPlayed + ? 0 + : (positionTicks / localItem.item.RunTimeTicks!) * 100, + }, + }, + }); + } + + // Handle remote state update if online + if (isOnline && api) { + try { + await getPlaystateApi(api).reportPlaybackProgress({ + playbackProgressInfo: { + ItemId: itemId, + PositionTicks: positionTicks, + ...(metadata && { AudioStreamIndex: metadata.AudioStreamIndex }), + ...(metadata && { + SubtitleStreamIndex: metadata.SubtitleStreamIndex, + }), + }, + }); + } catch (error) { + console.error("Failed to report playback progress on server", error); + } + // If it was a downloaded item, re-sync with the server for the latest state. + // This is crucial because the server might have marked the item as "Played" + // based on its own rules (e.g., >95% progress). + if (localItem) { + await _syncRemoteToLocal(localItem); + } + } + }; + + /** + * Marks an item as played. + * + * - If offline and downloaded, it marks as played locally. + * - If online, it marks as played on the server and syncs the state back to the local item if it exists. + * + * @param itemId The ID of the item. + */ + const markItemPlayed = async (itemId: string) => { + const localItem = getDownloadedItemById(itemId); + + // Handle local state update for downloaded items + if (localItem) { + updateDownloadedItem(itemId, { + ...localItem, + item: { + ...localItem.item, + UserData: { + ...localItem.item.UserData, + Played: true, + PlaybackPositionTicks: 0, + PlayedPercentage: 0, + LastPlayedDate: new Date().toISOString(), + }, + }, + }); + } + + // Handle remote state update if online + if (isOnline && api && user) { + try { + await getPlaystateApi(api).markPlayedItem({ + itemId, + userId: user.Id, + }); + + // If it was a downloaded item, re-sync with server for the latest state + if (localItem) { + await _syncRemoteToLocal(localItem); + } + } catch (error) { + console.error("Failed to mark item as played on server", error); + } + } + }; + + /** + * Marks an item as unplayed. + * + * - If offline and downloaded, it marks as unplayed locally. + * - If online, it marks as unplayed on the server and syncs the state back to the local item if it exists. + * + * @param itemId The ID of the item. + */ + const markItemUnplayed = async (itemId: string) => { + const localItem = getDownloadedItemById(itemId); + + // Handle local state update for downloaded items + if (localItem) { + updateDownloadedItem(itemId, { + ...localItem, + item: { + ...localItem.item, + UserData: { + ...localItem.item.UserData, + Played: false, + PlaybackPositionTicks: 0, + PlayedPercentage: 0, + LastPlayedDate: new Date().toISOString(), // Keep track of when it was marked unplayed + }, + }, + }); + } + + // Handle remote state update if online + if (isOnline && api && user) { + try { + await getPlaystateApi(api).markUnplayedItem({ + itemId, + userId: user.Id, + }); + + // If it was a downloaded item, re-sync with server for the latest state + if (localItem) { + await _syncRemoteToLocal(localItem); + } + } catch (error) { + console.error("Failed to mark item as unplayed on server", error); + } + } + }; + + return { reportPlaybackProgress, markItemPlayed, markItemUnplayed }; +}; diff --git a/hooks/useRevalidatePlaybackProgressCache.ts b/hooks/useRevalidatePlaybackProgressCache.ts index 9cda914d..d215bcc6 100644 --- a/hooks/useRevalidatePlaybackProgressCache.ts +++ b/hooks/useRevalidatePlaybackProgressCache.ts @@ -1,10 +1,14 @@ import { useQueryClient } from "@tanstack/react-query"; +import { useDownload } from "@/providers/DownloadProvider"; +import { useTwoWaySync } from "./useTwoWaySync"; /** * useRevalidatePlaybackProgressCache invalidates queries related to playback progress. */ export function useInvalidatePlaybackProgressCache() { const queryClient = useQueryClient(); + const { downloadedFiles } = useDownload(); + const { syncPlaybackState } = useTwoWaySync(); const revalidate = async () => { // List of all the queries to invalidate @@ -17,11 +21,33 @@ export function useInvalidatePlaybackProgressCache() { ["episodes"], ["seasons"], ["home"], + ["downloadedItems"], ]; - // Invalidate each query - for (const queryKey of queriesToInvalidate) { - await queryClient.invalidateQueries({ queryKey }); + // We Invalidate all the queries to the latest server versions + await Promise.all( + queriesToInvalidate.map((queryKey) => + queryClient.invalidateQueries({ queryKey }), + ), + ); + + // Sync playback state for downloaded items + if (downloadedFiles) { + // We sync the playback state for the downloaded items + const syncResults = await Promise.all( + downloadedFiles.map((downloadedItem) => + syncPlaybackState(downloadedItem.item.Id!), + ), + ); + // We invalidate the queries again in case we have updated a server's playback progress. + const shouldInvalidate = syncResults.some((result) => result); + + console.log("shouldInvalidate", shouldInvalidate); + if (shouldInvalidate) { + queriesToInvalidate.map((queryKey) => + queryClient.invalidateQueries({ queryKey }), + ); + } } }; diff --git a/hooks/useTrickplay.ts b/hooks/useTrickplay.ts index e221b49e..24c6929e 100644 --- a/hooks/useTrickplay.ts +++ b/hooks/useTrickplay.ts @@ -1,11 +1,69 @@ -import { apiAtom } from "@/providers/JellyfinProvider"; -import { ticksToMs } from "@/utils/time"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { Image } from "expo-image"; -import { useAtom } from "jotai"; import { useCallback, useMemo, useRef, useState } from "react"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { store } from "@/utils/store"; +import { ticksToMs } from "@/utils/time"; +import { useDownload } from "@/providers/DownloadProvider"; +import { useGlobalSearchParams } from "expo-router"; -interface TrickplayData { +interface TrickplayUrl { + x: number; + y: number; + url: string; +} + +/** Hook to handle trickplay logic for a given item. */ +export const useTrickplay = (item: BaseItemDto) => { + const [trickPlayUrl, setTrickPlayUrl] = useState(null); + const { getDownloadedItemById } = useDownload(); + const lastCalculationTime = useRef(0); + const throttleDelay = 200; + const isOffline = useGlobalSearchParams().offline === "true"; + const trickplayInfo = useMemo(() => getTrickplayInfo(item), [item]); + + /** Generates the trickplay URL for the given item and sheet index. + * We change between offline and online trickplay URLs depending on the state of the app. */ + const getTrickplayUrl = useCallback((item: BaseItemDto, sheetIndex: number) => { + // If we are offline, we can use the downloaded item's trickplay data path + const downloadedItem = getDownloadedItemById(item.Id!); + if (isOffline && downloadedItem?.trickPlayData?.path) { + return `${downloadedItem.trickPlayData.path}${sheetIndex}.jpg`; + } + return generateTrickplayUrl(item, sheetIndex); + }, [trickplayInfo]); + + /** Calculates the trickplay URL for the current progress. */ + const calculateTrickplayUrl = useCallback( + (progress: number) => { + const now = Date.now(); + if (!trickplayInfo || !item.Id || now - lastCalculationTime.current < throttleDelay) return; + lastCalculationTime.current = now; + const { sheetIndex, x, y } = calculateTrickplayTile(progress, trickplayInfo); + const url = getTrickplayUrl(item, sheetIndex); + if (url) setTrickPlayUrl({ x, y, url }); + }, + [trickplayInfo, item, throttleDelay, getTrickplayUrl], + ); + + /** Prefetches all the trickplay images for the item. */ + const prefetchAllTrickplayImages = useCallback(() => { + if (!trickplayInfo || !item.Id) return; + for (let index = 0; index < trickplayInfo.totalImageSheets; index++) { + const url = getTrickplayUrl(item, index); + if (url) Image.prefetch(url); + } + }, [trickplayInfo, item, getTrickplayUrl]); + + return { + trickPlayUrl, + calculateTrickplayUrl, + prefetchAllTrickplayImages, + trickplayInfo, + }; +}; + +export interface TrickplayData { Interval?: number; TileWidth?: number; TileHeight?: number; @@ -14,141 +72,93 @@ interface TrickplayData { ThumbnailCount?: number; } -interface TrickplayInfo { +export interface TrickplayInfo { resolution: string; aspectRatio: number; data: TrickplayData; + totalImageSheets: number; } -interface TrickplayUrl { - x: number; - y: number; - url: string; -} +/** Generates a trickplay URL based on the item, resolution, and sheet index. */ +export const generateTrickplayUrl = (item: BaseItemDto, sheetIndex: number) => { + const api = store.get(apiAtom); + const resolution = getTrickplayInfo(item)?.resolution; + if (!resolution || !api) return null; + return `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${sheetIndex}.jpg?api_key=${api.accessToken}`; +}; -export const useTrickplay = (item: BaseItemDto, enabled = true) => { - const [api] = useAtom(apiAtom); - const [trickPlayUrl, setTrickPlayUrl] = useState(null); - const lastCalculationTime = useRef(0); - const throttleDelay = 200; // 200ms throttle +/** + * Parses the trickplay metadata from a BaseItemDto. + * @param item The Jellyfin media item. + * @returns Parsed trickplay information or null if not available. + */ +export const getTrickplayInfo = (item: BaseItemDto): TrickplayInfo | null => { + if (!item.Id || !item.Trickplay) return null; - const trickplayInfo = useMemo(() => { - if (!enabled || !item.Id || !item.Trickplay) { - return null; - } + const mediaSourceId = item.Id; + const trickplayDataForSource = item.Trickplay[mediaSourceId]; - const mediaSourceId = item.Id; - const trickplayData = item.Trickplay[mediaSourceId]; + if (!trickplayDataForSource) { + return null; + } - if (!trickplayData) { - return null; - } + const firstResolution = Object.keys(trickplayDataForSource)[0]; + if (!firstResolution) { + return null; + } - // Get the first available resolution - const firstResolution = Object.keys(trickplayData)[0]; - return firstResolution - ? { - resolution: firstResolution, - aspectRatio: - trickplayData[firstResolution].Width! / - trickplayData[firstResolution].Height!, - data: trickplayData[firstResolution], - } - : null; - }, [item, enabled]); + const data = trickplayDataForSource[firstResolution]; + const { Interval, TileWidth, TileHeight, Width, Height } = data; - // Takes in ticks. - const calculateTrickplayUrl = useCallback( - (progress: number) => { - if (!enabled) { - return null; - } + if ( + !Interval || + !TileWidth || + !TileHeight || + !Width || + !Height || + !item.RunTimeTicks + ) { + return null; + } - const now = Date.now(); - if (now - lastCalculationTime.current < throttleDelay) { - return null; - } - lastCalculationTime.current = now; - - if (!trickplayInfo || !api || !item.Id) { - return null; - } - - const { data, resolution } = trickplayInfo; - const { Interval, TileWidth, TileHeight, Width, Height } = data; - - if ( - !Interval || - !TileWidth || - !TileHeight || - !resolution || - !Width || - !Height - ) { - throw new Error("Invalid trickplay data"); - } - - const currentTimeMs = Math.max(0, ticksToMs(progress)); - const currentTile = Math.floor(currentTimeMs / Interval); - - const tileSize = TileWidth * TileHeight; - const tileOffset = currentTile % tileSize; - const index = Math.floor(currentTile / tileSize); - - const tileOffsetX = tileOffset % TileWidth; - const tileOffsetY = Math.floor(tileOffset / TileWidth); - - const newTrickPlayUrl = { - x: tileOffsetX, - y: tileOffsetY, - url: `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${index}.jpg?api_key=${api.accessToken}`, - }; - - setTrickPlayUrl(newTrickPlayUrl); - return newTrickPlayUrl; - }, - [trickplayInfo, item, api, enabled], - ); - - const prefetchAllTrickplayImages = useCallback(() => { - if (!api || !enabled || !trickplayInfo || !item.Id || !item.RunTimeTicks) { - return; - } - - const { data, resolution } = trickplayInfo; - const { Interval, TileWidth, TileHeight, Width, Height } = data; - - if ( - !Interval || - !TileWidth || - !TileHeight || - !resolution || - !Width || - !Height - ) { - throw new Error("Invalid trickplay data"); - } - - // Calculate tiles per sheet - const tilesPerRow = TileWidth; - const tilesPerColumn = TileHeight; - const tilesPerSheet = tilesPerRow * tilesPerColumn; - const totalTiles = Math.ceil(ticksToMs(item.RunTimeTicks) / Interval); - const totalIndexes = Math.ceil(totalTiles / tilesPerSheet); - - // Prefetch all trickplay images - for (let index = 0; index < totalIndexes; index++) { - const url = `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${index}.jpg?api_key=${api.accessToken}`; - Image.prefetch(url); - } - }, [trickplayInfo, item, api, enabled]); + const tilesPerSheet = TileWidth * TileHeight; + const totalTiles = Math.ceil(ticksToMs(item.RunTimeTicks) / Interval); + const totalImageSheets = Math.ceil(totalTiles / tilesPerSheet); return { - trickPlayUrl: enabled ? trickPlayUrl : null, - calculateTrickplayUrl: enabled ? calculateTrickplayUrl : () => null, - prefetchAllTrickplayImages: enabled - ? prefetchAllTrickplayImages - : () => null, - trickplayInfo: enabled ? trickplayInfo : null, + resolution: firstResolution, + aspectRatio: Width / Height, + data, + totalImageSheets, }; }; + +/** + * Calculates the specific image sheet and tile offset for a given time. + * @param progressTicks The current playback time in ticks. + * @param trickplayInfo The parsed trickplay information object. + * @returns An object with the image sheet index, and the X/Y coordinates for the tile. + */ +const calculateTrickplayTile = ( + progressTicks: number, + trickplayInfo: TrickplayInfo, +) => { + const { data } = trickplayInfo; + const { Interval, TileWidth, TileHeight } = data; + + if (!Interval || !TileWidth || !TileHeight) { + throw new Error("Invalid trickplay data provided to calculateTile"); + } + + const currentTimeMs = Math.max(0, ticksToMs(progressTicks)); + const currentTile = Math.floor(currentTimeMs / Interval); + + const tilesPerSheet = TileWidth * TileHeight; + const sheetIndex = Math.floor(currentTile / tilesPerSheet); + const tileIndexInSheet = currentTile % tilesPerSheet; + + const x = tileIndexInSheet % TileWidth; + const y = Math.floor(tileIndexInSheet / TileWidth); + + return { sheetIndex, x, y }; +}; diff --git a/hooks/useTwoWaySync.ts b/hooks/useTwoWaySync.ts new file mode 100644 index 00000000..8ee2ff2b --- /dev/null +++ b/hooks/useTwoWaySync.ts @@ -0,0 +1,81 @@ +import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; +import { useNetInfo } from "@react-native-community/netinfo"; +import { useAtomValue } from "jotai"; +import { useDownload } from "@/providers/DownloadProvider"; +import { apiAtom, userAtom } from "../providers/JellyfinProvider"; +import { usePlaybackManager } from "./usePlaybackManager"; + +/** + * This hook is used to sync the playback state of a downloaded item with the server + * when the application comes back online after being used offline. + */ +export const useTwoWaySync = () => { + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const netInfo = useNetInfo(); + const { getDownloadedItemById, updateDownloadedItem } = useDownload(); + const { reportPlaybackProgress, markItemUnplayed, markItemPlayed } = + usePlaybackManager(); + + /** + * Syncs the playback state of an offline item with the server. + * It determines if the local or remote state is more recent and applies the necessary update. + * + * @returns A Promise indicating whether a server update was made (true) or not (false). + */ + const syncPlaybackState = async (itemId: string): Promise => { + if (!api || !user || !netInfo.isConnected) { + // Cannot sync if offline or not logged in + return false; + } + + const localItem = getDownloadedItemById(itemId); + if (!localItem) return false; + + const remoteItem = ( + await getUserLibraryApi(api).getItem({ itemId, userId: user.Id }) + ).data; + if (!remoteItem) return false; + + const localLastPlayed = localItem.item.UserData?.LastPlayedDate + ? new Date(localItem.item.UserData.LastPlayedDate) + : new Date(0); + const remoteLastPlayed = remoteItem.UserData?.LastPlayedDate + ? new Date(remoteItem.UserData.LastPlayedDate) + : new Date(0); + + // If the remote item has been played more recently, we take the server's version as the source of truth. + if (remoteLastPlayed > localLastPlayed) { + updateDownloadedItem(itemId, { + ...localItem, + item: { + ...localItem.item, + UserData: { + ...localItem.item.UserData, + LastPlayedDate: remoteItem.UserData?.LastPlayedDate, + PlaybackPositionTicks: remoteItem.UserData?.PlaybackPositionTicks, + Played: remoteItem.UserData?.Played, + PlayedPercentage: remoteItem.UserData?.PlayedPercentage, + }, + }, + }); + return false; + } else if (remoteLastPlayed < localLastPlayed) { + // Since we're this is the source of truth, essentially need to make sure the played status matches the local item. + await getItemsApi(api).updateItemUserData({ + itemId: localItem.item.Id!, + userId: user.Id, + updateUserItemDataDto: { + Played: localItem.item.UserData?.Played, + PlaybackPositionTicks: localItem.item.UserData?.PlaybackPositionTicks, + PlayedPercentage: localItem.item.UserData?.PlayedPercentage, + LastPlayedDate: localItem.item.UserData?.LastPlayedDate, + }, + }); + return true; + } + return false; + }; + + return { syncPlaybackState }; +}; diff --git a/i18n.ts b/i18n.ts index 2a7a92a9..160185b9 100644 --- a/i18n.ts +++ b/i18n.ts @@ -1,7 +1,6 @@ +import { getLocales } from "expo-localization"; import i18n from "i18next"; import { initReactI18next } from "react-i18next"; - -import { getLocales } from "expo-localization"; import de from "./translations/de.json"; import en from "./translations/en.json"; import eo from "./translations/eo.json"; diff --git a/modules/VlcPlayer.types.ts b/modules/VlcPlayer.types.ts index 85de4348..9bdd0afd 100644 --- a/modules/VlcPlayer.types.ts +++ b/modules/VlcPlayer.types.ts @@ -41,10 +41,10 @@ export type VlcPlayerSource = { type?: string; isNetwork?: boolean; autoplay?: boolean; - externalSubtitles: { name: string; DeliveryUrl: string }[]; + startPosition?: number; + externalSubtitles?: { name: string; DeliveryUrl: string }[]; initOptions?: any[]; mediaOptions?: { [key: string]: any }; - startPosition?: number; }; export type TrackInfo = { @@ -94,5 +94,5 @@ export interface VlcPlayerViewRef { getChapters: () => Promise; setVideoCropGeometry: (geometry: string | null) => Promise; getVideoCropGeometry: () => Promise; - setSubtitleURL: (url: string, name: string) => Promise; + setSubtitleURL: (url: string) => Promise; } diff --git a/modules/VlcPlayerView.tsx b/modules/VlcPlayerView.tsx index 70775876..cdc40f6e 100644 --- a/modules/VlcPlayerView.tsx +++ b/modules/VlcPlayerView.tsx @@ -1,8 +1,6 @@ import { requireNativeViewManager } from "expo-modules-core"; import * as React from "react"; - -import { VideoPlayer, useSettings } from "@/utils/atoms/settings"; -import { Platform, ViewStyle } from "react-native"; +import { ViewStyle } from "react-native"; import type { VlcPlayerSource, VlcPlayerViewProps, @@ -13,22 +11,12 @@ interface NativeViewRef extends VlcPlayerViewRef { setNativeProps?: (props: Partial) => void; } -const VLCViewManager = requireNativeViewManager("VlcPlayer"); const VLC3ViewManager = requireNativeViewManager("VlcPlayer3"); // Create a forwarded ref version of the native view const NativeView = React.forwardRef( (props, ref) => { - const [settings] = useSettings(); - - if (Platform.OS === "ios" || Platform.isTVOS) { - if (settings.defaultPlayer === VideoPlayer.VLC_3) { - console.log("[Apple] Using Vlc Player 3"); - return ; - } - } - console.log("Using default Vlc Player"); - return ; + return ; }, ); @@ -95,8 +83,8 @@ const VlcPlayerView = React.forwardRef( const geometry = await nativeRef.current?.getVideoCropGeometry(); return geometry ?? null; }, - setSubtitleURL: async (url: string, name: string) => { - await nativeRef.current?.setSubtitleURL(url, name); + setSubtitleURL: async (url: string) => { + await nativeRef.current?.setSubtitleURL(url); }, })); diff --git a/modules/vlc-player-3/ios/VlcPlayer3Module.swift b/modules/vlc-player-3/ios/VlcPlayer3Module.swift index c0e32606..fd7ff179 100644 --- a/modules/vlc-player-3/ios/VlcPlayer3Module.swift +++ b/modules/vlc-player-3/ios/VlcPlayer3Module.swift @@ -54,6 +54,10 @@ public class VlcPlayer3Module: Module { return view.getAudioTracks() } + AsyncFunction("setSubtitleURL") { (view: VlcPlayer3View, url: String, name: String) in + view.setSubtitleURL(url, name: name) + } + AsyncFunction("setSubtitleTrack") { (view: VlcPlayer3View, trackIndex: Int) in view.setSubtitleTrack(trackIndex) } @@ -61,11 +65,6 @@ public class VlcPlayer3Module: Module { AsyncFunction("getSubtitleTracks") { (view: VlcPlayer3View) -> [[String: Any]]? in return view.getSubtitleTracks() } - - AsyncFunction("setSubtitleURL") { - (view: VlcPlayer3View, url: String, name: String) in - view.setSubtitleURL(url, name: name) - } } } } diff --git a/modules/vlc-player-3/ios/VlcPlayer3View.swift b/modules/vlc-player-3/ios/VlcPlayer3View.swift index 50882734..40bf9156 100644 --- a/modules/vlc-player-3/ios/VlcPlayer3View.swift +++ b/modules/vlc-player-3/ios/VlcPlayer3View.swift @@ -22,6 +22,8 @@ class VlcPlayer3View: ExpoView { private var isStopping: Bool = false // Define isStopping here private var lastProgressCall = Date().timeIntervalSince1970 var hasSource = false + var isTranscoding = false + private var initialSeekPerformed: Bool = false // MARK: - Initialization @@ -88,7 +90,6 @@ class VlcPlayer3View: ExpoView { // If the specified time is greater than the duration, seek to the end let seekTime = time > duration ? duration - 1000 : time player.time = VLCTime(int: seekTime) - if wasPlaying { self.play() } @@ -110,13 +111,18 @@ class VlcPlayer3View: ExpoView { var initOptions = source["initOptions"] as? [Any] ?? [] self.startPosition = source["startPosition"] as? Int32 ?? 0 self.externalSubtitles = source["externalSubtitles"] as? [[String: String]] - initOptions.append("--start-time=\(self.startPosition)") guard let uri = source["uri"] as? String, !uri.isEmpty else { print("Error: Invalid or empty URI") self.onVideoError?(["error": "Invalid or empty URI"]) return } + + self.isTranscoding = uri.contains("m3u8") + + if !self.isTranscoding, self.startPosition > 0 { + initOptions.append("--start-time=\(self.startPosition)") + } let autoplay = source["autoplay"] as? Bool ?? false let isNetwork = source["isNetwork"] as? Bool ?? false @@ -126,6 +132,7 @@ class VlcPlayer3View: ExpoView { self.mediaPlayer?.delegate = self self.mediaPlayer?.drawable = self.videoView self.mediaPlayer?.scaleFactor = 0 + self.initialSeekPerformed = false let media: VLCMedia if isNetwork { @@ -287,9 +294,14 @@ class VlcPlayer3View: ExpoView { let currentTimeMs = player.time.intValue let durationMs = player.media?.length.intValue ?? 0 + print("Debug: Current time: \(currentTimeMs)") if currentTimeMs >= 0 && currentTimeMs < durationMs { + if self.isTranscoding, !self.initialSeekPerformed, self.startPosition > 0 { + player.time = VLCTime(int: self.startPosition * 1000) + self.initialSeekPerformed = true + } self.onVideoProgress?([ "currentTime": currentTimeMs, "duration": durationMs, diff --git a/modules/vlc-player/android/build.gradle b/modules/vlc-player/android/build.gradle index 3077d202..b2695833 100644 --- a/modules/vlc-player/android/build.gradle +++ b/modules/vlc-player/android/build.gradle @@ -29,7 +29,7 @@ if (useManagedAndroidSdkVersions) { mavenCentral() } dependencies { - classpath "com.android.tools.build:gradle:7.1.3" + classpath "com.android.tools.build:gradle:8.11.0" } } project.android { diff --git a/modules/vlc-player/ios/VlcPlayerView.swift b/modules/vlc-player/ios/VlcPlayerView.swift index f02478d2..599166fd 100644 --- a/modules/vlc-player/ios/VlcPlayerView.swift +++ b/modules/vlc-player/ios/VlcPlayerView.swift @@ -137,10 +137,7 @@ extension VLCPlayerWrapper: VLCMediaPlayerDelegate { } } -// MARK: - VLCMediaDelegate -extension VLCPlayerWrapper: VLCMediaDelegate { - // Implement VLCMediaDelegate methods if needed -} + class VlcPlayerView: ExpoView { let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VlcPlayerView") @@ -154,6 +151,10 @@ class VlcPlayerView: ExpoView { private var isStopping: Bool = false // Define isStopping here private var externalSubtitles: [[String: String]]? var hasSource = false + var initialSeekPerformed = false + // A flag variable determinging if we should perform the initial seek. Its either transcoding or offline playback. that makes + var shouldPerformInitialSeek: Bool = false + // MARK: - Initialization required init(appContext: AppContext? = nil) { @@ -172,6 +173,19 @@ class VlcPlayerView: ExpoView { ) } + // Workaround: When playing an HLS video for the first time, seeking to a specific time immediately can cause a crash. + // To avoid this, we wait until the video has started playing before performing the initial seek. + func performInitialSeek() { + guard !initialSeekPerformed, + startPosition > 0, + shouldPerformInitialSeek, + vlc.player.isSeekable else { return } + + initialSeekPerformed = true + logger.debug("First time update, performing initial seek to \(self.startPosition) seconds") + vlc.player.time = VLCTime(int: startPosition * 1000) + } + private func setupNotifications() { NotificationCenter.default.addObserver( self, selector: #selector(applicationWillResignActive), @@ -254,6 +268,8 @@ class VlcPlayerView: ExpoView { let autoplay = source["autoplay"] as? Bool ?? false let isNetwork = source["isNetwork"] as? Bool ?? false + // Set shouldPeformIntial based on isTranscoding and is not a network stream + self.shouldPerformInitialSeek = uri.contains("m3u8") || !isNetwork self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()]) let media: VLCMedia! @@ -277,8 +293,11 @@ class VlcPlayerView: ExpoView { self.hasSource = true if autoplay { logger.info("Playing...") + // The Video is not transcoding so it its safe to seek to the start position. + if !self.shouldPerformInitialSeek { + self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000)) + } self.play() - self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000)) } } } @@ -415,6 +434,9 @@ class VlcPlayerView: ExpoView { private func updatePlayerState() { let player = self.vlc.player + if player.isPlaying { + performInitialSeek() + } self.onVideoStateChange?([ "target": self.reactTag ?? NSNull(), "currentTime": player.time.intValue, diff --git a/package.json b/package.json index 079f547c..a1eed56a 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "lint": "biome check --write --unsafe" }, "dependencies": { - "@bottom-tabs/react-navigation": "0.8.6", + "@bottom-tabs/react-navigation": "0.9.2", "@expo/config-plugins": "~9.0.15", "@expo/react-native-action-sheet": "^4.1.1", "@expo/vector-icons": "^14.0.4", @@ -35,36 +35,36 @@ "@tanstack/react-query": "^5.66.0", "add": "^2.0.6", "axios": "^1.7.9", - "expo": "^52.0.31", - "expo-asset": "~11.0.3", - "expo-background-fetch": "~13.0.5", + "expo": "~52.0.47", + "expo-asset": "~11.0.5", + "expo-background-fetch": "~13.0.6", "expo-blur": "~14.0.3", "expo-brightness": "~13.0.3", - "expo-build-properties": "~0.13.2", - "expo-constants": "~17.0.5", + "expo-build-properties": "~0.13.3", + "expo-constants": "~17.0.8", "expo-crypto": "~14.0.2", - "expo-dev-client": "~5.0.11", - "expo-device": "~7.0.2", + "expo-dev-client": "~5.0.20", + "expo-device": "~7.0.3", "expo-font": "~13.0.3", "expo-haptics": "~14.0.1", - "expo-image": "~2.0.4", + "expo-image": "~2.0.7", "expo-keep-awake": "~14.0.2", "expo-linear-gradient": "~14.0.2", "expo-linking": "~7.0.5", "expo-localization": "~16.0.1", "expo-network": "~7.0.5", - "expo-notifications": "~0.29.13", - "expo-router": "~4.0.17", + "expo-notifications": "~0.29.14", + "expo-router": "~4.0.21", "expo-screen-orientation": "~8.0.4", "expo-sensors": "~14.0.2", "expo-sharing": "~13.0.1", - "expo-splash-screen": "~0.29.22", + "expo-splash-screen": "~0.29.24", "expo-status-bar": "~2.0.1", - "expo-system-ui": "~4.0.8", - "expo-task-manager": "~12.0.5", - "expo-updates": "~0.26.17", + "expo-system-ui": "~4.0.9", + "expo-task-manager": "~12.0.6", + "expo-updates": "~0.27.4", "expo-web-browser": "~14.0.2", - "i18next": "^24.2.2", + "i18next": "^25.0.0", "jotai": "^2.11.3", "lodash": "^4.17.21", "nativewind": "^2.0.11", @@ -73,27 +73,27 @@ "react-i18next": "^15.4.0", "react-native": "npm:react-native-tvos@~0.77.2-0", "react-native-awesome-slider": "^2.9.0", - "react-native-bottom-tabs": "0.8.6", + "react-native-bottom-tabs": "0.9.2", "react-native-circular-progress": "^1.4.1", "react-native-collapsible": "^1.6.2", "react-native-compressor": "^1.10.3", "react-native-country-flag": "^2.0.2", "react-native-device-info": "^14.0.4", "react-native-edge-to-edge": "^1.4.3", - "react-native-gesture-handler": "2.22.0", + "react-native-gesture-handler": "~2.24.0", "react-native-get-random-values": "^1.11.0", "react-native-google-cast": "^4.8.3", "react-native-image-colors": "^2.4.0", "react-native-ios-context-menu": "^3.1.0", "react-native-ios-utilities": "5.1.1", "react-native-mmkv": "^2.12.2", - "react-native-pager-view": "6.5.1", + "react-native-pager-view": "6.6.0", "react-native-progress": "^5.0.1", "react-native-reanimated": "~3.16.7", "react-native-reanimated-carousel": "3.5.1", - "react-native-safe-area-context": "5.1.0", - "react-native-screens": "~4.5.0", - "react-native-svg": "15.11.1", + "react-native-safe-area-context": "5.2.0", + "react-native-screens": "4.10.0", + "react-native-svg": "15.11.2", "react-native-tab-view": "^4.0.5", "react-native-udp": "^4.1.7", "react-native-uitextview": "^1.4.0", @@ -102,7 +102,7 @@ "react-native-video": "6.10.0", "react-native-volume-manager": "^2.0.8", "react-native-web": "~0.19.13", - "react-native-webview": "13.13.2", + "react-native-webview": "13.13.0", "sonner-native": "^0.17.0", "tailwindcss": "3.3.2", "use-debounce": "^10.0.4", @@ -112,10 +112,10 @@ }, "devDependencies": { "@babel/core": "^7.26.8", - "@biomejs/biome": "^1.9.4", - "@react-native-community/cli": "15.1.3", + "@biomejs/biome": "^2.0.0", + "@react-native-community/cli": "18.0.0", "@react-native-tvos/config-tv": "^0.1.1", - "@types/jest": "^29.5.14", + "@types/jest": "^30.0.0", "@types/lodash": "^4.17.15", "@types/react": "~18.3.12", "@types/react-native-vector-icons": "^6.4.18", @@ -123,7 +123,7 @@ "@types/uuid": "^10.0.0", "cross-env": "^7.0.3", "husky": "^9.1.7", - "lint-staged": "^15.5.0", + "lint-staged": "^16.1.2", "postinstall-postinstall": "^2.1.0", "react-test-renderer": "19.0.0", "typescript": "~5.7.3" @@ -131,11 +131,17 @@ "private": true, "expo": { "install": { - "exclude": ["react-native"] + "exclude": [ + "react-native" + ] } }, "lint-staged": { - "*.{js,jsx,ts,tsx}": ["biome check --write --unsafe"], - "*.{json}": ["biome format --write"] + "*.{js,jsx,ts,tsx}": [ + "biome check --write --unsafe --no-errors-on-unmatched" + ], + "*.json": [ + "biome format --write" + ] } } diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index c1ea7b6f..b2311717 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -1,36 +1,15 @@ -import { useHaptic } from "@/hooks/useHaptic"; -import useImageStorage from "@/hooks/useImageStorage"; -import { useInterval } from "@/hooks/useInterval"; -import { DownloadMethod, useSettings } from "@/utils/atoms/settings"; -import { getOrSetDeviceId } from "@/utils/device"; -import useDownloadHelper from "@/utils/download"; -import { getItemImage } from "@/utils/getItemImage"; -import { useLog, writeToLog } from "@/utils/log"; -import { storage } from "@/utils/mmkv"; -import { - type JobStatus, - cancelAllJobs, - cancelJobById, - deleteDownloadItemInfoFromDiskTmp, - getAllJobsByDeviceId, - getDownloadItemInfoFromDiskTmp, -} from "@/utils/optimize-server"; import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; -import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api"; import BackGroundDownloader from "@kesha-antonov/react-native-background-downloader"; -import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query"; -import axios from "axios"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import * as Application from "expo-application"; import * as FileSystem from "expo-file-system"; -import type { FileInfo } from "expo-file-system"; -import Notifications from "expo-notifications"; -import { useRouter } from "expo-router"; +import { router } from "expo-router"; import { atom, useAtom } from "jotai"; -import type React from "react"; -import { +import { throttle } from "lodash"; +import React, { createContext, useCallback, useContext, @@ -38,21 +17,62 @@ import { useMemo, } from "react"; import { useTranslation } from "react-i18next"; -import { AppState, type AppStateStatus, Platform } from "react-native"; import { toast } from "sonner-native"; +import { useHaptic } from "@/hooks/useHaptic"; +import useImageStorage from "@/hooks/useImageStorage"; +import { useInterval } from "@/hooks/useInterval"; +import { generateTrickplayUrl, getTrickplayInfo } from "@/hooks/useTrickplay"; +import { useSettings } from "@/utils/atoms/settings"; +import { getOrSetDeviceId } from "@/utils/device"; +import useDownloadHelper from "@/utils/download"; +import { getItemImage } from "@/utils/getItemImage"; +import { writeToLog } from "@/utils/log"; +import { storage } from "@/utils/mmkv"; +import { fetchAndParseSegments } from "@/utils/segments"; import { Bitrate } from "../components/BitrateSelector"; +import { + DownloadedItem, + DownloadsDatabase, + JobStatus, + TrickPlayData, +} from "./Downloads/types"; import { apiAtom } from "./JellyfinProvider"; -export type DownloadedItem = { - item: Partial; - mediaSource: MediaSourceInfo; +const calculateEstimatedSize = (p: JobStatus): number => { + let size = p.mediaSource.Size; + const maxBitrate = p.maxBitrate.value; + if ( + maxBitrate && + size && + p.mediaSource.Bitrate && + maxBitrate < p.mediaSource.Bitrate + ) { + size = (size / p.mediaSource.Bitrate) * maxBitrate; + } + // This function is for estimated size, so just return the adjusted size + return size ?? 0; +}; + +// Helper to calculate download speed +const calculateSpeed = ( + process: JobStatus, + newBytesDownloaded: number, +): number | undefined => { + const { bytesDownloaded: oldBytes = 0, lastProgressUpdateTime } = process; + const deltaBytes = newBytesDownloaded - oldBytes; + + if (lastProgressUpdateTime && deltaBytes > 0) { + const deltaTimeInSeconds = + (Date.now() - new Date(lastProgressUpdateTime).getTime()) / 1000; + if (deltaTimeInSeconds > 0) { + return deltaBytes / deltaTimeInSeconds; + } + } + return undefined; }; export const processesAtom = atom([]); - -function onAppStateChange(status: AppStateStatus) { - focusManager.setFocused(status === "active"); -} +const DOWNLOADS_DATABASE_KEY = "downloads.v2.json"; const DownloadContext = createContext(processesAtom); - + const [processes, setProcesses] = useAtom(processesAtom); + const [settings] = useSettings(); const successHapticFeedback = useHaptic("success"); + /// Cant use the background downloader callback. As its not triggered if size is unknown. + const updateProgress = async () => { + const tasks = await BackGroundDownloader.checkForExistingDownloads(); + if (!tasks) { + return; + } + // check if processes are missing + setProcesses((processes) => { + const missingProcesses = tasks + .filter((t) => t.metadata && !processes.some((p) => p.id === t.id)) + .map((t) => { + return t.metadata as JobStatus; + }); + + const currentProcesses = [...processes, ...missingProcesses]; + const updatedProcesses = currentProcesses.map((p) => { + // fallback. Doesn't really work for transcodes as they may be a lot smaller. + // We make an wild guess by comparing bitrates + const task = tasks.find((s) => s.id === p.id); + if (task && p.status === "downloading") { + const estimatedSize = calculateEstimatedSize(p); + let progress = p.progress; + if (estimatedSize > 0) { + progress = (100 / estimatedSize) * task.bytesDownloaded; + } + if (progress >= 100) { + progress = 99; + } + const speed = calculateSpeed(p, task.bytesDownloaded); + return { + ...p, + progress, + speed, + bytesDownloaded: task.bytesDownloaded, + lastProgressUpdateTime: new Date(), + estimatedTotalSizeBytes: estimatedSize, + }; + } + return p; + }); + + return updatedProcesses; + }); + }; + + useInterval(updateProgress, 2000); + + const getDownloadedItemById = (id: string): DownloadedItem | undefined => { + const db = getDownloadsDatabase(); + + // Check movies first + if (db.movies[id]) { + return db.movies[id]; + } + + // If not in movies, check episodes + for (const series of Object.values(db.series)) { + for (const season of Object.values(series.seasons)) { + for (const episode of Object.values(season.episodes)) { + if (episode.item.Id === id) { + return episode; + } + } + } + } + + return undefined; + }; + + const updateProcess = useCallback( + ( + processId: string, + updater: + | Partial + | ((current: JobStatus) => Partial), + ) => { + setProcesses((prev) => + prev.map((p) => { + if (p.id !== processId) return p; + const newStatus = + typeof updater === "function" ? updater(p) : updater; + return { + ...p, + ...newStatus, + }; + }), + ); + }, + [setProcesses], + ); + const authHeader = useMemo(() => { return api?.accessToken; }, [api]); - const usingOptimizedServer = useMemo( - () => settings?.downloadMethod === DownloadMethod.Optimized, - [settings], - ); - - const getDownloadUrl = (process: JobStatus) => { - return usingOptimizedServer - ? `${settings.optimizedVersionsServerUrl}download/${process.id}` - : process.inputUrl; - }; - - const { data: downloadedFiles, refetch } = useQuery({ + const { data: downloadedItems } = useQuery({ queryKey: ["downloadedItems"], - queryFn: getAllDownloadedItems, + queryFn: async () => { + const db = getDownloadsDatabase(); + const allItems = [ + ...Object.values(db.movies), + ...Object.values(db.series).flatMap((series) => + Object.values(series.seasons).flatMap((season) => + Object.values(season.episodes), + ), + ), + ]; + return allItems; + }, staleTime: 0, refetchOnMount: true, refetchOnReconnect: true, refetchOnWindowFocus: true, + + // We always want fetch, even if there is no internet. + networkMode: "always", }); - useEffect(() => { - const subscription = AppState.addEventListener("change", onAppStateChange); - - return () => subscription.remove(); - }, []); - - useQuery({ - queryKey: ["jobs"], - queryFn: async () => { - const deviceId = await getOrSetDeviceId(); - const url = settings?.optimizedVersionsServerUrl; - - if ( - settings?.downloadMethod !== DownloadMethod.Optimized || - !url || - !deviceId || - !authHeader - ) - return []; - - const jobs = await getAllJobsByDeviceId({ - deviceId, - authHeader, - url, - }); - - const downloadingProcesses = processes - .filter((p) => p.status === "downloading") - .filter((p) => jobs.some((j) => j.id === p.id)); - - const updatedProcesses = jobs.filter( - (j) => !downloadingProcesses.some((p) => p.id === j.id), - ); - - setProcesses([...updatedProcesses, ...downloadingProcesses]); - - for (const job of jobs) { - const process = processes.find((p) => p.id === job.id); - if ( - process && - process.status === "optimizing" && - job.status === "completed" - ) { - if (settings.autoDownload) { - startDownload(job); - } else { - toast.info( - t("home.downloads.toasts.item_is_ready_to_be_downloaded", { - item: job.item.Name, - }), - { - action: { - label: t("home.downloads.toasts.go_to_downloads"), - onClick: () => { - router.push("/downloads"); - toast.dismiss(); - }, - }, - }, - ); - Notifications.scheduleNotificationAsync({ - content: { - title: job.item.Name, - body: `${job.item.Name} is ready to be downloaded`, - data: { - url: "/downloads", - }, - }, - trigger: null, - }); - } - } - } - - return jobs; - }, - staleTime: 0, - refetchInterval: 2000, - enabled: settings?.downloadMethod === DownloadMethod.Optimized, - }); - - /// Cant use the background downloader callback. As its not triggered if size is unknown. - const updateProgress = async () => { - if (settings?.downloadMethod === DownloadMethod.Optimized) { - return; - } - - // const response = await getSessionApi(api).getSessions({ - // activeWithinSeconds: 300, - // }); - - const tasks = await BackGroundDownloader.checkForExistingDownloads(); - - // check if processes are missing - const missingProcesses = tasks - .filter((t) => !processes.some((p) => p.id === t.id)) - .map((t) => { - return t.metadata; - }); - - processes = [...processes, ...missingProcesses]; - - const updatedProcesses = processes.map((p) => { - // const result = response.data.find((s) => s.Id == p.sessionId); - // if (result) { - // return { - // ...p, - // progress: result.TranscodingInfo?.CompletionPercentage, - // }; - // } - - // fallback. Doesn't really work for transcodes as they may be a lot smaller. - // We make an wild guess by comparing bitrates - const task = tasks.find((s) => s.id === p.id); - if (task) { - let progress = p.progress; - let size = p.mediaSource.Size; - const maxBitrate = p.maxBitrate.value; - if (maxBitrate && maxBitrate < p.mediaSource.Bitrate) { - size = (size / p.mediaSource.Bitrate) * maxBitrate; - } - progress = (100 / size) * task.bytesDownloaded; - if (progress >= 100) { - progress = 99; - } - - return { - ...p, - progress, - }; - } - return p; - }); - - setProcesses(updatedProcesses); - }; - - useInterval(updateProgress, 2000); - - useEffect(() => { - const checkIfShouldStartDownload = async () => { - if (processes.length === 0) return; - await BackGroundDownloader?.checkForExistingDownloads(); - }; - - checkIfShouldStartDownload(); - }, [settings, processes]); - - const removeProcess = useCallback( - async (id: string) => { - const deviceId = await getOrSetDeviceId(); - if (!deviceId || !authHeader) return; - - if (usingOptimizedServer) { - try { - await cancelJobById({ - authHeader, - id, - url: settings?.optimizedVersionsServerUrl, - }); - } catch (error) { - console.error(error); - } - } - - setProcesses((prev: any[]) => { - return prev.filter( - (process: { itemId: string | undefined }) => process.id !== id, - ); - }); - }, - [settings?.optimizedVersionsServerUrl, authHeader], - ); - const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`; + const getDownloadsDatabase = (): DownloadsDatabase => { + const file = storage.getString(DOWNLOADS_DATABASE_KEY); + if (file) { + return JSON.parse(file) as DownloadsDatabase; + } + return { movies: {}, series: {} }; + }; + + const saveDownloadsDatabase = async (db: DownloadsDatabase) => { + storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db)); + queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); + }; + + /** Generates a filename for a given item */ + const generateFilename = (item: BaseItemDto): string => { + let rawFilename = ""; + if (item.Type === "Movie" && item.Name) { + rawFilename = `${item.Name}`; + } else if ( + item.Type === "Episode" && + item.SeriesName && + item.ParentIndexNumber !== undefined && + item.IndexNumber !== undefined + ) { + const season = String(item.ParentIndexNumber).padStart(2, "0"); + const episode = String(item.IndexNumber).padStart(2, "0"); + rawFilename = `${item.SeriesName} S${season}E${episode} ${item.Name}`; + } else { + // Fallback to a unique name if data is missing + rawFilename = `${item.Name || "video"} ${item.Id}`; + } + // Sanitize the entire string to remove illegal characters + return rawFilename.replace(/[\\/:*?"<>|\s]/g, "_"); + }; + + /** + * Downloads the trickplay images for a given item. + * @param item - The item to download the trickplay images for. + * @returns The path to the trickplay images. + */ + const downloadTrickplayImages = async ( + item: BaseItemDto, + ): Promise => { + const trickplayInfo = getTrickplayInfo(item); + if (!api || !trickplayInfo || !item.Id) { + return undefined; + } + + const filename = generateFilename(item); + const trickplayDir = `${FileSystem.documentDirectory}${filename}_trickplay/`; + await FileSystem.makeDirectoryAsync(trickplayDir, { intermediates: true }); + let totalSize = 0; + + for (let index = 0; index < trickplayInfo.totalImageSheets; index++) { + const url = generateTrickplayUrl(item, index); + if (!url) continue; + const destination = `${trickplayDir}${index}.jpg`; + try { + await FileSystem.downloadAsync(url, destination); + const fileInfo = await FileSystem.getInfoAsync(destination); + if (fileInfo.exists) { + totalSize += fileInfo.size; + } + } catch (e) { + console.error( + `Failed to download trickplay image ${index} for item ${item.Id}`, + e, + ); + } + } + + return { path: trickplayDir, size: totalSize }; + }; + + /** + * Downloads and links external subtitles to the media source. + * @param mediaSource - The media source to download the subtitles for. + */ + const downloadAndLinkSubtitles = async ( + mediaSource: MediaSourceInfo, + item: BaseItemDto, + ) => { + const externalSubtitles = mediaSource.MediaStreams?.filter( + (stream) => + stream.Type === "Subtitle" && stream.DeliveryMethod === "External", + ); + if (externalSubtitles && api) { + await Promise.all( + externalSubtitles.map(async (subtitle) => { + const url = api.basePath + subtitle.DeliveryUrl; + const filename = generateFilename(item); + const destination = `${FileSystem.documentDirectory}${filename}_subtitle_${subtitle.Index}`; + await FileSystem.downloadAsync(url, destination); + subtitle.DeliveryUrl = destination; + }), + ); + } + }; + + /** + * Starts a download for a given process. + * @param process - The process to start the download for. + */ const startDownload = useCallback( async (process: JobStatus) => { if (!process?.item.Id || !authHeader) throw new Error("No item id"); - setProcesses((prev) => - prev.map((p) => - p.id === process.id - ? { - ...p, - speed: undefined, - status: "downloading", - progress: 0, - } - : p, - ), - ); + updateProcess(process.id, { + speed: undefined, + status: "downloading", + progress: 0, + }); BackGroundDownloader?.setConfig({ - isLogsEnabled: true, + isLogsEnabled: false, progressInterval: 500, headers: { Authorization: authHeader, }, }); - - toast.info( - t("home.downloads.toasts.download_stated_for_item", { - item: process.item.Name, - }), - { - action: { - label: t("home.downloads.toasts.go_to_downloads"), - onClick: () => { - router.push("/downloads"); - toast.dismiss(); - }, - }, - }, - ); - - const baseDirectory = FileSystem.documentDirectory; - + const filename = generateFilename(process.item); + const videoFilePath = `${FileSystem.documentDirectory}${filename}.mp4`; BackGroundDownloader?.download({ id: process.id, - url: getDownloadUrl(process), - destination: `${baseDirectory}/${process.item.Id}.mp4`, + url: process.inputUrl, + destination: videoFilePath, metadata: process, }) .begin(() => { - setProcesses((prev) => - prev.map((p) => - p.id === process.id - ? { - ...p, - speed: undefined, - status: "downloading", - progress: 0, - } - : p, - ), - ); + updateProcess(process.id, { + status: "downloading", + progress: 0, + bytesDownloaded: 0, + lastProgressUpdateTime: new Date(), + }); }) - .progress((data) => { - if (!usingOptimizedServer) { - return; + .progress( + throttle((data) => { + updateProcess(process.id, (currentProcess) => { + const percent = (data.bytesDownloaded / data.bytesTotal) * 100; + return { + speed: calculateSpeed(currentProcess, data.bytesDownloaded), + status: "downloading", + progress: percent, + bytesDownloaded: data.bytesDownloaded, + lastProgressUpdateTime: new Date(), + }; + }); + }, 500), + ) + .done(async () => { + const trickPlayData = await downloadTrickplayImages(process.item); + const videoFileInfo = await FileSystem.getInfoAsync(videoFilePath); + if (!videoFileInfo.exists) { + throw new Error("Downloaded file does not exist"); } - const percent = (data.bytesDownloaded / data.bytesTotal) * 100; - setProcesses((prev) => - prev.map((p) => - p.id === process.id - ? { - ...p, - speed: undefined, - status: "downloading", - progress: percent, - } - : p, - ), - ); - }) - .done(async (doneHandler) => { - await saveDownloadedItemInfo( - process.item, - doneHandler.bytesDownloaded, + const videoFileSize = videoFileInfo.size; + const db = getDownloadsDatabase(); + const { item, mediaSource } = process; + // Only download external subtitles for non-transcoded streams. + if (!mediaSource.TranscodingUrl) { + await downloadAndLinkSubtitles(mediaSource, item); + } + const { introSegments, creditSegments } = await fetchAndParseSegments( + item.Id!, + api!, ); + const downloadedItem: DownloadedItem = { + item, + mediaSource, + videoFilePath, + videoFileSize, + trickPlayData, + userData: { + audioStreamIndex: 0, + subtitleStreamIndex: 0, + }, + introSegments, + creditSegments, + }; + + if (item.Type === "Movie" && item.Id) { + db.movies[item.Id] = downloadedItem; + } else if ( + item.Type === "Episode" && + item.SeriesId && + item.ParentIndexNumber !== undefined && + item.ParentIndexNumber !== null && + item.IndexNumber !== undefined && + item.IndexNumber !== null + ) { + if (!db.series[item.SeriesId]) { + const seriesInfo: Partial = { + Id: item.SeriesId, + Name: item.SeriesName, + Type: "Series", + }; + db.series[item.SeriesId] = { + seriesInfo: seriesInfo as BaseItemDto, + seasons: {}, + }; + } + + const seasonNumber = item.ParentIndexNumber; + if (!db.series[item.SeriesId].seasons[seasonNumber]) { + db.series[item.SeriesId].seasons[seasonNumber] = { + episodes: {}, + }; + } + + const episodeNumber = item.IndexNumber; + db.series[item.SeriesId].seasons[seasonNumber].episodes[ + episodeNumber + ] = downloadedItem; + } + await saveDownloadsDatabase(db); + toast.success( t("home.downloads.toasts.download_completed_for_item", { item: process.item.Name, }), - { - duration: 3000, - action: { - label: t("home.downloads.toasts.go_to_downloads"), - onClick: () => { - router.push("/downloads"); - toast.dismiss(); - }, - }, - }, ); - setTimeout(() => { - BackGroundDownloader.completeHandler(process.id); - removeProcess(process.id); - }, 1000); - }) - .error(async (error) => { removeProcess(process.id); - BackGroundDownloader.completeHandler(process.id); - let errorMsg = ""; - if (error.errorCode === 1000) { - errorMsg = "No space left"; - } - if (error.errorCode === 404) { - errorMsg = "File not found on server"; - } + }) + .error((error) => { + console.error("Download error:", error); toast.error( t("home.downloads.toasts.download_failed_for_item", { item: process.item.Name, - error: errorMsg, }), ); - writeToLog("ERROR", `Download failed for ${process.item.Name}`, { - error, - processDetails: { - id: process.id, - itemName: process.item.Name, - itemId: process.item.Id, - }, - }); - console.error("Error details:", { - errorCode: error.errorCode, - }); + removeProcess(process.id); }); }, - [queryClient, settings?.optimizedVersionsServerUrl, authHeader], + [authHeader, queryClient], ); + const manageDownloadQueue = useCallback(() => { + const activeDownloads = processes.filter( + (p) => p.status === "downloading", + ).length; + const concurrentLimit = settings?.remuxConcurrentLimit || 1; + if (activeDownloads < concurrentLimit) { + const queuedDownload = processes.find((p) => p.status === "queued"); + if (queuedDownload) { + startDownload(queuedDownload); + } + } + }, [processes, settings?.remuxConcurrentLimit, startDownload]); + + const removeProcess = useCallback( + async (id: string) => { + const tasks = await BackGroundDownloader.checkForExistingDownloads(); + const task = tasks?.find((t) => t.id === id); + task?.stop(); + BackGroundDownloader.completeHandler(id); + setProcesses((prev) => prev.filter((process) => process.id !== id)); + manageDownloadQueue(); + }, + [setProcesses, manageDownloadQueue], + ); + + useEffect(() => { + manageDownloadQueue(); + }, [processes, manageDownloadQueue]); + + /** + * Cleans the cache directory. + */ + const cleanCacheDirectory = async (): Promise => { + try { + await FileSystem.deleteAsync(APP_CACHE_DOWNLOAD_DIRECTORY, { + idempotent: true, + }); + await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, { + intermediates: true, + }); + } catch (_error) { + toast.error(t("Failed to clean cache directory.")); + } + }; + const startBackgroundDownload = useCallback( async ( url: string, item: BaseItemDto, mediaSource: MediaSourceInfo, - maxBitrate?: Bitrate, + maxBitrate: Bitrate, ) => { if (!api || !item.Id || !authHeader) throw new Error("startBackgroundDownload ~ Missing required params"); - try { - const fileExtension = mediaSource.TranscodingContainer; - const deviceId = await getOrSetDeviceId(); - + const deviceId = getOrSetDeviceId(); await saveSeriesPrimaryImage(item); const itemImage = getItemImage({ item, @@ -433,46 +510,21 @@ function useDownloadProvider() { width: 500, }); await saveImage(item.Id, itemImage?.uri); - if (usingOptimizedServer) { - const response = await axios.post( - `${settings?.optimizedVersionsServerUrl}optimize-version`, - { - url, - fileExtension, - deviceId, - itemId: item.Id, - item, - }, - { - headers: { - "Content-Type": "application/json", - Authorization: authHeader, - }, - }, - ); - - if (response.status !== 201) { - throw new Error("Failed to start optimization job"); - } - } else { - const job: JobStatus = { - id: item.Id!, - deviceId: deviceId, - inputUrl: url, - item: item, - itemId: item.Id!, - mediaSource, - progress: 0, - maxBitrate, - status: "downloading", - timestamp: new Date(), - }; - setProcesses([...processes, job]); - startDownload(job); - } - + const job: JobStatus = { + id: item.Id!, + deviceId: deviceId, + maxBitrate, + inputUrl: url, + item: item, + itemId: item.Id!, + mediaSource, + progress: 0, + status: "queued", + timestamp: new Date(), + }; + setProcesses((prev) => [...prev, job]); toast.success( - t("home.downloads.toasts.queued_item_for_optimization", { + t("home.downloads.toasts.download_stated_for_item", { item: item.Name, }), { @@ -487,394 +539,197 @@ function useDownloadProvider() { ); } catch (error) { writeToLog("ERROR", "Error in startBackgroundDownload", error); - console.error("Error in startBackgroundDownload:", error); - if (axios.isAxiosError(error)) { - console.error("Axios error details:", { - message: error.message, - response: error.response?.data, - status: error.response?.status, - headers: error.response?.headers, - }); - toast.error( - t("home.downloads.toasts.failed_to_start_download_for_item", { - item: item.Name, - message: error.message, - }), - ); - if (error.response) { - toast.error( - t("home.downloads.toasts.server_responded_with_status", { - statusCode: error.response.status, - }), - ); - } else if (error.request) { - t("home.downloads.toasts.no_response_received_from_server"); - } else { - toast.error("Error setting up the request"); - } - } else { - console.error("Non-Axios error:", error); - toast.error( - t( - "home.downloads.toasts.failed_to_start_download_for_item_unexpected_error", - { item: item.Name }, - ), - ); - } } }, - [settings?.optimizedVersionsServerUrl, authHeader], + [authHeader, startDownload, queryClient], ); - const deleteAllFiles = async (): Promise => { - Promise.all([ - deleteLocalFiles(), - removeDownloadedItemsFromStorage(), - cancelAllServerJobs(), - queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }), - ]) - .then(() => - toast.success( - t( - "home.downloads.toasts.all_files_folders_and_jobs_deleted_successfully", - ), - ), - ) - .catch((reason) => { - console.error("Failed to delete all files, folders, and jobs:", reason); - toast.error( - t( - "home.downloads.toasts.an_error_occured_while_deleting_files_and_jobs", - ), - ); - }); - }; + const deleteFile = async (id: string, type: "Movie" | "Episode") => { + const db = getDownloadsDatabase(); + let downloadedItem: DownloadedItem | undefined; - const forEveryDocumentDirFile = async ( - includeMMKV: boolean, - ignoreList: string[], - callback: (file: FileInfo) => void, - ) => { - const baseDirectory = FileSystem.documentDirectory; - if (!baseDirectory) { - throw new Error("Base directory not found"); - } - - const dirContents = await FileSystem.readDirectoryAsync(baseDirectory); - for (const item of dirContents) { - // Exclude mmkv directory. - // Deleting this deletes all user information as well. Logout should handle this. - if ( - (item === "mmkv" && !includeMMKV) || - ignoreList.some((i) => item.includes(i)) - ) { - continue; + if (type === "Movie") { + downloadedItem = db.movies[id]; + if (downloadedItem) { + delete db.movies[id]; } - await FileSystem.getInfoAsync(`${baseDirectory}${item}`) - .then((itemInfo) => { - if (itemInfo.exists && !itemInfo.isDirectory) { - callback(itemInfo); + } else if (type === "Episode") { + const cleanUpEmptyParents = ( + series: any, + seasonNumber: string, + seriesId: string, + ) => { + if (!Object.keys(series.seasons[seasonNumber].episodes).length) { + delete series.seasons[seasonNumber]; + } + if (!Object.keys(series.seasons).length) { + delete db.series[seriesId]; + } + }; + + for (const [seriesId, series] of Object.entries(db.series)) { + for (const [seasonNumber, season] of Object.entries(series.seasons)) { + for (const [episodeNumber, episode] of Object.entries( + season.episodes, + )) { + if (episode.item.Id === id) { + downloadedItem = episode; + delete season.episodes[Number(episodeNumber)]; + cleanUpEmptyParents(series, seasonNumber, seriesId); + break; + } } - }) - .catch((e) => console.error(e)); - } - }; - - const deleteLocalFiles = async (): Promise => { - await forEveryDocumentDirFile(false, [], (file) => { - console.warn("Deleting file", file.uri); - FileSystem.deleteAsync(file.uri, { idempotent: true }); - }); - }; - - const removeDownloadedItemsFromStorage = async () => { - // delete any saved images first - Promise.all([deleteFileByType("Movie"), deleteFileByType("Episode")]) - .then(() => storage.delete("downloadedItems")) - .catch((reason) => { - console.error("Failed to remove downloadedItems from storage:", reason); - throw reason; - }); - }; - - const cancelAllServerJobs = async (): Promise => { - if (!authHeader) { - throw new Error("No auth header available"); - } - if (!settings?.optimizedVersionsServerUrl) { - console.error("No server URL configured"); - return; - } - - const deviceId = await getOrSetDeviceId(); - if (!deviceId) { - throw new Error("Failed to get device ID"); - } - - try { - await cancelAllJobs({ - authHeader, - url: settings.optimizedVersionsServerUrl, - deviceId, - }); - } catch (error) { - console.error("Failed to cancel all server jobs:", error); - throw error; - } - }; - - const deleteFile = async (id: string): Promise => { - if (!id) { - console.error("Invalid file ID"); - return; - } - - try { - const directory = FileSystem.documentDirectory; - - if (!directory) { - console.error("Document directory not found"); - return; + if (downloadedItem) break; + } + if (downloadedItem) break; } - const dirContents = await FileSystem.readDirectoryAsync(directory); + } - for (const item of dirContents) { - const itemNameWithoutExtension = item.split(".")[0]; - if (itemNameWithoutExtension === id) { - const filePath = `${directory}${item}`; - await FileSystem.deleteAsync(filePath, { idempotent: true }); - break; + if (downloadedItem?.videoFilePath) { + await FileSystem.deleteAsync(downloadedItem.videoFilePath, { + idempotent: true, + }); + } + + if (downloadedItem?.mediaSource?.MediaStreams) { + for (const stream of downloadedItem.mediaSource.MediaStreams) { + if ( + stream.Type === "Subtitle" && + stream.DeliveryMethod === "External" + ) { + await FileSystem.deleteAsync(stream.DeliveryUrl!, { + idempotent: true, + }); } } - - const downloadedItems = storage.getString("downloadedItems"); - if (downloadedItems) { - let items = JSON.parse(downloadedItems) as DownloadedItem[]; - items = items.filter((item) => item.item.Id !== id); - storage.set("downloadedItems", JSON.stringify(items)); - } - - queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); - } catch (error) { - console.error( - `Failed to delete file and storage entry for ID ${id}:`, - error, - ); } + + if (downloadedItem?.trickPlayData?.path) { + await FileSystem.deleteAsync(downloadedItem.trickPlayData.path, { + idempotent: true, + }); + } + + await saveDownloadsDatabase(db); + successHapticFeedback(); }; const deleteItems = async (items: BaseItemDto[]) => { - Promise.all( - items.map((i) => { - if (i.Id) return deleteFile(i.Id); - return; - }), - ).then(() => successHapticFeedback()); - }; - - const cleanCacheDirectory = async () => { - const cacheDir = await FileSystem.getInfoAsync( - APP_CACHE_DOWNLOAD_DIRECTORY, - ); - if (cacheDir.exists) { - const cachedFiles = await FileSystem.readDirectoryAsync( - APP_CACHE_DOWNLOAD_DIRECTORY, - ); - let position = 0; - const batchSize = 3; - - // batching promise.all to avoid OOM - while (position < cachedFiles.length) { - const itemsForBatch = cachedFiles.slice(position, position + batchSize); - await Promise.all( - itemsForBatch.map(async (file) => { - const info = await FileSystem.getInfoAsync( - `${APP_CACHE_DOWNLOAD_DIRECTORY}${file}`, - ); - if (info.exists) { - await FileSystem.deleteAsync(info.uri, { idempotent: true }); - return Promise.resolve(file); - } - return Promise.reject(); - }), - ); - - position += batchSize; + for (const item of items) { + if (item.Id && (item.Type === "Movie" || item.Type === "Episode")) { + await deleteFile(item.Id, item.Type); } } }; + /** Deletes all files */ + const deleteAllFiles = async (): Promise => { + await deleteFileByType("Movie"); + await deleteFileByType("Episode"); + toast.success( + t( + "home.downloads.toasts.all_files_folders_and_jobs_deleted_successfully", + ), + ); + }; + + /** Deletes all files of a given type. */ const deleteFileByType = async (type: BaseItemDto["Type"]) => { - await Promise.all( - downloadedFiles - ?.filter((file) => file.item.Type === type) - ?.flatMap((file) => { - const promises = []; - if (type === "Episode" && file.item.SeriesId) - promises.push(deleteFile(file.item.SeriesId)); - promises.push(deleteFile(file.item.Id!)); - return promises; - }) || [], + const itemsToDelete = downloadedItems?.filter( + (file) => file.item.Type === type, ); + if (itemsToDelete) await deleteItems(itemsToDelete.map((i) => i.item)); }; - const appSizeUsage = useMemo(async () => { - const sizes: number[] = - downloadedFiles?.map((d) => { - return getDownloadedItemSize(d.item.Id!); - }) || []; + /** Returns the size of a downloaded item. */ + const getDownloadedItemSize = (itemId: string): number => { + const downloadedItem = getDownloadedItemById(itemId); + if (!downloadedItem) return 0; - await forEveryDocumentDirFile( - true, - getAllDownloadedItems().map((d) => d.item.Id!), - (file) => { - if (file.exists) { - sizes.push(file.size); + const trickplaySize = downloadedItem.trickPlayData?.size || 0; + return downloadedItem.videoFileSize + trickplaySize; + }; + + /** Updates a downloaded item. */ + const updateDownloadedItem = ( + itemId: string, + updatedItem: DownloadedItem, + ) => { + const db = getDownloadsDatabase(); + if (db.movies[itemId]) { + db.movies[itemId] = updatedItem; + } else { + for (const series of Object.values(db.series)) { + for (const season of Object.values(series.seasons)) { + for (const episode of Object.values(season.episodes)) { + if (episode.item.Id === itemId) { + season.episodes[episode.item.IndexNumber as number] = updatedItem; + } + } } - }, - ).catch((e) => { - console.error(e); - }); - - return sizes.reduce((sum, size) => sum + size, 0); - }, [logs, downloadedFiles, forEveryDocumentDirFile]); - - function getDownloadedItem(itemId: string): DownloadedItem | null { - try { - const downloadedItems = storage.getString("downloadedItems"); - if (downloadedItems) { - const items: DownloadedItem[] = JSON.parse(downloadedItems); - const item = items.find((i) => i.item.Id === itemId); - return item || null; } - return null; - } catch (error) { - console.error(`Failed to retrieve item with ID ${itemId}:`, error); - return null; } - } + saveDownloadsDatabase(db); + }; - function getAllDownloadedItems(): DownloadedItem[] { - try { - const downloadedItems = storage.getString("downloadedItems"); - if (downloadedItems) { - return JSON.parse(downloadedItems) as DownloadedItem[]; - } - return []; - } catch (error) { - console.error("Failed to retrieve downloaded items:", error); - return []; - } - } + /** + * Returns the size of the app and the remaining space on the device. + * @returns The size of the app and the remaining space on the device. + */ + const appSizeUsage = async () => { + const [total, remaining] = await Promise.all([ + FileSystem.getTotalDiskCapacityAsync(), + FileSystem.getFreeDiskStorageAsync(), + ]); - function saveDownloadedItemInfo(item: BaseItemDto, size = 0) { - try { - const downloadedItems = storage.getString("downloadedItems"); - const items: DownloadedItem[] = downloadedItems - ? JSON.parse(downloadedItems) - : []; - - const existingItemIndex = items.findIndex((i) => i.item.Id === item.Id); - - const data = getDownloadItemInfoFromDiskTmp(item.Id!); - - if (!data?.mediaSource) - throw new Error( - "Media source not found in tmp storage. Did you forget to save it before starting download?", - ); - - const newItem = { item, mediaSource: data.mediaSource }; - - if (existingItemIndex !== -1) { - items[existingItemIndex] = newItem; - } else { - items.push(newItem); - } - - deleteDownloadItemInfoFromDiskTmp(item.Id!); - - storage.set("downloadedItems", JSON.stringify(items)); - storage.set(`downloadedItemSize-${item.Id}`, size.toString()); - - queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); - refetch(); - } catch (error) { - console.error( - "Failed to save downloaded item information with media source:", - error, + let appSize = 0; + const downloadedFiles = await FileSystem.readDirectoryAsync( + `${FileSystem.documentDirectory!}`, + ); + for (const file of downloadedFiles) { + const fileInfo = await FileSystem.getInfoAsync( + `${FileSystem.documentDirectory!}${file}`, ); + if (fileInfo.exists) { + appSize += fileInfo.size; + } } - } - - function getDownloadedItemSize(itemId: string): number { - const size = storage.getString(`downloadedItemSize-${itemId}`); - return size ? Number.parseInt(size) : 0; - } + return { total, remaining, app: appSize }; + }; return { processes, startBackgroundDownload, - downloadedFiles, + downloadedFiles: downloadedItems, + getDownloadsDatabase, deleteAllFiles, deleteFile, deleteItems, - saveDownloadedItemInfo, removeProcess, - setProcesses, startDownload, - getDownloadedItem, deleteFileByType, - appSizeUsage, getDownloadedItemSize, + getDownloadedItemById, APP_CACHE_DOWNLOAD_DIRECTORY, cleanCacheDirectory, + updateDownloadedItem, + appSizeUsage, }; } -export function DownloadProvider({ children }: { children: React.ReactNode }) { - const downloadProviderValue = useDownloadProvider(); - - return ( - - {children} - - ); -} - export function useDownload() { - if (Platform.isTV) { - // Since tv doesn't do downloads, just return no-op functions for everything - return { - processes: [], - startBackgroundDownload: useCallback( - async ( - _url: string, - _item: BaseItemDto, - _mediaSource: MediaSourceInfo, - _maxBitrate?: Bitrate, - ) => {}, - [], - ), - downloadedFiles: [], - deleteAllFiles: async (): Promise => {}, - deleteFile: async (id: string): Promise => {}, - deleteItems: async (items: BaseItemDto[]) => {}, - saveDownloadedItemInfo: (item: BaseItemDto, size?: number) => {}, - removeProcess: (id: string) => {}, - setProcesses: () => {}, - startDownload: async (_process: JobStatus): Promise => {}, - getDownloadedItem: (itemId: string) => {}, - deleteFileByType: async (_type: BaseItemDto["Type"]) => {}, - appSizeUsage: async () => 0, - getDownloadedItemSize: (itemId: string) => {}, - APP_CACHE_DOWNLOAD_DIRECTORY: "", - cleanCacheDirectory: async (): Promise => {}, - }; - } - const context = useContext(DownloadContext); if (context === null) { throw new Error("useDownload must be used within a DownloadProvider"); } return context; } + +export function DownloadProvider({ children }: { children: React.ReactNode }) { + const downloadUtils = useDownloadProvider(); + return ( + + {children} + + ); +} diff --git a/providers/Downloads/types.ts b/providers/Downloads/types.ts new file mode 100644 index 00000000..9f823879 --- /dev/null +++ b/providers/Downloads/types.ts @@ -0,0 +1,117 @@ +import type { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { Bitrate } from "@/components/BitrateSelector"; + +/** + * Represents the data for downloaded trickplay files. + */ +export interface TrickPlayData { + /** The local directory path where trickplay image sheets are stored. */ + path: string; + /** The total size of all trickplay images in bytes. */ + size: number; +} + +/** + * Represents the user data for a downloaded item. + */ +interface UserData { + subtitleStreamIndex: number; + /** The last known audio stream index. */ + audioStreamIndex: number; +} + +/** Represents a segment of time in a media item, used for intro/credit skipping. */ +export interface MediaTimeSegment { + startTime: number; + endTime: number; + text: string; +} + +export interface Segment { + startTime: number; + endTime: number; + text: string; +} + +/** Represents a single downloaded media item with all necessary metadata for offline playback. */ +export interface DownloadedItem { + /** The Jellyfin item DTO. */ + item: BaseItemDto; + /** The media source information. */ + mediaSource: MediaSourceInfo; + /** The local file path of the downloaded video. */ + videoFilePath: string; + /** The size of the video file in bytes. */ + videoFileSize: number; + /** The local file path of the downloaded trickplay images. */ + trickPlayData?: TrickPlayData; + /** The intro segments for the item. */ + introSegments?: MediaTimeSegment[]; + /** The credit segments for the item. */ + creditSegments?: MediaTimeSegment[]; + /** The user data for the item. */ + userData: UserData; +} +/** + * Represents a downloaded Season, containing a map of its episodes. + */ +export interface DownloadedSeason { + /** A map of episode numbers to their downloaded item data. */ + episodes: Record; +} + +/** + * Represents a downloaded series, containing seasons and their episodes. + */ +export interface DownloadedSeries { + /** The Jellyfin item DTO for the series. */ + seriesInfo: BaseItemDto; + /** A map of season numbers to their downloaded season data. */ + seasons: Record< + number, + { + /** A map of episode numbers to their downloaded episode data. */ + episodes: Record; + } + >; +} + +/** + * The main structure for all downloaded content stored locally. + * This object is what will be saved to your local storage. + */ +export interface DownloadsDatabase { + /** A map of movie IDs to their downloaded movie data. */ + movies: Record; + /** A map of series IDs to their downloaded series data. */ + series: Record; +} + +/** + * Represents the status of a download job. + */ +export type JobStatus = { + id: string; + inputUrl: string; + item: BaseItemDto; + itemId: string; + deviceId: string; + progress: number; + status: + | "downloading" + | "paused" + | "error" + | "pending" + | "completed" + | "queued"; + timestamp: Date; + mediaSource: MediaSourceInfo; + maxBitrate: Bitrate; + bytesDownloaded?: number; + lastProgressUpdateTime?: Date; + speed?: number; + estimatedTotalSizeBytes?: number; +}; diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 339eeae2..f720a700 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -1,22 +1,16 @@ import "@/augmentations"; -import { useInterval } from "@/hooks/useInterval"; -import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; -import { useSettings } from "@/utils/atoms/settings"; -import { writeErrorLog, writeInfoLog } from "@/utils/log"; -import { storage } from "@/utils/mmkv"; -import { store } from "@/utils/store"; import { type Api, Jellyfin } from "@jellyfin/sdk"; import type { UserDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getUserApi } from "@jellyfin/sdk/lib/utils/api"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; import axios, { AxiosError } from "axios"; import { router, useSegments } from "expo-router"; import * as SplashScreen from "expo-splash-screen"; import { atom, useAtom } from "jotai"; import type React from "react"; import { - type ReactNode, createContext, + type ReactNode, useCallback, useContext, useEffect, @@ -27,6 +21,12 @@ import { useTranslation } from "react-i18next"; import { Platform } from "react-native"; import { getDeviceName } from "react-native-device-info"; import uuid from "react-native-uuid"; +import { useInterval } from "@/hooks/useInterval"; +import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; +import { useSettings } from "@/utils/atoms/settings"; +import { writeErrorLog, writeInfoLog } from "@/utils/log"; +import { storage } from "@/utils/mmkv"; +import { store } from "@/utils/store"; interface Server { address: string; @@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ setJellyfin( () => new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.28.0" }, + clientInfo: { name: "Streamyfin", version: "0.28.1" }, deviceInfo: { name: deviceName, id, @@ -80,9 +80,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const [isPolling, setIsPolling] = useState(false); const [secret, setSecret] = useState(null); const [ - settings, - updateSettings, - pluginSettings, + _settings, + _updateSettings, + _pluginSettings, setPluginSettings, refreshStreamyfinPluginSettings, ] = useSettings(); @@ -91,9 +91,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const headers = useMemo(() => { if (!deviceId) return {}; return { - authorization: `MediaBrowser Client="Streamyfin", Device=${ - Platform.OS === "android" ? "Android" : "iOS" - }, DeviceId="${deviceId}", Version="0.28.0"`, + authorization: `MediaBrowser Client="Streamyfin", Device=${Platform.OS === "android" ? "Android" : "iOS" + }, DeviceId="${deviceId}", Version="0.28.1"`, }; }, [deviceId]); @@ -287,8 +286,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ mutationFn: async () => { api ?.delete(`/Streamyfin/device/${deviceId}`) - .then((r) => writeInfoLog("Deleted expo push token for device")) - .catch((e) => + .then((_r) => writeInfoLog("Deleted expo push token for device")) + .catch((_e) => writeErrorLog("Failed to delete expo push token for device"), ); @@ -380,8 +379,6 @@ function useProtectedRoute(user: UserDto | null, loaded = false) { useEffect(() => { if (loaded === false) return; - console.log("Loaded", user); - const inAuthGroup = segments[0] === "(auth)"; if (!user?.Id && inAuthGroup) { diff --git a/providers/JobQueueProvider.tsx b/providers/JobQueueProvider.tsx deleted file mode 100644 index 232a5f02..00000000 --- a/providers/JobQueueProvider.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { useJobProcessor } from "@/utils/atoms/queue"; -import type React from "react"; -import { createContext } from "react"; - -const JobQueueContext = createContext(null); - -export const JobQueueProvider: React.FC<{ children: React.ReactNode }> = ({ - children, -}) => { - useJobProcessor(); - - return ( - {children} - ); -}; diff --git a/providers/PlaySettingsProvider.tsx b/providers/PlaySettingsProvider.tsx index cf61248d..1cdc2821 100644 --- a/providers/PlaySettingsProvider.tsx +++ b/providers/PlaySettingsProvider.tsx @@ -1,21 +1,14 @@ -import type { Bitrate } from "@/components/BitrateSelector"; -import { settingsAtom } from "@/utils/atoms/settings"; -import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; -import generateDeviceProfile from "@/utils/profiles/native"; import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client"; -import { getSessionApi } from "@jellyfin/sdk/lib/utils/api"; import { useAtomValue } from "jotai"; import type React from "react"; -import { - createContext, - useCallback, - useContext, - useEffect, - useState, -} from "react"; +import { createContext, useCallback, useContext, useState } from "react"; +import type { Bitrate } from "@/components/BitrateSelector"; +import { settingsAtom } from "@/utils/atoms/settings"; +import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; +import { generateDeviceProfile } from "@/utils/profiles/native"; import { apiAtom, userAtom } from "./JellyfinProvider"; export type PlaybackType = { diff --git a/translations/en.json b/translations/en.json index 7b8faad5..ffbb8f41 100644 --- a/translations/en.json +++ b/translations/en.json @@ -408,6 +408,7 @@ "download_episode": "Download Episode", "download_movie": "Download Movie", "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" diff --git a/utils/atoms/filters.ts b/utils/atoms/filters.ts index 4ce23122..df9c5c78 100644 --- a/utils/atoms/filters.ts +++ b/utils/atoms/filters.ts @@ -8,7 +8,6 @@ export enum SortByOption { CommunityRating = "CommunityRating", CriticRating = "CriticRating", DateCreated = "DateCreated", - DateLastContentAdded = "DateLastContentAdded", DatePlayed = "DatePlayed", PlayCount = "PlayCount", ProductionYear = "ProductionYear", @@ -38,7 +37,6 @@ export const sortOptions: { { key: SortByOption.CommunityRating, value: "Community Rating" }, { key: SortByOption.CriticRating, value: "Critics Rating" }, { key: SortByOption.DateCreated, value: "Date Added" }, - { key: SortByOption.DateLastContentAdded, value: "Date Episode Added" }, { key: SortByOption.DatePlayed, value: "Date Played" }, { key: SortByOption.PlayCount, value: "Play Count" }, { key: SortByOption.ProductionYear, value: "Production Year" }, diff --git a/utils/atoms/queue.ts b/utils/atoms/queue.ts index 573d964f..f681ea2d 100644 --- a/utils/atoms/queue.ts +++ b/utils/atoms/queue.ts @@ -1,9 +1,9 @@ -import { processesAtom } from "@/providers/DownloadProvider"; -import { useSettings } from "@/utils/atoms/settings"; -import type { JobStatus } from "@/utils/optimize-server"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { atom, useAtom } from "jotai"; import { useEffect } from "react"; +import { processesAtom } from "@/providers/DownloadProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { JobStatus } from "@/providers/Downloads/types"; export interface Job { id: string; @@ -68,5 +68,5 @@ export const useJobProcessor = () => { console.info("Processing queue", queue); queueActions.processJob(queue, setQueue, setRunning); } - }, [processes, queue, running, setQueue, setRunning]); + }, [processes, queue, running, setQueue, setRunning, settings]); }; diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index d21d3839..54f94db8 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -1,8 +1,3 @@ -import { BITRATES, type Bitrate } from "@/components/BitrateSelector"; -import * as ScreenOrientation from "@/packages/expo-screen-orientation"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { Video } from "@/utils/jellyseerr/server/models/Movie"; -import { writeInfoLog } from "@/utils/log"; import { type BaseItemKind, type CultureDto, @@ -14,9 +9,13 @@ import { 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"; -const STREAMYFIN_PLUGIN_ID = "1e9e5d386e6746158719e98a5c34f004"; +const _STREAMYFIN_PLUGIN_ID = "1e9e5d386e6746158719e98a5c34f004"; const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS"; export type DownloadQuality = "original" | "high" | "low"; @@ -82,7 +81,6 @@ export type DefaultLanguageOption = { export enum DownloadMethod { Remux = "remux", - Optimized = "optimized", } export type Home = { @@ -156,7 +154,6 @@ export type Settings = { defaultVideoOrientation: ScreenOrientation.OrientationLock; forwardSkipTime: number; rewindSkipTime: number; - optimizedVersionsServerUrl?: string | null; downloadMethod: DownloadMethod; autoDownload: boolean; showCustomMenuLinks: boolean; @@ -213,7 +210,6 @@ const defaultValues: Settings = { defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT, forwardSkipTime: 30, rewindSkipTime: 10, - optimizedVersionsServerUrl: null, downloadMethod: DownloadMethod.Remux, autoDownload: false, showCustomMenuLinks: false, @@ -224,7 +220,7 @@ const defaultValues: Settings = { jellyseerrServerUrl: undefined, hiddenLibraries: [], enableH265ForChromecast: false, - defaultPlayer: VideoPlayer.VLC_3, // ios-only setting. does not matter what this is for android + defaultPlayer: VideoPlayer.VLC_4, // ios-only setting. does not matter what this is for android maxAutoPlayEpisodeCount: { key: "3", value: 3 }, autoPlayEpisodeCount: 0, }; @@ -288,7 +284,7 @@ export const useSettings = () => { writeInfoLog("Got plugin settings", data?.settings); return data?.settings; }, - (err) => undefined, + (_err) => undefined, ); setPluginSettings(settings); return settings; diff --git a/utils/jellyfin/getDefaultPlaySettings.ts b/utils/jellyfin/getDefaultPlaySettings.ts index 2c32b615..db3d97c9 100644 --- a/utils/jellyfin/getDefaultPlaySettings.ts +++ b/utils/jellyfin/getDefaultPlaySettings.ts @@ -1,10 +1,11 @@ // utils/getDefaultPlaySettings.ts -import { BITRATES } from "@/components/BitrateSelector"; + import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client"; -import { type Settings, useSettings } from "../atoms/settings"; +import { BITRATES } from "@/components/BitrateSelector"; +import { type Settings } from "../atoms/settings"; import { AudioStreamRanker, StreamRanker, @@ -50,18 +51,9 @@ export function getDefaultPlaySettings( const mediaSource = item.MediaSources?.[0]; - // 2. Get default or preferred audio - const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex; - const preferedAudioIndex = mediaSource?.MediaStreams?.find( - (x) => x.Type === "Audio" && x.Language === settings?.defaultAudioLanguage, - )?.Index; - const firstAudioIndex = mediaSource?.MediaStreams?.find( - (x) => x.Type === "Audio", - )?.Index; - // We prefer the previous track over the default track. const trackOptions: TrackOptions = { - DefaultAudioStreamIndex: defaultAudioIndex ?? -1, + DefaultAudioStreamIndex: mediaSource?.DefaultAudioStreamIndex ?? -1, DefaultSubtitleStreamIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1, }; diff --git a/utils/jellyfin/media/getDownloadUrl.ts b/utils/jellyfin/media/getDownloadUrl.ts new file mode 100644 index 00000000..7812123f --- /dev/null +++ b/utils/jellyfin/media/getDownloadUrl.ts @@ -0,0 +1,68 @@ +import type { Api } from "@jellyfin/sdk"; +import type { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { Bitrate } from "@/components/BitrateSelector"; +import { generateDeviceProfile } from "@/utils/profiles/native"; +import { getDownloadStreamUrl, getStreamUrl } from "./getStreamUrl"; + +export const getDownloadUrl = async ({ + api, + item, + userId, + mediaSource, + maxBitrate, + audioStreamIndex, + subtitleStreamIndex, + deviceId, +}: { + api: Api; + item: BaseItemDto; + userId: string; + mediaSource: MediaSourceInfo; + maxBitrate: Bitrate; + audioStreamIndex: number; + subtitleStreamIndex: number; + deviceId: string; +}): Promise<{ + url: string | null; + mediaSource: MediaSourceInfo | null; +} | null> => { + const streamDetails = await getStreamUrl({ + api, + item, + userId, + startTimeTicks: 0, + mediaSourceId: mediaSource.Id, + maxStreamingBitrate: maxBitrate.value, + audioStreamIndex, + subtitleStreamIndex, + deviceId, + deviceProfile: await generateDeviceProfile(), + }); + + if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) { + console.log("Downloading item directly"); + return { + url: `${api.basePath}/Items/${item.Id}/Download?api_key=${api.accessToken}`, + mediaSource: streamDetails?.mediaSource ?? null, + }; + } + + const downloadStreamDetails = await getDownloadStreamUrl({ + api, + item, + userId, + mediaSourceId: mediaSource.Id, + deviceId, + maxStreamingBitrate: maxBitrate.value, + audioStreamIndex, + subtitleStreamIndex, + }); + + return { + url: downloadStreamDetails?.url ?? null, + mediaSource: downloadStreamDetails?.mediaSource ?? null, + }; +}; diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index 6960c388..bb2be0d3 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -1,12 +1,10 @@ -import generateDeviceProfile from "@/utils/profiles/native"; import type { Api } from "@jellyfin/sdk"; import type { BaseItemDto, MediaSourceInfo, - PlaybackInfoResponse, } from "@jellyfin/sdk/lib/generated-client/models"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; -import { Alert } from "react-native"; +import download from "@/utils/profiles/download"; export const getStreamUrl = async ({ api, @@ -15,11 +13,10 @@ export const getStreamUrl = async ({ startTimeTicks = 0, maxStreamingBitrate, playSessionId, - deviceProfile = generateDeviceProfile(), + deviceProfile, audioStreamIndex = 0, subtitleStreamIndex = undefined, mediaSourceId, - download = false, deviceId, }: { api: Api | null | undefined; @@ -28,12 +25,11 @@ export const getStreamUrl = async ({ startTimeTicks: number; maxStreamingBitrate?: number; playSessionId?: string | null; - deviceProfile?: any; + deviceProfile: any; audioStreamIndex?: number; subtitleStreamIndex?: number; height?: number; mediaSourceId?: string | null; - download?: bool; deviceId?: string | null; }): Promise<{ url: string | null; @@ -73,12 +69,16 @@ export const getStreamUrl = async ({ } sessionId = res.data.PlaySessionId || null; - mediaSource = res.data.MediaSources[0]; - let transcodeUrl = mediaSource.TranscodingUrl; + mediaSource = res.data.MediaSources?.[0]; + let transcodeUrl = mediaSource?.TranscodingUrl; if (transcodeUrl) { - if (download) { - transcodeUrl = transcodeUrl.replace("master.m3u8", "stream"); + // We need to change the subtitle method to hls for the transcoded url. + if (subtitleStreamIndex === -1) { + transcodeUrl = transcodeUrl.replace( + "SubtitleMethod=Encode", + "SubtitleMethod=Hls", + ); } console.log("Video is being transcoded:", transcodeUrl); return { @@ -88,21 +88,6 @@ export const getStreamUrl = async ({ }; } - let downloadParams = {}; - - if (download) { - // We need to disable static so we can have a remux with subtitle. - downloadParams = { - subtitleMethod: "Embed", - enableSubtitlesInManifest: true, - static: "false", - allowVideoStreamCopy: true, - allowAudioStreamCopy: true, - playSessionId: sessionId || "", - container: "ts", - }; - } - const streamParams = new URLSearchParams({ static: "true", container: "mp4", @@ -114,7 +99,6 @@ export const getStreamUrl = async ({ startTimeTicks: startTimeTicks.toString(), maxStreamingBitrate: maxStreamingBitrate?.toString() || "", userId: userId || "", - ...downloadParams, }); const directPlayUrl = `${ @@ -125,7 +109,113 @@ export const getStreamUrl = async ({ return { url: directPlayUrl, - sessionId: sessionId || playSessionId, + sessionId: sessionId || playSessionId || null, + mediaSource, + }; +}; + +export const getDownloadStreamUrl = async ({ + api, + item, + userId, + maxStreamingBitrate, + audioStreamIndex = 0, + subtitleStreamIndex = undefined, + mediaSourceId, + deviceId, +}: { + api: Api | null | undefined; + item: BaseItemDto | null | undefined; + userId: string | null | undefined; + maxStreamingBitrate?: number; + audioStreamIndex?: number; + subtitleStreamIndex?: number; + mediaSourceId?: string | null; + deviceId?: string | null; +}): Promise<{ + url: string | null; + sessionId: string | null; + mediaSource: MediaSourceInfo | undefined; +} | null> => { + if (!api || !userId || !item?.Id) { + console.warn("Missing required parameters for getStreamUrl"); + return null; + } + + let mediaSource: MediaSourceInfo | undefined; + let sessionId: string | null | undefined; + + const res = await getMediaInfoApi(api).getPlaybackInfo( + { + itemId: item.Id!, + }, + { + method: "POST", + data: { + userId, + deviceProfile: download, + subtitleStreamIndex, + startTimeTicks: 0, + isPlayback: true, + autoOpenLiveStream: true, + maxStreamingBitrate, + audioStreamIndex, + mediaSourceId, + }, + }, + ); + + if (res.status !== 200) { + console.error("Error getting playback info:", res.status, res.statusText); + } + + sessionId = res.data.PlaySessionId || null; + mediaSource = res.data.MediaSources?.[0]; + let transcodeUrl = mediaSource?.TranscodingUrl; + + if (transcodeUrl) { + transcodeUrl = transcodeUrl.replace("master.m3u8", "stream"); + console.log("Video is being transcoded:", transcodeUrl); + return { + url: `${api.basePath}${transcodeUrl}`, + sessionId, + mediaSource, + }; + } + + const downloadParams = { + // We need to disable static so we can have a remux with subtitle. + subtitleMethod: "Embed", + enableSubtitlesInManifest: true, + allowVideoStreamCopy: true, + allowAudioStreamCopy: true, + playSessionId: sessionId || "", + }; + + const streamParams = new URLSearchParams({ + static: "false", + container: "ts", + mediaSourceId: mediaSource?.Id || "", + subtitleStreamIndex: subtitleStreamIndex?.toString() || "", + audioStreamIndex: audioStreamIndex?.toString() || "", + deviceId: deviceId || api.deviceInfo.id, + api_key: api.accessToken, + startTimeTicks: "0", + maxStreamingBitrate: maxStreamingBitrate?.toString() || "", + userId: userId || "", + }); + + Object.entries(downloadParams).forEach(([key, value]) => { + streamParams.append(key, value.toString()); + }); + + const directPlayUrl = `${ + api.basePath + }/Videos/${item.Id}/stream?${streamParams.toString()}`; + + return { + url: directPlayUrl, + sessionId: sessionId || null, mediaSource, }; }; diff --git a/utils/jellyfin/playstate/markAsNotPlayed.ts b/utils/jellyfin/playstate/markAsNotPlayed.ts deleted file mode 100644 index 1478c965..00000000 --- a/utils/jellyfin/playstate/markAsNotPlayed.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { Api } from "@jellyfin/sdk"; -import type { AxiosError } from "axios"; - -interface MarkAsNotPlayedParams { - api: Api | null | undefined; - itemId: string | null | undefined; - userId: string | null | undefined; -} - -/** - * Marks a media item as not played for a specific user. - * - * @param params - The parameters for marking an item as not played - * @returns A promise that resolves to true if the operation was successful, false otherwise - */ -export const markAsNotPlayed = async ({ - api, - itemId, - userId, -}: MarkAsNotPlayedParams): Promise => { - if (!api || !itemId || !userId) { - console.error("Invalid parameters for markAsNotPlayed"); - return; - } - - try { - await api.axiosInstance.delete( - `${api.basePath}/UserPlayedItems/${itemId}`, - { - params: { userId }, - headers: { - Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`, - }, - }, - ); - } catch (error) { - const axiosError = error as AxiosError; - console.error( - "Failed to mark item as not played:", - axiosError.message, - axiosError.response?.status, - ); - return; - } -}; diff --git a/utils/jellyfin/playstate/markAsPlayed.ts b/utils/jellyfin/playstate/markAsPlayed.ts deleted file mode 100644 index e17638ec..00000000 --- a/utils/jellyfin/playstate/markAsPlayed.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Api } from "@jellyfin/sdk"; -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api"; - -interface MarkAsPlayedParams { - api: Api | null | undefined; - item: BaseItemDto | null | undefined; - userId: string | null | undefined; -} - -/** - * Marks a media item as played and updates its progress to completion. - * - * @param params - The parameters for marking an item as played∏ - * @returns A promise that resolves to true if the operation was successful, false otherwise - */ -export const markAsPlayed = async ({ - api, - item, - userId, -}: MarkAsPlayedParams): Promise => { - if (!api || !item?.Id || !userId || !item.RunTimeTicks) { - console.error("Invalid parameters for markAsPlayed"); - return false; - } - - try { - const response = await getPlaystateApi(api).markPlayedItem({ - itemId: item.Id, - datePlayed: new Date().toISOString(), - }); - - return response.status === 200; - } catch (error) { - return false; - } -}; diff --git a/utils/jellyfin/playstate/reportPlaybackProgress.ts b/utils/jellyfin/playstate/reportPlaybackProgress.ts deleted file mode 100644 index 76e27c25..00000000 --- a/utils/jellyfin/playstate/reportPlaybackProgress.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { getOrSetDeviceId } from "@/providers/JellyfinProvider"; -import type { Settings } from "@/utils/atoms/settings"; -import old from "@/utils/profiles/old"; -import type { Api } from "@jellyfin/sdk"; -import { DeviceProfile } from "@jellyfin/sdk/lib/generated-client"; -import { - getMediaInfoApi, - getPlaystateApi, - getSessionApi, -} from "@jellyfin/sdk/lib/utils/api"; -import { getAuthHeaders } from "../jellyfin"; -import { postCapabilities } from "../session/capabilities"; - -interface ReportPlaybackProgressParams { - api?: Api | null; - sessionId?: string | null; - itemId?: string | null; - positionTicks?: number | null; - IsPaused?: boolean; - deviceProfile?: Settings["deviceProfile"]; -} - -/** - * Reports playback progress to the Jellyfin server. - * - * @param params - The parameters for reporting playback progress - * @throws {Error} If any required parameter is missing - */ -export const reportPlaybackProgress = async ({ - api, - sessionId, - itemId, - positionTicks, - IsPaused = false, - deviceProfile, -}: ReportPlaybackProgressParams): Promise => { - if (!api || !sessionId || !itemId || !positionTicks) { - return; - } - - console.info("reportPlaybackProgress ~ IsPaused", IsPaused); - - try { - await getPlaystateApi(api).onPlaybackProgress({ - itemId, - audioStreamIndex: 0, - subtitleStreamIndex: 0, - mediaSourceId: itemId, - positionTicks: Math.round(positionTicks), - isPaused: IsPaused, - isMuted: false, - playMethod: "Transcode", - }); - // await api.axiosInstance.post( - // `${api.basePath}/Sessions/Playing/Progress`, - // { - // ItemId: itemId, - // PlaySessionId: sessionId, - // IsPaused, - // PositionTicks: Math.round(positionTicks), - // CanSeek: true, - // MediaSourceId: itemId, - // EventName: "timeupdate", - // }, - // { headers: getAuthHeaders(api) } - // ); - } catch (error) { - console.error(error); - } -}; diff --git a/utils/jellyfin/session/capabilities.ts b/utils/jellyfin/session/capabilities.ts index 50e8bcbd..c6f327e6 100644 --- a/utils/jellyfin/session/capabilities.ts +++ b/utils/jellyfin/session/capabilities.ts @@ -1,7 +1,7 @@ -import type { Settings } from "@/utils/atoms/settings"; -import generateDeviceProfile from "@/utils/profiles/native"; import type { Api } from "@jellyfin/sdk"; import type { AxiosResponse } from "axios"; +import type { Settings } from "@/utils/atoms/settings"; +import { generateDeviceProfile } from "@/utils/profiles/native"; import { getAuthHeaders } from "../jellyfin"; interface PostCapabilitiesParams { @@ -43,14 +43,14 @@ export const postCapabilities = async ({ ], supportsMediaControl: true, id: sessionId, - DeviceProfile: generateDeviceProfile(), + DeviceProfile: await generateDeviceProfile(), }, { headers: getAuthHeaders(api), }, ); return d; - } catch (error) { + } catch (_error) { throw new Error("Failed to mark as not played"); } }; diff --git a/utils/optimize-server.ts b/utils/optimize-server.ts deleted file mode 100644 index 45186ec7..00000000 --- a/utils/optimize-server.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { itemRouter } from "@/components/common/TouchableItemRouter"; -import { DownloadedItem } from "@/providers/DownloadProvider"; -import type { - BaseItemDto, - MediaSourceInfo, -} from "@jellyfin/sdk/lib/generated-client"; -import axios from "axios"; -import { MMKV } from "react-native-mmkv"; -import { writeToLog } from "./log"; - -interface IJobInput { - deviceId?: string | null; - authHeader?: string | null; - url?: string | null; -} - -export interface JobStatus { - id: string; - status: - | "queued" - | "optimizing" - | "completed" - | "failed" - | "cancelled" - | "downloading"; - progress: number; - outputPath: string; - inputUrl: string; - deviceId: string; - itemId: string; - item: BaseItemDto; - speed?: number; - timestamp: Date; - base64Image?: string; -} - -/** - * Fetches all jobs for a specific device. - * - * @param {IGetAllDeviceJobs} params - The parameters for the API request. - * @param {string} params.deviceId - The ID of the device to fetch jobs for. - * @param {string} params.authHeader - The authorization header for the API request. - * @param {string} params.url - The base URL for the API endpoint. - * - * @returns {Promise} A promise that resolves to an array of job statuses. - * - * @throws {Error} Throws an error if the API request fails or returns a non-200 status code. - */ -export async function getAllJobsByDeviceId({ - deviceId, - authHeader, - url, -}: IJobInput): Promise { - const statusResponse = await axios.get(`${url}all-jobs`, { - headers: { - Authorization: authHeader, - }, - params: { - deviceId, - }, - }); - if (statusResponse.status !== 200) { - console.error( - statusResponse.status, - statusResponse.data, - statusResponse.statusText, - ); - throw new Error("Failed to fetch job status"); - } - - return statusResponse.data; -} - -interface ICancelJob { - authHeader: string; - url: string; - id: string; -} - -export async function cancelJobById({ - authHeader, - url, - id, -}: ICancelJob): Promise { - const statusResponse = await axios.delete(`${url}cancel-job/${id}`, { - headers: { - Authorization: authHeader, - }, - }); - if (statusResponse.status !== 200) { - throw new Error("Failed to cancel process"); - } - - return true; -} - -export async function cancelAllJobs({ authHeader, url, deviceId }: IJobInput) { - if (!deviceId) return false; - if (!authHeader) return false; - if (!url) return false; - - try { - await getAllJobsByDeviceId({ - deviceId, - authHeader, - url, - }).then((jobs) => { - for (const job of jobs) { - cancelJobById({ - authHeader, - url, - id: job.id, - }); - } - }); - } catch (error) { - writeToLog("ERROR", "Failed to cancel all jobs", error); - console.error(error); - return false; - } - - return true; -} - -/** - * Fetches statistics for a specific device. - * - * @param {IJobInput} params - The parameters for the API request. - * @param {string} params.deviceId - The ID of the device to fetch statistics for. - * @param {string} params.authHeader - The authorization header for the API request. - * @param {string} params.url - The base URL for the API endpoint. - * - * @returns {Promise} A promise that resolves to the statistics data or null if the request fails. - * - * @throws {Error} Throws an error if any required parameter is missing. - */ -export async function getStatistics({ - authHeader, - url, - deviceId, -}: IJobInput): Promise { - if (!deviceId || !authHeader || !url) { - return null; - } - - try { - const statusResponse = await axios.get(`${url}statistics`, { - headers: { - Authorization: authHeader, - }, - params: { - deviceId, - }, - }); - - return statusResponse.data; - } catch (error) { - console.error("Failed to fetch statistics:", error); - return null; - } -} - -/** - * Saves the download item info to disk - this data is used temporarily to fetch additional download information - * in combination with the optimize server. This is used to not have to send all item info to the optimize server. - * - * @param {BaseItemDto} item - The item to save. - * @param {MediaSourceInfo} mediaSource - The media source of the item. - * @param {string} url - The URL of the item. - * @return {boolean} A promise that resolves when the item info is saved. - */ -export function saveDownloadItemInfoToDiskTmp( - item: BaseItemDto, - mediaSource: MediaSourceInfo, - url: string, -): boolean { - try { - const storage = new MMKV(); - - const downloadInfo = JSON.stringify({ - item, - mediaSource, - url, - }); - - storage.set(`tmp_download_info_${item.Id}`, downloadInfo); - - return true; - } catch (error) { - console.error("Failed to save download item info to disk:", error); - throw error; - } -} - -/** - * Retrieves the download item info from disk. - * - * @param {string} itemId - The ID of the item to retrieve. - * @return {{ - * item: BaseItemDto; - * mediaSource: MediaSourceInfo; - * url: string; - * } | null} The retrieved download item info or null if not found. - */ -export function getDownloadItemInfoFromDiskTmp(itemId: string): { - item: BaseItemDto; - mediaSource: MediaSourceInfo; - url: string; -} | null { - try { - const storage = new MMKV(); - const rawInfo = storage.getString(`tmp_download_info_${itemId}`); - - if (rawInfo) { - return JSON.parse(rawInfo); - } - return null; - } catch (error) { - console.error("Failed to retrieve download item info from disk:", error); - return null; - } -} - -/** - * Deletes the download item info from disk. - * - * @param {string} itemId - The ID of the item to delete. - * @return {boolean} True if the item info was successfully deleted, false otherwise. - */ -export function deleteDownloadItemInfoFromDiskTmp(itemId: string): boolean { - try { - const storage = new MMKV(); - storage.delete(`tmp_download_info_${itemId}`); - return true; - } catch (error) { - console.error("Failed to delete download item info from disk:", error); - return false; - } -} diff --git a/utils/profiles/download.js b/utils/profiles/download.js index 4f0d4d4d..9e9d8fdc 100644 --- a/utils/profiles/download.js +++ b/utils/profiles/download.js @@ -59,80 +59,55 @@ export default { ], SubtitleProfiles: [ // Official foramts - { Format: "vtt", Method: "Embed" }, { Format: "vtt", Method: "Encode" }, - { Format: "webvtt", Method: "Embed" }, { Format: "webvtt", Method: "Encode" }, - { Format: "srt", Method: "Embed" }, { Format: "srt", Method: "Encode" }, - { Format: "subrip", Method: "Embed" }, { Format: "subrip", Method: "Encode" }, - { Format: "ttml", Method: "Embed" }, { Format: "ttml", Method: "Encode" }, - { Format: "dvbsub", Method: "Embed" }, { Format: "dvdsub", Method: "Encode" }, - { Format: "ass", Method: "Embed" }, { Format: "ass", Method: "Encode" }, - { Format: "idx", Method: "Embed" }, { Format: "idx", Method: "Encode" }, - { Format: "pgs", Method: "Embed" }, { Format: "pgs", Method: "Encode" }, - { Format: "pgssub", Method: "Embed" }, { Format: "pgssub", Method: "Encode" }, - { Format: "ssa", Method: "Embed" }, { Format: "ssa", Method: "Encode" }, // Other formats - { Format: "microdvd", Method: "Embed" }, { Format: "microdvd", Method: "Encode" }, - { Format: "mov_text", Method: "Embed" }, { Format: "mov_text", Method: "Encode" }, - { Format: "mpl2", Method: "Embed" }, { Format: "mpl2", Method: "Encode" }, - { Format: "pjs", Method: "Embed" }, { Format: "pjs", Method: "Encode" }, - { Format: "realtext", Method: "Embed" }, { Format: "realtext", Method: "Encode" }, - { Format: "scc", Method: "Embed" }, { Format: "scc", Method: "Encode" }, - { Format: "smi", Method: "Embed" }, { Format: "smi", Method: "Encode" }, - { Format: "stl", Method: "Embed" }, { Format: "stl", Method: "Encode" }, - { Format: "sub", Method: "Embed" }, { Format: "sub", Method: "Encode" }, - { Format: "subviewer", Method: "Embed" }, { Format: "subviewer", Method: "Encode" }, - { Format: "teletext", Method: "Embed" }, { Format: "teletext", Method: "Encode" }, - { Format: "text", Method: "Embed" }, { Format: "text", Method: "Encode" }, - { Format: "vplayer", Method: "Embed" }, { Format: "vplayer", Method: "Encode" }, - { Format: "xsub", Method: "Embed" }, { Format: "xsub", Method: "Encode" }, ], }; diff --git a/utils/profiles/native.js b/utils/profiles/native.js index 909a9971..6d343e44 100644 --- a/utils/profiles/native.js +++ b/utils/profiles/native.js @@ -6,6 +6,7 @@ import DeviceInfo from "react-native-device-info"; * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import MediaTypes from "../../constants/MediaTypes"; +import { getSubtitleProfiles } from "./subtitles"; // Helper function to detect Dolby Vision support const supportsDolbyVision = async () => { @@ -27,13 +28,14 @@ const supportsDolbyVision = async () => { return false; }; -export const generateDeviceProfile = async () => { +export const generateDeviceProfile = async ({ transcode = false } = {}) => { + console.log("generating device profile", { transcode }); const dolbyVisionSupported = await supportsDolbyVision(); /** * Device profile for Native video player */ const profile = { - Name: "1. Vlc Player", + Name: `1. Vlc Player${transcode ? " (Transcoding)" : ""}`, MaxStaticBitrate: 999_999_999, MaxStreamingBitrate: 999_999_999, CodecProfiles: [ @@ -62,7 +64,7 @@ export const generateDeviceProfile = async () => { DirectPlayProfiles: [ { Type: MediaTypes.Video, - Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls", + Container: "mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls", VideoCodec: "h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video", AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma,dts", @@ -79,7 +81,7 @@ export const generateDeviceProfile = async () => { Type: MediaTypes.Video, Context: "Streaming", Protocol: "hls", - Container: "mp4", + Container: transcode ? "fmp4" : "ts", VideoCodec: "h264, hevc", AudioCodec: "aac,mp3,ac3,dts", }, @@ -92,84 +94,7 @@ export const generateDeviceProfile = async () => { MaxAudioChannels: "2", }, ], - SubtitleProfiles: [ - // Official formats - { Format: "vtt", Method: "Embed" }, - { Format: "vtt", Method: "External" }, - - { Format: "webvtt", Method: "Embed" }, - { Format: "webvtt", Method: "External" }, - - { Format: "srt", Method: "Embed" }, - { Format: "srt", Method: "External" }, - - { Format: "subrip", Method: "Embed" }, - { Format: "subrip", Method: "External" }, - - { Format: "ttml", Method: "Embed" }, - { Format: "ttml", Method: "External" }, - - { Format: "dvbsub", Method: "Embed" }, - { Format: "dvdsub", Method: "Encode" }, - - { Format: "ass", Method: "Embed" }, - { Format: "ass", Method: "External" }, - - { Format: "idx", Method: "Embed" }, - { Format: "idx", Method: "Encode" }, - - { Format: "pgs", Method: "Embed" }, - { Format: "pgs", Method: "Encode" }, - - { Format: "pgssub", Method: "Embed" }, - { Format: "pgssub", Method: "Encode" }, - - { Format: "ssa", Method: "Embed" }, - { Format: "ssa", Method: "External" }, - - // Other formats - { Format: "microdvd", Method: "Embed" }, - { Format: "microdvd", Method: "External" }, - - { Format: "mov_text", Method: "Embed" }, - { Format: "mov_text", Method: "External" }, - - { Format: "mpl2", Method: "Embed" }, - { Format: "mpl2", Method: "External" }, - - { Format: "pjs", Method: "Embed" }, - { Format: "pjs", Method: "External" }, - - { Format: "realtext", Method: "Embed" }, - { Format: "realtext", Method: "External" }, - - { Format: "scc", Method: "Embed" }, - { Format: "scc", Method: "External" }, - - { Format: "smi", Method: "Embed" }, - { Format: "smi", Method: "External" }, - - { Format: "stl", Method: "Embed" }, - { Format: "stl", Method: "External" }, - - { Format: "sub", Method: "Embed" }, - { Format: "sub", Method: "External" }, - - { Format: "subviewer", Method: "Embed" }, - { Format: "subviewer", Method: "External" }, - - { Format: "teletext", Method: "Embed" }, - { Format: "teletext", Method: "Encode" }, - - { Format: "text", Method: "Embed" }, - { Format: "text", Method: "External" }, - - { Format: "vplayer", Method: "Embed" }, - { Format: "vplayer", Method: "External" }, - - { Format: "xsub", Method: "Embed" }, - { Format: "xsub", Method: "External" }, - ], + SubtitleProfiles: getSubtitleProfiles(transcode ? "hls" : "External"), }; // Add Dolby Vision restriction if not supported @@ -192,5 +117,5 @@ export const generateDeviceProfile = async () => { }; export default async () => { - return await generateDeviceProfile(); + return await generateDeviceProfile({ transcode: false }); }; diff --git a/utils/profiles/subtitles.js b/utils/profiles/subtitles.js new file mode 100644 index 00000000..7defa380 --- /dev/null +++ b/utils/profiles/subtitles.js @@ -0,0 +1,56 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +const COMMON_SUBTITLE_PROFILES = [ + // Official formats + + { Format: "dvdsub", Method: "Embed" }, + { Format: "dvdsub", Method: "Encode" }, + + { Format: "idx", Method: "Embed" }, + { Format: "idx", Method: "Encode" }, + + { Format: "pgs", Method: "Embed" }, + { Format: "pgs", Method: "Encode" }, + + { Format: "pgssub", Method: "Embed" }, + { Format: "pgssub", Method: "Encode" }, + + { Format: "teletext", Method: "Embed" }, + { Format: "teletext", Method: "Encode" }, +]; + +const VARYING_SUBTITLE_FORMATS = [ + "webvtt", + "vtt", + "srt", + "subrip", + "ttml", + "ass", + "ssa", + "microdvd", + "mov_text", + "mpl2", + "pjs", + "realtext", + "scc", + "smi", + "stl", + "sub", + "subviewer", + "text", + "vplayer", + "xsub", +]; + +export const getSubtitleProfiles = (secondaryMethod) => { + const profiles = [...COMMON_SUBTITLE_PROFILES]; + for (const format of VARYING_SUBTITLE_FORMATS) { + profiles.push({ Format: format, Method: "Embed" }); + profiles.push({ Format: format, Method: secondaryMethod }); + } + return profiles; +}; diff --git a/utils/segments.ts b/utils/segments.ts new file mode 100644 index 00000000..5c36de78 --- /dev/null +++ b/utils/segments.ts @@ -0,0 +1,114 @@ +import { Api } from "@jellyfin/sdk"; +import { useQuery } from "@tanstack/react-query"; +import { useAtom } from "jotai"; +import { useDownload } from "@/providers/DownloadProvider"; +import { DownloadedItem, MediaTimeSegment } from "@/providers/Downloads/types"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { getAuthHeaders } from "./jellyfin/jellyfin"; + +interface IntroTimestamps { + EpisodeId: string; + HideSkipPromptAt: number; + IntroEnd: number; + IntroStart: number; + ShowSkipPromptAt: number; + Valid: boolean; +} + +interface CreditTimestamps { + Introduction: { + Start: number; + End: number; + Valid: boolean; + }; + Credits: { + Start: number; + End: number; + Valid: boolean; + }; +} + +export const useSegments = (itemId: string, isOffline: boolean) => { + const [api] = useAtom(apiAtom); + const { downloadedFiles } = useDownload(); + const downloadedItem = downloadedFiles?.find( + (d: DownloadedItem) => d.item.Id === itemId, + ); + + return useQuery({ + queryKey: ["segments", itemId, isOffline], + queryFn: async () => { + if (isOffline && downloadedItem) { + return getSegmentsForItem(downloadedItem); + } + if (!api) { + throw new Error("API client is not available"); + } + return fetchAndParseSegments(itemId, api); + }, + enabled: !!api, + }); +}; + +export const getSegmentsForItem = ( + item: DownloadedItem, +): { + introSegments: MediaTimeSegment[]; + creditSegments: MediaTimeSegment[]; +} => { + return { + introSegments: item.introSegments || [], + creditSegments: item.creditSegments || [], + }; +}; + +export const fetchAndParseSegments = async ( + itemId: string, + api: Api, +): Promise<{ + introSegments: MediaTimeSegment[]; + creditSegments: MediaTimeSegment[]; +}> => { + const introSegments: MediaTimeSegment[] = []; + const creditSegments: MediaTimeSegment[] = []; + + try { + const [introRes, creditRes] = await Promise.allSettled([ + api.axiosInstance.get( + `${api.basePath}/Episode/${itemId}/IntroTimestamps`, + { + headers: getAuthHeaders(api), + }, + ), + api.axiosInstance.get( + `${api.basePath}/Episode/${itemId}/Timestamps`, + { + headers: getAuthHeaders(api), + }, + ), + ]); + + if (introRes.status === "fulfilled" && introRes.value.data.Valid) { + introSegments.push({ + startTime: introRes.value.data.IntroStart, + endTime: introRes.value.data.IntroEnd, + text: "Intro", + }); + } + + if ( + creditRes.status === "fulfilled" && + creditRes.value.data.Credits.Valid + ) { + creditSegments.push({ + startTime: creditRes.value.data.Credits.Start, + endTime: creditRes.value.data.Credits.End, + text: "Credits", + }); + } + } catch (error) { + console.error("Failed to fetch segments", error); + } + + return { introSegments, creditSegments }; +};