Compare commits

...

71 Commits

Author SHA1 Message Date
1726aed3a0 merge upstream
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (pull_request) Has been cancelled
🤖 iOS IPA Build / 🏗️ Build iOS IPA (pull_request) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (pull_request) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (pull_request) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (pull_request_target) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (pull_request_target) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (pull_request_target) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (pull_request_target) Has been cancelled
2025-08-13 08:50:03 +02:00
Fredrik Burmester
2da861e4c2 fix: android build (?)
We might need to revert this later. this were the required deps updates to make build on android work
2025-08-04 07:32:03 +02:00
Fredrik Burmester
539889b6d9 fix: ios 26 beta black tab screens 2025-08-03 11:12:14 +02:00
Alex Kim
43c8187f52 Fixed out dated docs 2025-08-03 16:30:59 +10:00
Alex Kim
2bf75b02e3 Fixed bug with sync not working 2025-08-03 16:30:16 +10:00
Alex Kim
8213504665 Last minute fixes 2025-08-03 05:28:24 +10:00
Alex Kim
119d6e56c6 WIP 2025-08-03 04:25:37 +10:00
Alex Kim
3a82c9ec21 Revert vlc4 change 2025-08-02 06:18:34 +10:00
Alex Kim
756402fd11 Revert "Go back to vlc4"
This reverts commit b6eb8249b0.
2025-08-02 06:17:24 +10:00
Alex Kim
671a3e2570 WIP 2025-08-02 05:27:14 +10:00
Alex Kim
e9673cca62 WIP 2025-08-02 04:35:29 +10:00
Alex Kim
4aea5c0155 WIP 2025-07-19 00:37:28 +10:00
Alex Kim
d0b1c51fac Refactors 2025-07-18 21:45:35 +10:00
Alex Kim
70a503b8b0 WIP 2025-07-18 20:58:03 +10:00
Alex Kim
7cab178c71 Removed unused types 2025-07-18 19:40:46 +10:00
Alex Kim
3db9810e2f Fixed already watched episode offline 2025-07-18 04:15:03 +10:00
Alex Kim
60c7c88880 WIP 2025-07-18 03:19:31 +10:00
Alex Kim
32a1bbe7de New design for syncing playback 2025-07-18 03:04:21 +10:00
Alex Kim
2342c776f2 Stop cleaning cache directory from showing as toast 2025-07-17 12:19:18 +10:00
Alex Kim
25383edd43 Fix sync when coming back online 2025-07-17 12:03:54 +10:00
Alex Kim
d4a8c5fc7e fix: resolve all biome linting errors 2025-07-17 03:42:00 +10:00
Alex Kim
c010e73097 Fix playback not working for offline content 2025-07-15 00:44:06 +10:00
Alex Kim
270c12c2f2 Add seekable controls back to pip 2025-07-14 20:50:03 +10:00
Alex Kim
f88771acda Merge 2025-07-14 20:42:51 +10:00
Alex Kim
0da89bd6f3 Merge branch 'develop' into fix/vlc4 2025-07-13 19:39:37 +10:00
Alex Kim
501b88a71e Removing seeking functionality 2025-07-13 16:55:49 +10:00
Alex Kim
c71c7e38e1 Merge branch 'develop' into fix/vlc4 2025-07-13 03:00:54 +10:00
Alex Kim
b6eb8249b0 Go back to vlc4 2025-07-13 02:59:45 +10:00
Alex
ebe36774b0 Change to ts (#848)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-07-13 02:59:45 +10:00
Alex
8f943786af Update package json from expo doctor update (#846)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-07-13 02:59:45 +10:00
Fredrik Burmester
a40dfd0d6f chore: remove unnessesary file 2025-07-13 02:59:45 +10:00
Fredrik Burmester
bcd54718c7 chore: version 2025-07-13 02:59:45 +10:00
Fredrik Burmester
3e74bfdeee chore: version 2025-07-13 02:59:45 +10:00
Fredrik Burmester
b96ca1702f chore 2025-07-13 02:59:45 +10:00
Fredrik Burmester
0e8704e9b5 feat: add CodeRabbit configuration for React Native project 2025-07-13 02:59:44 +10:00
Alex
e247438628 Fix orientation race condition (#841)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-07-13 02:59:44 +10:00
arch-fan
bd073ec574 fix: expo issue by updating deps (#823) 2025-07-13 02:59:44 +10:00
renovate[bot]
5a38e29854 chore(deps): update github/codeql-action action to v3.29.2 (#821)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-13 02:59:44 +10:00
renovate[bot]
5d2ce263e2 fix(deps): update dependency com.android.tools.build:gradle to v8.11.0 (#819) 2025-07-13 02:59:44 +10:00
renovate[bot]
e2c8ed7cbe chore(deps): update github/codeql-action action to v3.29.1 (#818) 2025-07-13 02:59:44 +10:00
renovate[bot]
54beb63adc fix(deps): update dependency react-native-safe-area-context to v5.5.0 (#774)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-13 02:59:44 +10:00
renovate[bot]
8b0c1081ed chore(deps): update dependency @react-native-community/cli to v18 (#783)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-13 02:59:44 +10:00
renovate[bot]
924eb6695c fix(deps): update dependency @shopify/flash-list to v1.8.3 (#736)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-13 02:59:44 +10:00
renovate[bot]
bb7f708a68 chore(deps): update dependency @biomejs/biome to v2 (#811)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Gauvino <uruknarb20@gmail.com>
2025-07-13 02:59:44 +10:00
renovate[bot]
436868cac1 chore(deps): update dependency @types/jest to v30 (#812)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-13 02:59:44 +10:00
renovate[bot]
fb3a105bdf chore(deps): update marocchino/sticky-pull-request-comment action to v2.9.3 (#810)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-13 02:59:44 +10:00
Chris
8ee7c57606 docs: Update README.md (#802) 2025-07-13 02:59:44 +10:00
Chris
3e5d5aad9f docs: Clarify legal use of Streamyfin with a piracy disclaimer in README (#801) 2025-07-13 02:59:44 +10:00
renovate[bot]
d9dc2e089a fix(deps): update dependency i18next to v25 (#784) 2025-07-13 02:59:44 +10:00
renovate[bot]
edc3c633f3 fix(deps): update dependency com.android.tools.build:gradle to v8 (#772) 2025-07-13 02:59:44 +10:00
renovate[bot]
afc96cde05 chore(deps): update dependency lint-staged to v16 (#771)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-13 02:59:44 +10:00
Gauvain
4f75cf64dc fix: remove pull request target 2025-07-13 02:59:44 +10:00
renovate[bot]
f0f2bd34ba chore(deps): update github/codeql-action action to v3.29.0 (#769)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-13 02:59:44 +10:00
Gauvain
64b353e683 fix: pr build 2025-07-13 02:59:44 +10:00
renovate[bot]
d6c242d0d5 chore(deps): update dependency node to v22 (#766)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-13 02:59:44 +10:00
renovate[bot]
ab16972921 chore(deps): update github/codeql-action action to v3.28.19 (#763)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-13 02:59:44 +10:00
Gauvain
ef4bb14216 fix: add dashboard for renovate 2025-07-13 02:59:44 +10:00
storm1er
d48398589d feat: Persist ignore safe area accross stream and app restart (#701) 2025-07-13 02:59:44 +10:00
Gauvino
2133b382a1 fix: remove git commit from release sonce it's already present in artifact menu 2025-07-13 02:59:44 +10:00
Gauvino
8c5b9d068d fix: put @main instead of v8 to fix cache problem 2025-07-13 02:59:44 +10:00
Gauvain
26225bbf52 feat: update bun version (#745) 2025-07-13 02:59:44 +10:00
Gauvain
0a9da729a1 refactor: fix the ios-build action (#742) 2025-07-13 02:59:44 +10:00
Fredrik Burmester
cad9472779 chore: version 2025-07-13 02:59:44 +10:00
ec7f99d216 .github/workflows/build-android_Miron.yml aktualisiert
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (push) Successful in 25m6s
🤖 Android APK Build / 🏗️ Build Android APK (pull_request) Successful in 24m27s
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (pull_request) Successful in 26s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (pull_request) Failing after 27s
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (pull_request_target) Has been skipped
🚦 Security & Quality Gate / 📝 Validate PR Title (pull_request_target) Failing after 9s
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (pull_request_target) Failing after 10s
🤖 iOS IPA Build / 🏗️ Build iOS IPA (pull_request) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (pull_request_target) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (pull_request_target) Has been cancelled
2025-06-12 16:24:49 +02:00
9fcd184ad1 .github/workflows/build-android_Miron.yml aktualisiert
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (push) Failing after 24m28s
🤖 Android APK Build / 🏗️ Build Android APK (pull_request) Failing after 24m25s
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (pull_request) Successful in 16s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (pull_request) Failing after 33s
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (pull_request_target) Has been skipped
🚦 Security & Quality Gate / 📝 Validate PR Title (pull_request_target) Failing after 8s
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (pull_request_target) Failing after 15s
🤖 iOS IPA Build / 🏗️ Build iOS IPA (pull_request) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (pull_request_target) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (pull_request_target) Has been cancelled
2025-06-12 14:16:03 +02:00
c8f8661eac .github/workflows/build-android_Miron.yml aktualisiert
Some checks failed
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (pull_request) Successful in 15s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (pull_request) Failing after 26s
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (pull_request_target) Has been skipped
🚦 Security & Quality Gate / 📝 Validate PR Title (pull_request_target) Failing after 6s
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (pull_request_target) Failing after 10s
🤖 iOS IPA Build / 🏗️ Build iOS IPA (pull_request) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (pull_request_target) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (pull_request_target) Has been cancelled
2025-06-12 14:15:00 +02:00
eef9fe397f .github/workflows/build-android_Miron.yml aktualisiert
Some checks failed
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (pull_request) Successful in 16s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (pull_request) Failing after 53s
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (pull_request_target) Has been skipped
🚦 Security & Quality Gate / 📝 Validate PR Title (pull_request_target) Failing after 8s
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (pull_request_target) Failing after 9s
🤖 iOS IPA Build / 🏗️ Build iOS IPA (pull_request) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (pull_request_target) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (pull_request_target) Has been cancelled
2025-06-12 14:12:07 +02:00
a00d15aa5c .github/workflows/build-android_Miron.yml aktualisiert
Some checks failed
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (pull_request) Successful in 19s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (pull_request) Failing after 3m17s
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (pull_request_target) Has been skipped
🚦 Security & Quality Gate / 📝 Validate PR Title (pull_request_target) Failing after 22s
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (pull_request_target) Failing after 34s
🤖 iOS IPA Build / 🏗️ Build iOS IPA (pull_request) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (pull_request_target) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (pull_request_target) Has been cancelled
2025-06-12 14:11:26 +02:00
0b8642a217 .github/workflows/build-android_Miron.yml aktualisiert
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (pull_request) Failing after 12s
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (pull_request) Failing after 2s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (pull_request) Failing after 5s
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (pull_request_target) Has been skipped
🚦 Security & Quality Gate / 📝 Validate PR Title (pull_request_target) Failing after 2s
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (pull_request_target) Failing after 1s
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (pull_request_target) Failing after 2s
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (pull_request_target) Failing after 2s
🤖 iOS IPA Build / 🏗️ Build iOS IPA (pull_request) Has been cancelled
2025-06-12 11:24:33 +02:00
405111a3d3 .github/workflows/build-android_Miron.yml aktualisiert
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (pull_request) Failing after 12s
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (pull_request) Failing after 2s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (pull_request) Failing after 5s
🛎️ Discord Pull Request Notification / notify (pull_request) Failing after 30s
🚦 Security & Quality Gate / 📝 Validate PR Title (pull_request_target) Failing after 18s
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (pull_request_target) Failing after 29s
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (pull_request_target) Failing after 32s
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (pull_request_target) Failing after 1s
🤖 iOS IPA Build / 🏗️ Build iOS IPA (pull_request) Has been cancelled
2025-06-12 10:48:35 +02:00
lostb1t
0a72396a16 fix: loading conditionals (#753) 2025-06-07 13:19:32 +02:00
68 changed files with 2829 additions and 3049 deletions

View File

@@ -7,9 +7,9 @@ concurrency:
on: on:
workflow_dispatch: workflow_dispatch:
pull_request: pull_request:
branches: [develop, master] branches: [develop, master,ninjalama-patch-1]
push: push:
branches: [develop, master] branches: [develop, master, ninjalama-patch-1]
jobs: jobs:
build: build:
@@ -20,7 +20,7 @@ jobs:
steps: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@v4 # v4.2.2
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false show-progress: false
@@ -38,6 +38,9 @@ jobs:
distribution: 'zulu' distribution: 'zulu'
java-version: '17' java-version: '17'
- name: Set up Android SDK
uses: android-actions/setup-android@v2
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with: with:
@@ -70,7 +73,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact - name: 📤 Upload APK artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v3
with: with:
name: streamyfin-apk-${{ env.DATE_TAG }} name: streamyfin-apk-${{ env.DATE_TAG }}
path: | path: |

View File

@@ -4,7 +4,7 @@
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"[typescriptreact]": { "[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "vscode.typescript-language-features",
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"prettier.printWidth": 120, "prettier.printWidth": 120,

View File

@@ -64,12 +64,6 @@ export default function IndexLayout() {
title: t("home.settings.settings_title"), title: t("home.settings.settings_title"),
}} }}
/> />
<Stack.Screen
name='settings/optimized-server/page'
options={{
title: "",
}}
/>
<Stack.Screen <Stack.Screen
name='settings/marlin-search/page' name='settings/marlin-search/page'
options={{ options={{

View File

@@ -1,13 +1,3 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { type DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { writeToLog } from "@/utils/log";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { import {
BottomSheetBackdrop, BottomSheetBackdrop,
@@ -16,30 +6,58 @@ import {
BottomSheetView, BottomSheetView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { useNavigation, useRouter } from "expo-router"; import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native"; import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { useDownload } from "@/providers/DownloadProvider";
import { type DownloadedItem } from "@/providers/Downloads/types";
import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import { writeToLog } from "@/utils/log";
function migration_20241124(
deleteAllFiles: () => Promise<void>,
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() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation(); const { t } = useTranslation();
const [queue, setQueue] = useAtom(queueAtom); const [queue, setQueue] = useAtom(queueAtom);
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload(); const { deleteFileByType, downloadedFiles, removeProcess, deleteAllFiles } = useDownload();
const router = useRouter(); const router = useRouter();
const [settings] = useSettings(); const [settings] = useSettings();
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const movies = useMemo(() => { const movies = useMemo(() => {
try { return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
} catch {
migration_20241124();
return [];
}
}, [downloadedFiles]); }, [downloadedFiles]);
const groupedBySeries = useMemo(() => { const groupedBySeries = useMemo(() => {
@@ -54,12 +72,12 @@ export default function page() {
}); });
return Object.values(series); return Object.values(series);
} catch { } catch {
migration_20241124(); migration_20241124(deleteAllFiles, router, t);
return []; return [];
} }
}, [downloadedFiles]); }, [downloadedFiles, deleteAllFiles, router, t]);
const insets = useSafeAreaInsets(); const _insets = useSafeAreaInsets();
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
@@ -98,16 +116,10 @@ export default function page() {
return ( return (
<> <>
<ScrollView <View style={{ flex: 1 }}>
contentContainerStyle={{ <ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
paddingLeft: insets.left, <View className='py-4'>
paddingRight: insets.right, <View className='mb-4 flex flex-col space-y-4 px-4'>
paddingBottom: 100,
}}
>
<View className='py-4'>
<View className='mb-4 flex flex-col space-y-4 px-4'>
{settings?.downloadMethod === DownloadMethod.Remux && (
<View className='bg-neutral-900 p-4 rounded-2xl'> <View className='bg-neutral-900 p-4 rounded-2xl'>
<Text className='text-lg font-bold'> <Text className='text-lg font-bold'>
{t("home.downloads.queue")} {t("home.downloads.queue")}
@@ -151,70 +163,74 @@ export default function page() {
</Text> </Text>
)} )}
</View> </View>
)}
<ActiveDownloads /> <ActiveDownloads />
</View>
{movies.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.movies")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{movies?.map((item) => (
<View className='mb-2 last:mb-0' key={item.item.Id}>
<MovieCard item={item.item} />
</View>
))}
</View>
</ScrollView>
</View> </View>
)}
{groupedBySeries.length > 0 && ( {movies.length > 0 && (
<View className='mb-4'> <View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'> <View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'> <Text className='text-lg font-bold'>
{t("home.downloads.tvseries")} {t("home.downloads.movies")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{groupedBySeries?.length}
</Text> </Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{movies?.length}</Text>
</View>
</View> </View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{movies?.map((item) => (
<TouchableItemRouter
item={item.item}
isOffline
key={item.item.Id}
>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
</View>
</ScrollView>
</View> </View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}> )}
<View className='px-4 flex flex-row'> {groupedBySeries.length > 0 && (
{groupedBySeries?.map((items) => ( <View className='mb-4'>
<View <View className='flex flex-row items-center justify-between mb-2 px-4'>
className='mb-2 last:mb-0' <Text className='text-lg font-bold'>
key={items[0].item.SeriesId} {t("home.downloads.tvseries")}
> </Text>
<SeriesCard <View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
items={items.map((i) => i.item)} <Text className='text-xs font-bold'>
key={items[0].item.SeriesId} {groupedBySeries?.length}
/> </Text>
</View> </View>
))}
</View> </View>
</ScrollView> <ScrollView horizontal showsHorizontalScrollIndicator={false}>
</View> <View className='px-4 flex flex-row'>
)} {groupedBySeries?.map((items) => (
{downloadedFiles?.length === 0 && ( <View
<View className='flex px-4'> className='mb-2 last:mb-0'
<Text className='opacity-50'> key={items[0].item.SeriesId}
{t("home.downloads.no_downloaded_items")} >
</Text> <SeriesCard
</View> items={items.map((i) => i.item)}
)} key={items[0].item.SeriesId}
</View> />
</ScrollView> </View>
))}
</View>
</ScrollView>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className='flex px-4'>
<Text className='opacity-50'>
{t("home.downloads.no_downloaded_items")}
</Text>
</View>
)}
</View>
</ScrollView>
</View>
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enableDynamicSizing enableDynamicSizing
@@ -249,23 +265,3 @@ export default function page() {
</> </>
); );
} }
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(),
},
],
);
}

View File

@@ -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<string>(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 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<TouchableOpacity
onPress={() => onSave(optimizedVersionsServerUrl)}
>
<Text className='text-blue-500'>
{t("home.settings.downloads.save_button")}
</Text>
</TouchableOpacity>
),
});
}
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
return (
<DisabledSetting
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
className='p-4'
>
<OptimizedServerForm
value={optimizedVersionsServerUrl}
onChangeValue={setOptimizedVersionsServerUrl}
/>
</DisabledSetting>
);
}

View File

@@ -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 { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import type React from "react"; import type React from "react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -15,29 +9,18 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } 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 Page: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { id } = useLocalSearchParams() as { id: string }; const { id } = useLocalSearchParams() as { id: string };
const { t } = useTranslation(); const { t } = useTranslation();
const { data: item, isError } = useQuery({ const { offline } = useLocalSearchParams() as { offline?: string };
queryKey: ["item", id], const isOffline = offline === "true";
queryFn: async () => {
if (!api || !user || !id) return;
const res = await getUserLibraryApi(api).getItem({
itemId: id,
userId: user?.Id,
});
return res.data; const { data: item, isError } = useItemQuery(id, isOffline);
},
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
});
const opacity = useSharedValue(1); const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => { const animatedStyle = useAnimatedStyle(() => {
@@ -107,7 +90,7 @@ const Page: React.FC = () => {
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' /> <View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' /> <View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
</Animated.View> </Animated.View>
{item && <ItemContent item={item} />} {item && <ItemContent item={item} isOffline={isOffline} />}
</View> </View>
); );
}; };

View File

@@ -69,10 +69,16 @@ const page: React.FC = () => {
seriesId: item?.Id!, seriesId: item?.Id!,
userId: user?.Id!, userId: user?.Id!,
enableUserData: true, 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 || []; 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, staleTime: 60,
enabled: !!api && !!user?.Id && !!item?.Id, enabled: !!api && !!user?.Id && !!item?.Id,
}); });
@@ -136,7 +142,7 @@ const page: React.FC = () => {
resizeMode: "contain", resizeMode: "contain",
}} }}
/> />
) : null ) : undefined
} }
> >
<View className='flex flex-col pt-4'> <View className='flex flex-col pt-4'>

View File

@@ -2,7 +2,6 @@ import {
type BaseItemDto, type BaseItemDto,
type MediaSourceInfo, type MediaSourceInfo,
PlaybackOrder, PlaybackOrder,
type PlaybackProgressInfo,
PlaybackStartInfo, PlaybackStartInfo,
RepeatMode, RepeatMode,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
@@ -22,8 +21,8 @@ import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls"; import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets"; import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules"; import { VlcPlayerView } from "@/modules";
@@ -33,18 +32,16 @@ import type {
ProgressUpdatePayload, ProgressUpdatePayload,
VlcPlayerViewRef, VlcPlayerViewRef,
} from "@/modules/VlcPlayer.types"; } from "@/modules/VlcPlayer.types";
import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import generateDeviceProfile from "@/utils/profiles/native"; import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time"; import { msToTicks, ticksToSeconds } from "@/utils/time";
const downloadProvider = !Platform.isTV
? require("@/providers/DownloadProvider")
: { useDownload: () => null };
const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas"; const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas";
export default function page() { export default function page() {
@@ -74,7 +71,7 @@ export default function page() {
? null ? null
: require("react-native-volume-manager"); : require("react-native-volume-manager");
const getDownloadedItem = downloadProvider.useDownload(); const downloadUtils = useDownload();
const revalidateProgressCache = useInvalidatePlaybackProgressCache(); const revalidateProgressCache = useInvalidatePlaybackProgressCache();
@@ -111,6 +108,7 @@ export default function page() {
const [settings] = useSettings(); const [settings] = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const offline = offlineStr === "true"; const offline = offlineStr === "true";
const playbackManager = usePlaybackManager();
const audioIndex = audioIndexStr const audioIndex = audioIndexStr
? Number.parseInt(audioIndexStr, 10) ? Number.parseInt(audioIndexStr, 10)
@@ -123,18 +121,21 @@ export default function page() {
: BITRATES[0].value; : BITRATES[0].value;
const [item, setItem] = useState<BaseItemDto | null>(null); const [item, setItem] = useState<BaseItemDto | null>(null);
const [downloadedItem, setDownloadedItem] = useState<DownloadedItem | null>(
null,
);
const [itemStatus, setItemStatus] = useState({ const [itemStatus, setItemStatus] = useState({
isLoading: true, isLoading: true,
isError: false, isError: false,
}); });
/** Gets the initial playback position from the URL or the item's user data. */ /** Gets the initial playback position from the URL. */
const getInitialPlaybackTicks = useCallback((): number => { const getInitialPlaybackTicks = useCallback((): number => {
if (playbackPositionFromUrl) { if (playbackPositionFromUrl) {
return Number.parseInt(playbackPositionFromUrl, 10); return Number.parseInt(playbackPositionFromUrl, 10);
} }
return item?.UserData?.PlaybackPositionTicks ?? 0; return 0;
}, [playbackPositionFromUrl, item]); }, [playbackPositionFromUrl]);
useEffect(() => { useEffect(() => {
const fetchItemData = async () => { const fetchItemData = async () => {
@@ -142,8 +143,11 @@ export default function page() {
try { try {
let fetchedItem: BaseItemDto | null = null; let fetchedItem: BaseItemDto | null = null;
if (offline && !Platform.isTV) { if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId); const data = downloadUtils.getDownloadedItemById(itemId);
if (data) fetchedItem = data.item as BaseItemDto; if (data) {
fetchedItem = data.item as BaseItemDto;
setDownloadedItem(data);
}
} else { } else {
const res = await getUserLibraryApi(api!).getItem({ const res = await getUserLibraryApi(api!).getItem({
itemId, itemId,
@@ -179,17 +183,21 @@ export default function page() {
useEffect(() => { useEffect(() => {
const fetchStreamData = async () => { const fetchStreamData = async () => {
setStreamStatus({ isLoading: true, isError: false }); setStreamStatus({ isLoading: true, isError: false });
const native = await generateDeviceProfile();
try { try {
let result: Stream | null = null; let result: Stream | null = null;
if (offline && !Platform.isTV) { if (offline && downloadedItem) {
const data = await getDownloadedItem.getDownloadedItem(itemId); if (!downloadedItem?.mediaSource) return;
if (!data?.mediaSource) return; const url = downloadedItem.videoFilePath;
const url = await getDownloadedFileUrl(data.item.Id!);
if (item) { if (item) {
result = { mediaSource: data.mediaSource, sessionId: "", url }; result = {
mediaSource: downloadedItem.mediaSource,
sessionId: "",
url: url,
};
} }
} else { } else {
const native = await generateDeviceProfile();
const transcoding = await generateDeviceProfile({ transcode: true });
const res = await getStreamUrl({ const res = await getStreamUrl({
api, api,
item, item,
@@ -199,7 +207,7 @@ export default function page() {
maxStreamingBitrate: bitrateValue, maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex, subtitleStreamIndex: subtitleIndex,
deviceProfile: native, deviceProfile: bitrateValue ? transcoding : native,
}); });
if (!res) return; if (!res) return;
const { mediaSource, sessionId, url } = res; const { mediaSource, sessionId, url } = res;
@@ -220,26 +228,36 @@ export default function page() {
} }
}; };
fetchStreamData(); fetchStreamData();
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]); }, [
itemId,
mediaSourceId,
bitrateValue,
api,
item,
user?.Id,
downloadedItem,
]);
useEffect(() => { useEffect(() => {
if (!stream) return; if (!stream || !api) return;
const reportPlaybackStart = async () => { const reportPlaybackStart = async () => {
await getPlaystateApi(api!).reportPlaybackStart({ console.log("reporting playback start", currentPlayStateInfo());
await getPlaystateApi(api).reportPlaybackStart({
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo, playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
}); });
}; };
reportPlaybackStart(); reportPlaybackStart();
}, [stream]); }, [stream, api]);
const togglePlay = async () => { const togglePlay = async () => {
lightHapticFeedback(); lightHapticFeedback();
setIsPlaying(!isPlaying); setIsPlaying(!isPlaying);
if (isPlaying) { if (isPlaying) {
await videoRef.current?.pause(); await videoRef.current?.pause();
reportPlaybackProgress(); playbackManager.reportPlaybackProgress(
item?.Id!,
msToTicks(progress.get()),
);
} else { } else {
videoRef.current?.play(); videoRef.current?.play();
await getPlaystateApi(api!).reportPlaybackStart({ await getPlaystateApi(api!).reportPlaybackStart({
@@ -249,7 +267,6 @@ export default function page() {
}; };
const reportPlaybackStopped = useCallback(async () => { const reportPlaybackStopped = useCallback(async () => {
if (offline) return;
const currentTimeInTicks = msToTicks(progress.get()); const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({ await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!, itemId: item?.Id!,
@@ -257,8 +274,6 @@ export default function page() {
positionTicks: currentTimeInTicks, positionTicks: currentTimeInTicks,
playSessionId: stream?.sessionId!, playSessionId: stream?.sessionId!,
}); });
revalidateProgressCache();
}, [ }, [
api, api,
item, item,
@@ -273,6 +288,7 @@ export default function page() {
reportPlaybackStopped(); reportPlaybackStopped();
setIsPlaybackStopped(true); setIsPlaybackStopped(true);
videoRef.current?.stop(); videoRef.current?.stop();
revalidateProgressCache();
}, [videoRef, reportPlaybackStopped]); }, [videoRef, reportPlaybackStopped]);
useEffect(() => { useEffect(() => {
@@ -316,10 +332,16 @@ export default function page() {
playbackPosition: msToTicks(currentTime).toString(), playbackPosition: msToTicks(currentTime).toString(),
}); });
if (offline) return; if (!item?.Id) return;
if (!item?.Id || !stream) return;
reportPlaybackProgress(); playbackManager.reportPlaybackProgress(
item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
}, },
[ [
item?.Id, item?.Id,
@@ -339,28 +361,10 @@ export default function page() {
setIsPipStarted(pipStarted); setIsPipStarted(pipStarted);
}, []); }, []);
const reportPlaybackProgress = useCallback(async () => {
if (!api || offline || !stream) return;
await getPlaystateApi(api).reportPlaybackProgress({
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
});
}, [
api,
isPlaying,
offline,
stream,
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
progress,
]);
/** Gets the initial playback position in seconds. */ /** Gets the initial playback position in seconds. */
const startPosition = useMemo(() => { const startPosition = useMemo(() => {
if (offline) return 0;
return ticksToSeconds(getInitialPlaybackTicks()); return ticksToSeconds(getInitialPlaybackTicks());
}, [offline, getInitialPlaybackTicks]); }, [getInitialPlaybackTicks]);
const volumeUpCb = useCallback(async () => { const volumeUpCb = useCallback(async () => {
if (Platform.isTV) return; if (Platform.isTV) return;
@@ -445,14 +449,24 @@ export default function page() {
const { state, isBuffering, isPlaying } = e.nativeEvent; const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") { if (state === "Playing") {
setIsPlaying(true); setIsPlaying(true);
reportPlaybackProgress(); if (item?.Id) {
playbackManager.reportPlaybackProgress(
item.Id,
msToTicks(progress.get()),
);
}
if (!Platform.isTV) await activateKeepAwakeAsync(); if (!Platform.isTV) await activateKeepAwakeAsync();
return; return;
} }
if (state === "Paused") { if (state === "Paused") {
setIsPlaying(false); setIsPlaying(false);
reportPlaybackProgress(); if (item?.Id) {
playbackManager.reportPlaybackProgress(
item.Id,
msToTicks(progress.get()),
);
}
if (!Platform.isTV) await deactivateKeepAwake(); if (!Platform.isTV) await deactivateKeepAwake();
return; return;
} }
@@ -464,7 +478,7 @@ export default function page() {
setIsBuffering(true); setIsBuffering(true);
} }
}, },
[reportPlaybackProgress], [playbackManager, item?.Id, progress],
); );
const allAudio = const allAudio =
@@ -482,25 +496,29 @@ export default function page() {
.filter((sub: any) => sub.DeliveryMethod === "External") .filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({ .map((sub: any) => ({
name: sub.DisplayTitle, name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl, DeliveryUrl: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl,
})); }));
/** The text based subtitle tracks */
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream); const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
/** The user chosen subtitle track from the server */
const chosenSubtitleTrack = allSubs.find( const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex, (sub) => sub.Index === subtitleIndex,
); );
/** The user chosen audio track from the server */
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex); const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
/** Whether the stream we're playing is not transcoding*/
const notTranscoding = !stream?.mediaSource.TranscodingUrl; const notTranscoding = !stream?.mediaSource.TranscodingUrl;
/** The initial options to pass to the VLC Player */
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`]; const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
if ( if (
chosenSubtitleTrack && chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream) (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
) { ) {
// If not transcoding, we can the index as normal.
// If transcoding, we need to reverse the text based subtitles, because VLC reverses the HLS subtitles.
const finalIndex = notTranscoding const finalIndex = notTranscoding
? allSubs.indexOf(chosenSubtitleTrack) ? allSubs.indexOf(chosenSubtitleTrack)
: textSubs.indexOf(chosenSubtitleTrack); : [...textSubs].reverse().indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`); initOptions.push(`--sub-track=${finalIndex}`);
} }
@@ -516,7 +534,7 @@ export default function page() {
return () => setIsMounted(false); return () => setIsMounted(false);
}, []); }, []);
if (itemStatus.isLoading || streamStatus.isLoading) { if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
return ( return (
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'> <View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
<Loader /> <Loader />
@@ -550,7 +568,7 @@ export default function page() {
source={{ source={{
uri: stream?.url || "", uri: stream?.url || "",
autoplay: true, autoplay: true,
isNetwork: true, isNetwork: !offline,
startPosition, startPosition,
externalSubtitles, externalSubtitles,
initOptions, initOptions,

View File

@@ -7,7 +7,6 @@ import {
getOrSetDeviceId, getOrSetDeviceId,
getTokenFromStorage, getTokenFromStorage,
} from "@/providers/JellyfinProvider"; } from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { type Settings, useSettings } from "@/utils/atoms/settings"; import { type Settings, useSettings } from "@/utils/atoms/settings";
@@ -23,7 +22,6 @@ import {
writeToLog, writeToLog,
} from "@/utils/log"; } from "@/utils/log";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
@@ -137,16 +135,13 @@ if (!Platform.isTV) {
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
console.log("TaskManager ~ trigger"); console.log("TaskManager ~ trigger");
const now = Date.now();
const settingsData = storage.getString("settings"); const settingsData = storage.getString("settings");
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData; if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
const settings: Partial<Settings> = JSON.parse(settingsData); const settings: Partial<Settings> = JSON.parse(settingsData);
const url = settings?.optimizedVersionsServerUrl;
if (!settings?.autoDownload || !url) if (!settings?.autoDownload)
return BackgroundFetch.BackgroundFetchResult.NoData; return BackgroundFetch.BackgroundFetchResult.NoData;
const token = getTokenFromStorage(); const token = getTokenFromStorage();
@@ -156,74 +151,6 @@ if (!Platform.isTV) {
if (!token || !deviceId || !baseDirectory) if (!token || !deviceId || !baseDirectory)
return BackgroundFetch.BackgroundFetchResult.NoData; return BackgroundFetch.BackgroundFetchResult.NoData;
const jobs = await getAllJobsByDeviceId({
deviceId,
authHeader: token,
url,
});
console.log("TaskManager ~ Active jobs: ", jobs.length);
for (const job of jobs) {
if (job.status === "completed") {
const downloadUrl = `${url}download/${job.id}`;
const tasks = await BackGroundDownloader.checkForExistingDownloads();
if (tasks.find((task: { id: string }) => task.id === job.id)) {
console.log("TaskManager ~ Download already in progress: ", job.id);
continue;
}
BackGroundDownloader.download({
id: job.id,
url: downloadUrl,
destination: `${baseDirectory}${job.item.Id}.mp4`,
headers: {
Authorization: token,
},
})
.begin(() => {
console.log("TaskManager ~ Download started: ", job.id);
})
.done(() => {
console.log("TaskManager ~ Download completed: ", job.id);
saveDownloadedItemInfo(job.item);
BackGroundDownloader.completeHandler(job.id);
cancelJobById({
authHeader: token,
id: job.id,
url: url,
});
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: "Download completed",
data: {
url: "/downloads",
},
},
trigger: null,
});
})
.error((error: any) => {
console.log("TaskManager ~ Download error: ", job.id, error);
BackGroundDownloader.completeHandler(job.id);
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: "Download failed",
data: {
url: "/downloads",
},
},
trigger: null,
});
});
}
}
console.log(`Auto download started: ${new Date(now).toISOString()}`);
// Be sure to return the successful result type! // Be sure to return the successful result type!
return BackgroundFetch.BackgroundFetchResult.NewData; return BackgroundFetch.BackgroundFetchResult.NewData;
}); });
@@ -464,64 +391,62 @@ function Layout() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<JobQueueProvider> <JellyfinProvider>
<JellyfinProvider> <PlaySettingsProvider>
<PlaySettingsProvider> <LogProvider>
<LogProvider> <WebSocketProvider>
<WebSocketProvider> <DownloadProvider>
<DownloadProvider> <BottomSheetModalProvider>
<BottomSheetModalProvider> <SystemBars style='light' hidden={false} />
<SystemBars style='light' hidden={false} /> <ThemeProvider value={DarkTheme}>
<ThemeProvider value={DarkTheme}> <Stack initialRouteName='(auth)/(tabs)'>
<Stack initialRouteName='(auth)/(tabs)'> <Stack.Screen
<Stack.Screen name='(auth)/(tabs)'
name='(auth)/(tabs)' options={{
options={{ headerShown: false,
headerShown: false, title: "",
title: "", header: () => null,
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}} }}
closeButton
/> />
</ThemeProvider> <Stack.Screen
</BottomSheetModalProvider> name='(auth)/player'
</DownloadProvider> options={{
</WebSocketProvider> headerShown: false,
</LogProvider> title: "",
</PlaySettingsProvider> header: () => null,
</JellyfinProvider> }}
</JobQueueProvider> />
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</JellyfinProvider>
</QueryClientProvider> </QueryClientProvider>
); );
} }

View File

@@ -4,7 +4,7 @@
"": { "": {
"name": "streamyfin", "name": "streamyfin",
"dependencies": { "dependencies": {
"@bottom-tabs/react-navigation": "0.8.6", "@bottom-tabs/react-navigation": "0.9.2",
"@expo/config-plugins": "~9.0.15", "@expo/config-plugins": "~9.0.15",
"@expo/react-native-action-sheet": "^4.1.1", "@expo/react-native-action-sheet": "^4.1.1",
"@expo/vector-icons": "^14.0.4", "@expo/vector-icons": "^14.0.4",
@@ -59,27 +59,27 @@
"react-i18next": "^15.4.0", "react-i18next": "^15.4.0",
"react-native": "npm:react-native-tvos@~0.77.2-0", "react-native": "npm:react-native-tvos@~0.77.2-0",
"react-native-awesome-slider": "^2.9.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-circular-progress": "^1.4.1",
"react-native-collapsible": "^1.6.2", "react-native-collapsible": "^1.6.2",
"react-native-compressor": "^1.10.3", "react-native-compressor": "^1.10.3",
"react-native-country-flag": "^2.0.2", "react-native-country-flag": "^2.0.2",
"react-native-device-info": "^14.0.4", "react-native-device-info": "^14.0.4",
"react-native-edge-to-edge": "^1.4.3", "react-native-edge-to-edge": "^1.4.3",
"react-native-gesture-handler": "~2.20.2", "react-native-gesture-handler": "~2.24.0",
"react-native-get-random-values": "^1.11.0", "react-native-get-random-values": "^1.11.0",
"react-native-google-cast": "^4.8.3", "react-native-google-cast": "^4.8.3",
"react-native-image-colors": "^2.4.0", "react-native-image-colors": "^2.4.0",
"react-native-ios-context-menu": "^3.1.0", "react-native-ios-context-menu": "^3.1.0",
"react-native-ios-utilities": "5.1.1", "react-native-ios-utilities": "5.1.1",
"react-native-mmkv": "^2.12.2", "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-progress": "^5.0.1",
"react-native-reanimated": "~3.16.7", "react-native-reanimated": "~3.16.7",
"react-native-reanimated-carousel": "3.5.1", "react-native-reanimated-carousel": "3.5.1",
"react-native-safe-area-context": "4.12.0", "react-native-safe-area-context": "5.2.0",
"react-native-screens": "~4.4.0", "react-native-screens": "4.10.0",
"react-native-svg": "15.8.0", "react-native-svg": "15.11.2",
"react-native-tab-view": "^4.0.5", "react-native-tab-view": "^4.0.5",
"react-native-udp": "^4.1.7", "react-native-udp": "^4.1.7",
"react-native-uitextview": "^1.4.0", "react-native-uitextview": "^1.4.0",
@@ -88,7 +88,7 @@
"react-native-video": "6.10.0", "react-native-video": "6.10.0",
"react-native-volume-manager": "^2.0.8", "react-native-volume-manager": "^2.0.8",
"react-native-web": "~0.19.13", "react-native-web": "~0.19.13",
"react-native-webview": "13.12.5", "react-native-webview": "13.13.0",
"sonner-native": "^0.17.0", "sonner-native": "^0.17.0",
"tailwindcss": "3.3.2", "tailwindcss": "3.3.2",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
@@ -397,7 +397,7 @@
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.0.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Sw3rz2m6bBADeQpr3+MD7Ch4E1l15DTt/+dfqKnwkm3cn4BrYwnArmvKeZdVsFRDjMyjlKIP88bw1r7o+9aqzw=="], "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.0.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Sw3rz2m6bBADeQpr3+MD7Ch4E1l15DTt/+dfqKnwkm3cn4BrYwnArmvKeZdVsFRDjMyjlKIP88bw1r7o+9aqzw=="],
"@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@0.8.6", "", { "dependencies": { "color": "^4.2.3" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-hLlyBAUz4ahaVK2Op2VcJeAkCSpm3KKho4IojkPyXsos4WEHtO44EYWC71TDbVGeOP5HQ9k7FSwAW3IiZs0wHw=="], "@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@0.9.2", "", { "dependencies": { "color": "^4.2.3" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-IZZKllcaqCGsKIgeXmYFGU95IXxbBpXtwKws4Lg2GJw/qqAYYsPFEl0JBvnymSD7G1zkHYEilg5UHuTd0NmX7A=="],
"@dominicstop/ts-event-emitter": ["@dominicstop/ts-event-emitter@1.1.0", "", {}, "sha512-CcxmJIvUb1vsFheuGGVSQf4KdPZC44XolpUT34+vlal+LyQoBUOn31pjFET5M9ctOxEpt8xa0M3/2M7uUiAoJw=="], "@dominicstop/ts-event-emitter": ["@dominicstop/ts-event-emitter@1.1.0", "", {}, "sha512-CcxmJIvUb1vsFheuGGVSQf4KdPZC44XolpUT34+vlal+LyQoBUOn31pjFET5M9ctOxEpt8xa0M3/2M7uUiAoJw=="],
@@ -1843,7 +1843,7 @@
"react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="], "react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="],
"react-native-bottom-tabs": ["react-native-bottom-tabs@0.8.6", "", { "dependencies": { "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-N5b3MoSfsEqlmvFyIyL0X0bd+QAtB+cXH1rl/+R2Kr0BefBTC7ZldGcPhgK3FhBbt0vJDpd3kLb/dvmqZd+Eag=="], "react-native-bottom-tabs": ["react-native-bottom-tabs@0.9.2", "", { "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-kwLx9OM6v5P10TdmNhlEgb8nmwBOpwy3ULIxEv1v6cYjzuRkeYtA2dqYeFhJAn1rmWMrl3MnL3xzW5Q3IQyfAg=="],
"react-native-circular-progress": ["react-native-circular-progress@1.4.1", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=16.0.0", "react-native": ">=0.50.0", "react-native-svg": ">=7.0.0" } }, "sha512-HEzvI0WPuWvsCgWE3Ff2HBTMgAEQB2GvTFw0KHyD/t1STAlDDRiolu0mEGhVvihKR3jJu3v3V4qzvSklY/7XzQ=="], "react-native-circular-progress": ["react-native-circular-progress@1.4.1", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=16.0.0", "react-native": ">=0.50.0", "react-native-svg": ">=7.0.0" } }, "sha512-HEzvI0WPuWvsCgWE3Ff2HBTMgAEQB2GvTFw0KHyD/t1STAlDDRiolu0mEGhVvihKR3jJu3v3V4qzvSklY/7XzQ=="],
@@ -1857,7 +1857,7 @@
"react-native-edge-to-edge": ["react-native-edge-to-edge@1.4.3", "", { "peerDependencies": { "react": ">=18.2.0", "react-native": ">=0.74.0" } }, "sha512-fYchwiQ2D/8NzcvJK1sD9Cm25GFQfsLgYmGpohoSpRxwBwR5UCL0wUf4scoQgYncRh9Hmc2t8ml/sikTwMM3ng=="], "react-native-edge-to-edge": ["react-native-edge-to-edge@1.4.3", "", { "peerDependencies": { "react": ">=18.2.0", "react-native": ">=0.74.0" } }, "sha512-fYchwiQ2D/8NzcvJK1sD9Cm25GFQfsLgYmGpohoSpRxwBwR5UCL0wUf4scoQgYncRh9Hmc2t8ml/sikTwMM3ng=="],
"react-native-gesture-handler": ["react-native-gesture-handler@2.20.2", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4", "prop-types": "^15.7.2" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-HqzFpFczV4qCnwKlvSAvpzEXisL+Z9fsR08YV5LfJDkzuArMhBu2sOoSPUF/K62PCoAb+ObGlTC83TKHfUd0vg=="], "react-native-gesture-handler": ["react-native-gesture-handler@2.24.0", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ZdWyOd1C8axKJHIfYxjJKCcxjWEpUtUWgTOVY2wynbiveSQDm8X/PDyAKXSer/GOtIpjudUbACOndZXCN3vHsw=="],
"react-native-get-random-values": ["react-native-get-random-values@1.11.0", "", { "dependencies": { "fast-base64-decode": "^1.0.0" }, "peerDependencies": { "react-native": ">=0.56" } }, "sha512-4BTbDbRmS7iPdhYLRcz3PGFIpFJBwNZg9g42iwa2P6FOv9vZj/xJc678RZXnLNZzd0qd7Q3CCF6Yd+CU2eoXKQ=="], "react-native-get-random-values": ["react-native-get-random-values@1.11.0", "", { "dependencies": { "fast-base64-decode": "^1.0.0" }, "peerDependencies": { "react-native": ">=0.56" } }, "sha512-4BTbDbRmS7iPdhYLRcz3PGFIpFJBwNZg9g42iwa2P6FOv9vZj/xJc678RZXnLNZzd0qd7Q3CCF6Yd+CU2eoXKQ=="],
@@ -1875,7 +1875,7 @@
"react-native-mmkv": ["react-native-mmkv@2.12.2", "", { "peerDependencies": { "react": "*", "react-native": ">=0.71.0" } }, "sha512-6058Aq0p57chPrUutLGe9fYoiDVDNMU2PKV+lLFUJ3GhoHvUrLdsS1PDSCLr00yqzL4WJQ7TTzH+V8cpyrNcfg=="], "react-native-mmkv": ["react-native-mmkv@2.12.2", "", { "peerDependencies": { "react": "*", "react-native": ">=0.71.0" } }, "sha512-6058Aq0p57chPrUutLGe9fYoiDVDNMU2PKV+lLFUJ3GhoHvUrLdsS1PDSCLr00yqzL4WJQ7TTzH+V8cpyrNcfg=="],
"react-native-pager-view": ["react-native-pager-view@6.5.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-YdX7bP+rPYvATMU7HzlMq9JaG3ui/+cVRbFZFGW+QshDULANFg9ECR1BA7H7JTIcO/ZgWCwF+1aVmYG5yBA9Og=="], "react-native-pager-view": ["react-native-pager-view@6.6.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-LmgZs9ihypMEot82u22n98nKGP4E3ixg6Q/VwS7lbrpVZw7pmC9cdplh10PqlyGMBwPHYt875y5n+3sIzQvUkg=="],
"react-native-progress": ["react-native-progress@5.0.1", "", { "dependencies": { "prop-types": "^15.7.2" }, "peerDependencies": { "react-native-svg": "*" } }, "sha512-TYfJ4auAe5vubDma2yfFvt7ktSI+UCfysqJnkdHEcLXqAitRFOozgF/cLgN5VNi/iLdaf3ga1ETi2RF4jVZ/+g=="], "react-native-progress": ["react-native-progress@5.0.1", "", { "dependencies": { "prop-types": "^15.7.2" }, "peerDependencies": { "react-native-svg": "*" } }, "sha512-TYfJ4auAe5vubDma2yfFvt7ktSI+UCfysqJnkdHEcLXqAitRFOozgF/cLgN5VNi/iLdaf3ga1ETi2RF4jVZ/+g=="],
@@ -1883,11 +1883,11 @@
"react-native-reanimated-carousel": ["react-native-reanimated-carousel@3.5.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-native": ">=0.6.0", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-9BBQV6JAYSQm2lV7MFtT4mzapXmW4IZO6s38gfiJL84Jg23ivGB1UykcNQauKgtHyhtW2NuZJzItb1s42lM+hA=="], "react-native-reanimated-carousel": ["react-native-reanimated-carousel@3.5.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-native": ">=0.6.0", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-9BBQV6JAYSQm2lV7MFtT4mzapXmW4IZO6s38gfiJL84Jg23ivGB1UykcNQauKgtHyhtW2NuZJzItb1s42lM+hA=="],
"react-native-safe-area-context": ["react-native-safe-area-context@4.12.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ukk5PxcF4p3yu6qMZcmeiZgowhb5AsKRnil54YFUUAXVIS7PJcMHGGC+q44fCiBg44/1AJk5njGMez1m9H0BVQ=="], "react-native-safe-area-context": ["react-native-safe-area-context@5.2.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-QpcGA6MRKe8Zbpf1hirCBudNQYGsMv0n/CTTROMOFcXbqRUoEXLCsYxUmYKi7JJb3ziL2DbyzWXyH2/gw4Tkfw=="],
"react-native-screens": ["react-native-screens@4.4.0", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-c7zc7Zwjty6/pGyuuvh9gK3YBYqHPOxrhXfG1lF4gHlojQSmIx2piNbNaV+Uykj+RDTmFXK0e/hA+fucw/Qozg=="], "react-native-screens": ["react-native-screens@4.10.0", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-Tw21NGuXm3PbiUGtZd0AnXirUixaAbPXDjNR0baBH7/WJDaDTTELLcQ7QRXuqAWbmr/EVCrKj1348ei1KFIr8A=="],
"react-native-svg": ["react-native-svg@15.8.0", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-KHJzKpgOjwj1qeZzsBjxNdoIgv2zNCO9fVcoq2TEhTRsVV5DGTZ9JzUZwybd7q4giT/H3RdtqC3u44dWdO0Ffw=="], "react-native-svg": ["react-native-svg@15.11.2", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-+YfF72IbWQUKzCIydlijV1fLuBsQNGMT6Da2kFlo1sh+LE3BIm/2Q7AR1zAAR6L0BFLi1WaQPLfFUC9bNZpOmw=="],
"react-native-tab-view": ["react-native-tab-view@4.0.5", "", { "dependencies": { "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-Xn3TpYo4yvKRC/f4+cOcvsXlitdnSaYkacshckrEI3JiDmFKNFIRVNxtZFggm4MwbJafq2RzuzR6xrgKoxgkTw=="], "react-native-tab-view": ["react-native-tab-view@4.0.5", "", { "dependencies": { "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-Xn3TpYo4yvKRC/f4+cOcvsXlitdnSaYkacshckrEI3JiDmFKNFIRVNxtZFggm4MwbJafq2RzuzR6xrgKoxgkTw=="],
@@ -1905,7 +1905,7 @@
"react-native-web": ["react-native-web@0.19.13", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^6.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-etv3bN8rJglrRCp/uL4p7l8QvUNUC++QwDbdZ8CB7BvZiMvsxfFIRM1j04vxNldG3uo2puRd6OSWR3ibtmc29A=="], "react-native-web": ["react-native-web@0.19.13", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^6.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-etv3bN8rJglrRCp/uL4p7l8QvUNUC++QwDbdZ8CB7BvZiMvsxfFIRM1j04vxNldG3uo2puRd6OSWR3ibtmc29A=="],
"react-native-webview": ["react-native-webview@13.12.5", "", { "dependencies": { "escape-string-regexp": "^4.0.0", "invariant": "2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-INOKPom4dFyzkbxbkuQNfeRG9/iYnyRDzrDkJeyvSWgJAW2IDdJkWFJBS2v0RxIL4gqLgHkiIZDOfiLaNnw83Q=="], "react-native-webview": ["react-native-webview@13.13.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0", "invariant": "2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-lSbFuEZ0uGN/yCIt4+nulbH7WuZlClGlqJ16E6KVl4hOZMGCPoikbNReNs5OsVb6LIGYeQnYxKwPkMwYmJpBqg=="],
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],

View File

@@ -1,11 +1,12 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useMemo } from "react";
import type React from "react"; import type React from "react";
import { useMemo } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider";
import { ProgressBar } from "./common/ProgressBar";
import { WatchedIndicator } from "./WatchedIndicator"; import { WatchedIndicator } from "./WatchedIndicator";
type ContinueWatchingPosterProps = { type ContinueWatchingPosterProps = {
@@ -62,18 +63,6 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`; return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}, [item]); }, [item]);
const progress = useMemo(() => {
if (item.Type === "Program") {
const startDate = new Date(item.StartDate || "");
const endDate = new Date(item.EndDate || "");
const now = new Date();
const total = endDate.getTime() - startDate.getTime();
const elapsed = now.getTime() - startDate.getTime();
return (elapsed / total) * 100;
}
return item.UserData?.PlayedPercentage || 0;
}, [item]);
if (!url) if (!url)
return <View className='aspect-video border border-neutral-800 w-44' />; return <View className='aspect-video border border-neutral-800 w-44' />;
@@ -101,22 +90,8 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
</View> </View>
)} )}
</View> </View>
{!progress && <WatchedIndicator item={item} />} {!item.UserData?.Played && <WatchedIndicator item={item} />}
{progress > 0 && ( <ProgressBar item={item} />
<>
<View
className={
"absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full"
}
/>
<View
style={{
width: `${progress}%`,
}}
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"}
/>
</>
)}
</View> </View>
); );
}; };

View File

@@ -1,11 +1,3 @@
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
import download from "@/utils/profiles/download";
import Ionicons from "@expo/vector-icons/Ionicons"; import Ionicons from "@expo/vector-icons/Ionicons";
import { import {
BottomSheetBackdrop, BottomSheetBackdrop,
@@ -22,17 +14,23 @@ import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import type React from "react"; import type React from "react";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { Alert, Platform, View, type ViewProps } from "react-native"; import { Alert, Platform, Switch, View, type ViewProps } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getDownloadUrl } from "@/utils/jellyfin/media/getDownloadUrl";
import { AudioTrackSelector } from "./AudioTrackSelector"; import { AudioTrackSelector } from "./AudioTrackSelector";
import { type Bitrate, BitrateSelector } from "./BitrateSelector"; import { type Bitrate, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button"; import { Button } from "./Button";
import { Text } from "./common/Text";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector"; import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle"; import ProgressCircle from "./ProgressCircle";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
import { Text } from "./common/Text";
interface DownloadProps extends ViewProps { interface DownloadProps extends ViewProps {
items: BaseItemDto[]; items: BaseItemDto[];
@@ -54,11 +52,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [queue, setQueue] = useAtom(queueAtom); const [queue, _setQueue] = useAtom(queueAtom);
const [settings] = useSettings(); const [settings] = useSettings();
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
const { processes, startBackgroundDownload, downloadedFiles } = useDownload(); const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
//const { startRemuxing } = useRemuxHlsToMp4();
const [selectedMediaSource, setSelectedMediaSource] = useState< const [selectedMediaSource, setSelectedMediaSource] = useState<
MediaSourceInfo | undefined | null MediaSourceInfo | undefined | null
@@ -77,10 +75,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
() => user?.Policy?.EnableContentDownloading, () => user?.Policy?.EnableContentDownloading,
[user], [user],
); );
const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === DownloadMethod.Optimized,
[settings],
);
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -88,7 +82,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
bottomSheetModalRef.current?.present(); bottomSheetModalRef.current?.present();
}, []); }, []);
const handleSheetChanges = useCallback((index: number) => {}, []); const handleSheetChanges = useCallback((_index: number) => { }, []);
const closeModal = useCallback(() => { const closeModal = useCallback(() => {
bottomSheetModalRef.current?.dismiss(); bottomSheetModalRef.current?.dismiss();
@@ -102,6 +96,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
[items, downloadedFiles], [items, downloadedFiles],
); );
const itemsToDownload = useMemo(() => {
if (downloadUnwatchedOnly) {
return itemsNotDownloaded.filter((item) => !item.UserData?.Played);
}
return itemsNotDownloaded;
}, [itemsNotDownloaded, downloadUnwatchedOnly]);
const allItemsDownloaded = useMemo(() => { const allItemsDownloaded = useMemo(() => {
if (items.length === 0) return false; if (items.length === 0) return false;
return itemsNotDownloaded.length === 0; return itemsNotDownloaded.length === 0;
@@ -136,39 +137,14 @@ export const DownloadItems: React.FC<DownloadProps> = ({
firstItem.Type !== "Episode" firstItem.Type !== "Episode"
? "/downloads" ? "/downloads"
: ({ : ({
pathname: `/downloads/${firstItem.SeriesId}`, pathname: `/downloads/${firstItem.SeriesId}`,
params: { params: {
episodeSeasonIndex: firstItem.ParentIndexNumber, episodeSeasonIndex: firstItem.ParentIndexNumber,
}, },
} as Href), } as Href),
); );
}; };
const acceptDownloadOptions = useCallback(() => {
if (userCanDownload === true) {
if (itemsNotDownloaded.some((i) => !i.Id)) {
throw new Error("No item id");
}
closeModal();
initiateDownload(...itemsNotDownloaded);
} else {
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
);
}
}, [
queue,
setQueue,
itemsNotDownloaded,
usingOptimizedServer,
userCanDownload,
maxBitrate,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
]);
const initiateDownload = useCallback( const initiateDownload = useCallback(
async (...items: BaseItemDto[]) => { async (...items: BaseItemDto[]) => {
if ( if (
@@ -181,46 +157,53 @@ export const DownloadItems: React.FC<DownloadProps> = ({
"DownloadItem ~ initiateDownload: No api or user or item", "DownloadItem ~ initiateDownload: No api or user or item",
); );
} }
let mediaSource = selectedMediaSource; const downloadDetailsPromises = items.map(async (item) => {
let audioIndex: number | undefined = selectedAudioStream; const { mediaSource, audioIndex, subtitleIndex } =
let subtitleIndex: number | undefined = selectedSubtitleStream; itemsNotDownloaded.length > 1
? getDefaultPlaySettings(item, settings!)
: {
mediaSource: selectedMediaSource,
audioIndex: selectedAudioStream,
subtitleIndex: selectedSubtitleStream,
};
for (const item of items) { const downloadDetails = await getDownloadUrl({
if (itemsNotDownloaded.length > 1) {
const defaults = getDefaultPlaySettings(item, settings!);
mediaSource = defaults.mediaSource;
audioIndex = defaults.audioIndex;
subtitleIndex = defaults.subtitleIndex;
}
const res = await getStreamUrl({
api, api,
item, item,
startTimeTicks: 0, userId: user.Id!,
userId: user?.Id, mediaSource: mediaSource!,
audioStreamIndex: audioIndex, audioStreamIndex: audioIndex ?? -1,
maxStreamingBitrate: maxBitrate.value, subtitleStreamIndex: subtitleIndex ?? -1,
mediaSourceId: mediaSource?.Id, maxBitrate,
subtitleStreamIndex: subtitleIndex, deviceId: api.deviceInfo.id,
deviceProfile: download,
download: true,
// deviceId: mediaSource?.Id,
}); });
if (!res) { return {
url: downloadDetails?.url,
item,
mediaSource: downloadDetails?.mediaSource,
};
});
const downloadDetails = await Promise.all(downloadDetailsPromises);
for (const { url, item, mediaSource } of downloadDetails) {
if (!url) {
Alert.alert( Alert.alert(
t("home.downloads.something_went_wrong"), t("home.downloads.something_went_wrong"),
t("home.downloads.could_not_get_stream_url_from_jellyfin"), t("home.downloads.could_not_get_stream_url_from_jellyfin"),
); );
continue; continue;
} }
if (!mediaSource) {
const { mediaSource: source, url } = res; console.error(`Could not get download URL for ${item.Name}`);
toast.error(
if (!url || !source) throw new Error("No url"); t("Could not get download URL for {{itemName}}", {
itemName: item.Name,
saveDownloadItemInfoToDiskTmp(item, source, url); }),
await startBackgroundDownload(url, item, source, maxBitrate); );
continue;
}
await startBackgroundDownload(url, item, mediaSource, maxBitrate);
} }
}, },
[ [
@@ -232,11 +215,25 @@ export const DownloadItems: React.FC<DownloadProps> = ({
selectedSubtitleStream, selectedSubtitleStream,
settings, settings,
maxBitrate, maxBitrate,
usingOptimizedServer,
startBackgroundDownload, startBackgroundDownload,
], ],
); );
const acceptDownloadOptions = useCallback(() => {
if (userCanDownload === true) {
if (itemsToDownload.some((i) => !i.Id)) {
throw new Error("No item id");
}
closeModal();
initiateDownload(...itemsToDownload);
} else {
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
);
}
}, [closeModal, initiateDownload, itemsToDownload, userCanDownload]);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => ( (props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop <BottomSheetBackdrop
@@ -253,7 +250,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
if (itemsNotDownloaded.length !== 1) return; if (itemsNotDownloaded.length !== 1) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } = const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(items[0], settings); getDefaultPlaySettings(items[0], settings);
setSelectedMediaSource(mediaSource ?? undefined); setSelectedMediaSource(mediaSource ?? undefined);
setSelectedAudioStream(audioIndex ?? 0); setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1); setSelectedSubtitleStream(subtitleIndex ?? -1);
@@ -327,7 +323,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
<Text className='text-neutral-300'> <Text className='text-neutral-300'>
{subtitle || {subtitle ||
t("item_card.download.download_x_item", { t("item_card.download.download_x_item", {
item_count: itemsNotDownloaded.length, item_count: itemsToDownload.length,
})} })}
</Text> </Text>
</View> </View>
@@ -337,6 +333,15 @@ export const DownloadItems: React.FC<DownloadProps> = ({
onChange={setMaxBitrate} onChange={setMaxBitrate}
selected={maxBitrate} selected={maxBitrate}
/> />
{itemsNotDownloaded.length > 1 && (
<View className='flex flex-row items-center justify-between w-full py-2'>
<Text>{t("item_card.download.download_unwatched_only")}</Text>
<Switch
onValueChange={setDownloadUnwatchedOnly}
value={downloadUnwatchedOnly}
/>
</View>
)}
{itemsNotDownloaded.length === 1 && ( {itemsNotDownloaded.length === 1 && (
<> <>
<MediaSourceSelector <MediaSourceSelector
@@ -361,6 +366,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
</> </>
)} )}
</View> </View>
<Button <Button
className='mt-auto' className='mt-auto'
onPress={acceptDownloadOptions} onPress={acceptDownloadOptions}
@@ -368,13 +374,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
> >
{t("item_card.download.download_button")} {t("item_card.download.download_button")}
</Button> </Button>
<View className='opacity-70 text-center w-full flex items-center'>
<Text className='text-xs'>
{usingOptimizedServer
? t("item_card.download.using_optimized_server")
: t("item_card.download.using_default_method")}
</Text>
</View>
</View> </View>
</BottomSheetView> </BottomSheetView>
</BottomSheetModal> </BottomSheetModal>

View File

@@ -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 { import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
@@ -30,12 +8,35 @@ import { useAtom } from "jotai";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; 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 { AddToFavorites } from "./AddToFavorites";
import { ItemHeader } from "./ItemHeader"; import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails"; import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { MediaSourceSelector } from "./MediaSourceSelector"; import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession"; import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null; const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
export type SelectedOptions = { export type SelectedOptions = {
@@ -45,8 +46,13 @@ export type SelectedOptions = {
subtitleIndex: number; subtitleIndex: number;
}; };
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( interface ItemContentProps {
({ item }) => { item: BaseItemDto;
isOffline: boolean;
}
export const ItemContent: React.FC<ItemContentProps> = React.memo(
({ item, isOffline }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [settings] = useSettings(); const [settings] = useSettings();
const { orientation } = useOrientation(); const { orientation } = useOrientation();
@@ -68,66 +74,75 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
defaultBitrate, defaultBitrate,
defaultMediaSource, defaultMediaSource,
defaultSubtitleIndex, 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. // Needs to automatically change the selected to the default values for default indexes.
useEffect(() => { useEffect(() => {
setSelectedOptions(() => ({ if (item) {
bitrate: defaultBitrate, setSelectedOptions(() => ({
mediaSource: defaultMediaSource, bitrate: defaultBitrate,
subtitleIndex: defaultSubtitleIndex ?? -1, mediaSource: defaultMediaSource,
audioIndex: defaultAudioIndex, subtitleIndex: defaultSubtitleIndex ?? -1,
})); audioIndex: defaultAudioIndex,
}));
}
}, [ }, [
defaultAudioIndex, defaultAudioIndex,
defaultBitrate, defaultBitrate,
defaultSubtitleIndex, defaultSubtitleIndex,
defaultMediaSource, defaultMediaSource,
item,
]); ]);
if (!Platform.isTV) { useEffect(() => {
useEffect(() => { if (Platform.isTV) {
navigation.setOptions({ return;
headerRight: () => }
item && ( navigation.setOptions({
<View className='flex flex-row items-center space-x-2'> headerRight: () =>
<Chromecast.Chromecast item && (
background='blur' <View className='flex flex-row items-center space-x-2'>
width={22} <Chromecast.Chromecast background='blur' width={22} height={22} />
height={22} {item.Type !== "Program" && (
/> <View className='flex flex-row items-center space-x-2'>
{item.Type !== "Program" && ( {!Platform.isTV && !isOffline && (
<View className='flex flex-row items-center space-x-2'> <DownloadSingleItem item={item} size='large' />
{!Platform.isTV && ( )}
<DownloadSingleItem item={item} size='large' /> {user?.Policy?.IsAdministrator && !isOffline && (
)} <PlayInRemoteSessionButton item={item} size='large' />
{user?.Policy?.IsAdministrator && ( )}
<PlayInRemoteSessionButton item={item} size='large' /> <PlayedStatus
)} items={[item]}
size='large'
<PlayedStatus items={[item]} size='large' /> isOffline={isOffline}
<AddToFavorites item={item} /> />
</View> {!isOffline && <AddToFavorites item={item} />}
)} </View>
</View> )}
), </View>
}); ),
}, [item]); });
} }, [item, navigation, isOffline, user]);
useEffect(() => { useEffect(() => {
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP) if (item) {
setHeaderHeight(230); if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
else if (item.Type === "Movie") setHeaderHeight(500); setHeaderHeight(230);
else setHeaderHeight(350); else if (item.Type === "Movie") setHeaderHeight(500);
}, [item.Type, orientation]); else setHeaderHeight(350);
}
}, [item, orientation]);
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]); if (!item || !selectedOptions) return null;
const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
if (!selectedOptions) return null;
return ( return (
<View <View
@@ -168,13 +183,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
onLoad={() => setLoadingLogo(false)} onLoad={() => setLoadingLogo(false)}
onError={() => setLoadingLogo(false)} onError={() => setLoadingLogo(false)}
/> />
) : null ) : undefined
} }
> >
<View className='flex flex-col bg-transparent shrink'> <View className='flex flex-col bg-transparent shrink'>
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'> <View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
<ItemHeader item={item} className='mb-4' /> <ItemHeader item={item} className='mb-4' />
{item.Type !== "Program" && !Platform.isTV && ( {item.Type !== "Program" && !Platform.isTV && !isOffline && (
<View className='flex flex-row items-center justify-start w-full h-16'> <View className='flex flex-row items-center justify-start w-full h-16'>
<BitrateSelector <BitrateSelector
className='mr-1' className='mr-1'
@@ -233,25 +248,34 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
className='grow' className='grow'
selectedOptions={selectedOptions} selectedOptions={selectedOptions}
item={item} item={item}
isOffline={isOffline}
/> />
</View> </View>
{item.Type === "Episode" && ( {item.Type === "Episode" && (
<SeasonEpisodesCarousel item={item} loading={loading} /> <SeasonEpisodesCarousel
item={item}
loading={loading}
isOffline={isOffline}
/>
)} )}
<ItemTechnicalDetails source={selectedOptions.mediaSource} /> {!isOffline && (
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
)}
<OverviewText text={item.Overview} className='px-4 mb-4' /> <OverviewText text={item.Overview} className='px-4 mb-4' />
{item.Type !== "Program" && ( {item.Type !== "Program" && (
<> <>
{item.Type === "Episode" && ( {item.Type === "Episode" && !isOffline && (
<CurrentSeries item={item} className='mb-4' /> <CurrentSeries item={item} className='mb-4' />
)} )}
<CastAndCrew item={item} className='mb-4' loading={loading} /> {!isOffline && (
<CastAndCrew item={item} className='mb-4' loading={loading} />
)}
{item.People && item.People.length > 0 && ( {item.People && item.People.length > 0 && !isOffline && (
<View className='mb-4'> <View className='mb-4'>
{item.People.slice(0, 3).map((person, idx) => ( {item.People.slice(0, 3).map((person, idx) => (
<MoreMoviesWithActor <MoreMoviesWithActor
@@ -264,7 +288,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</View> </View>
)} )}
<SimilarItems itemId={item.Id} /> {!isOffline && <SimilarItems itemId={item.Id} />}
</> </>
)} )}
</View> </View>

View File

@@ -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 { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
@@ -15,7 +5,6 @@ import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, Pressable } from "react-native";
import { Alert, TouchableOpacity, View } from "react-native"; import { Alert, TouchableOpacity, View } from "react-native";
import CastContext, { import CastContext, {
CastButton, CastButton,
@@ -33,12 +22,23 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } 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 { Button } from "./Button";
import type { SelectedOptions } from "./ItemContent"; import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof Button> { interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto; item: BaseItemDto;
selectedOptions: SelectedOptions; selectedOptions: SelectedOptions;
isOffline?: boolean;
} }
const ANIMATION_DURATION = 500; const ANIMATION_DURATION = 500;
@@ -47,6 +47,7 @@ const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({ export const PlayButton: React.FC<Props> = ({
item, item,
selectedOptions, selectedOptions,
isOffline,
...props ...props
}: Props) => { }: Props) => {
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
@@ -76,7 +77,7 @@ export const PlayButton: React.FC<Props> = ({
} }
router.push(`/player/direct-player?${q}`); router.push(`/player/direct-player?${q}`);
}, },
[router], [router, isOffline],
); );
const onPress = useCallback(async () => { const onPress = useCallback(async () => {
@@ -91,6 +92,8 @@ export const PlayButton: React.FC<Props> = ({
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "", subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "", mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "", bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
offline: isOffline ? "true" : "false",
}); });
const queryString = queryParams.toString(); const queryString = queryParams.toString();

View File

@@ -1,50 +1,22 @@
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query";
import type React from "react"; import type React from "react";
import { View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
interface Props extends ViewProps { interface Props extends ViewProps {
items: BaseItemDto[]; items: BaseItemDto[];
isOffline?: boolean;
size?: "default" | "large"; size?: "default" | "large";
} }
export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => { export const PlayedStatus: React.FC<Props> = ({
const queryClient = useQueryClient(); items,
isOffline = false,
const invalidateQueries = () => { ...props
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"],
});
};
const allPlayed = items.every((item) => item.UserData?.Played); const allPlayed = items.every((item) => item.UserData?.Played);
const toggle = useMarkAsPlayed(items, isOffline);
const markAsPlayedStatus = useMarkAsPlayed(items);
return ( return (
<View {...props}> <View {...props}>
@@ -52,8 +24,7 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
fillColor={allPlayed ? "primary" : undefined} fillColor={allPlayed ? "primary" : undefined}
icon={allPlayed ? "checkmark" : "checkmark"} icon={allPlayed ? "checkmark" : "checkmark"}
onPress={async () => { onPress={async () => {
console.log(allPlayed); await toggle(!allPlayed);
await markAsPlayedStatus(!allPlayed);
}} }}
size={props.size} size={props.size}
/> />

View File

@@ -18,7 +18,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
selected, selected,
...props ...props
}) => { }) => {
if (Platform.isTV) return null; const { t } = useTranslation();
const subtitleStreams = useMemo(() => { const subtitleStreams = useMemo(() => {
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle"); return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
}, [source]); }, [source]);
@@ -28,9 +28,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
[subtitleStreams, selected], [subtitleStreams, selected],
); );
if (subtitleStreams?.length === 0) return null; if (Platform.isTV || subtitleStreams?.length === 0) return null;
const { t } = useTranslation();
return ( return (
<View <View

View File

@@ -0,0 +1,47 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useMemo } from "react";
import { View } from "react-native";
interface ProgressBarProps {
item: BaseItemDto;
}
export const ProgressBar: React.FC<ProgressBarProps> = ({ 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 (
<>
<View
className={
"absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full"
}
/>
<View
style={{
width: `${progress}%`,
}}
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"}
/>
</>
);
};

View File

@@ -1,5 +1,3 @@
import { useFavorite } from "@/hooks/useFavorite";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { useActionSheet } from "@expo/react-native-action-sheet"; import { useActionSheet } from "@expo/react-native-action-sheet";
import type { import type {
BaseItemDto, BaseItemDto,
@@ -8,9 +6,12 @@ import type {
import { useRouter, useSegments } from "expo-router"; import { useRouter, useSegments } from "expo-router";
import { type PropsWithChildren, useCallback } from "react"; import { type PropsWithChildren, useCallback } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import { useFavorite } from "@/hooks/useFavorite";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
item: BaseItemDto; item: BaseItemDto;
isOffline?: boolean;
} }
export const itemRouter = ( export const itemRouter = (
@@ -50,6 +51,7 @@ export const itemRouter = (
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
item, item,
isOffline = false,
children, children,
...props ...props
}) => { }) => {
@@ -105,7 +107,10 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity <TouchableOpacity
onLongPress={showActionSheet} onLongPress={showActionSheet}
onPress={() => { onPress={() => {
const url = itemRouter(item, from); let url = itemRouter(item, from);
if (isOffline) {
url += `&offline=true`;
}
// @ts-expect-error // @ts-expect-error
router.push(url); router.push(url);
}} }}
@@ -114,4 +119,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
{children} {children}
</TouchableOpacity> </TouchableOpacity>
); );
return null;
}; };

View File

@@ -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 { Ionicons } from "@expo/vector-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
@@ -19,12 +13,22 @@ import {
type ViewProps, type ViewProps,
} from "react-native"; } from "react-native";
import { toast } from "sonner-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"; import { Button } from "../Button";
const BackGroundDownloader = !Platform.isTV const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader") ? require("@kesha-antonov/react-native-background-downloader")
: null; : null;
interface Props extends ViewProps {} interface Props extends ViewProps { }
const bytesToMB = (bytes: number) => {
return bytes / 1024 / 1024;
};
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => { export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
const { processes } = useDownload(); const { processes } = useDownload();
@@ -59,32 +63,18 @@ interface DownloadCardProps extends TouchableOpacityProps {
} }
const DownloadCard = ({ process, ...props }: DownloadCardProps) => { const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { processes, startDownload } = useDownload(); const { startDownload, removeProcess } = useDownload();
const router = useRouter(); const router = useRouter();
const { removeProcess, setProcesses } = useDownload();
const [settings] = useSettings();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const cancelJobMutation = useMutation({ const cancelJobMutation = useMutation({
mutationFn: async (id: string) => { mutationFn: async (id: string) => {
if (!process) throw new Error("No active download"); if (!process) throw new Error("No active download");
removeProcess(id);
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"] });
}
}
}, },
onSuccess: () => { onSuccess: () => {
toast.success(t("home.downloads.toasts.download_cancelled")); toast.success(t("home.downloads.toasts.download_cancelled"));
queryClient.invalidateQueries({ queryKey: ["downloads"] });
}, },
onError: (e) => { onError: (e) => {
console.error(e); console.error(e);
@@ -93,11 +83,14 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
}); });
const eta = (p: JobStatus) => { 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 bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
const timeLeft = (length - length * (p.progress / 100)) / p.speed; if (bytesRemaining <= 0) return null;
return formatTimeString(timeLeft, "tick");
const secondsRemaining = bytesRemaining / p.speed;
return formatTimeString(secondsRemaining, "s");
}; };
const base64Image = useMemo(() => { 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' className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden'
{...props} {...props}
> >
{(process.status === "optimizing" || {process.status === "downloading" && (
process.status === "downloading") && (
<View <View
className={` className={`
bg-purple-600 h-1 absolute bottom-0 left-0 bg-purple-600 h-1 absolute bottom-0 left-0
@@ -151,8 +143,10 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
) : ( ) : (
<Text className='text-xs'>{process.progress.toFixed(0)}%</Text> <Text className='text-xs'>{process.progress.toFixed(0)}%</Text>
)} )}
{process.speed && ( {process.speed && process.speed > 0 && (
<Text className='text-xs'>{process.speed?.toFixed(2)}x</Text> <Text className='text-xs'>
{bytesToMB(process.speed).toFixed(2)} MB/s
</Text>
)} )}
{eta(process) && ( {eta(process) && (
<Text className='text-xs'> <Text className='text-xs'>

View File

@@ -1,25 +1,16 @@
import { useHaptic } from "@/hooks/useHaptic";
import { import {
ActionSheetProvider, ActionSheetProvider,
useActionSheet, useActionSheet,
} from "@expo/react-native-action-sheet"; } 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 type React from "react";
import { useCallback, useMemo } from "react"; import { useCallback } from "react";
import { import { type TouchableOpacityProps, View } from "react-native";
TouchableOpacity,
type TouchableOpacityProps,
View,
} from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { DownloadSize } from "@/components/downloads/DownloadSize"; import { DownloadSize } from "@/components/downloads/DownloadSize";
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener"; import { useHaptic } from "@/hooks/useHaptic";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
import { runtimeTicksToSeconds } from "@/utils/time"; import { runtimeTicksToSeconds } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import ContinueWatchingPoster from "../ContinueWatchingPoster"; import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { TouchableItemRouter } from "../common/TouchableItemRouter";
@@ -27,26 +18,17 @@ interface EpisodeCardProps extends TouchableOpacityProps {
item: BaseItemDto; item: BaseItemDto;
} }
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => { export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
const { deleteFile } = useDownload(); const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const successHapticFeedback = useHaptic("success"); 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. * Handles deleting the file with haptic feedback.
*/ */
const handleDeleteFile = useCallback(() => { const handleDeleteFile = useCallback(() => {
if (item.Id) { if (item.Id) {
deleteFile(item.Id); deleteFile(item.Id, "Episode");
successHapticFeedback(); successHapticFeedback();
} }
}, [deleteFile, item.Id]); }, [deleteFile, item.Id]);
@@ -77,10 +59,10 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
}, [showActionSheetWithOptions, handleDeleteFile]); }, [showActionSheetWithOptions, handleDeleteFile]);
return ( return (
<TouchableOpacity <TouchableItemRouter
onPress={handleOpenFile} item={item}
isOffline={true}
onLongPress={showActionSheet} onLongPress={showActionSheet}
key={item.Id}
className='flex flex-col mb-4' className='flex flex-col mb-4'
> >
<View className='flex flex-row items-start mb-2'> <View className='flex flex-row items-start mb-2'>
@@ -104,7 +86,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
<Text numberOfLines={3} className='text-xs text-neutral-500 shrink'> <Text numberOfLines={3} className='text-xs text-neutral-500 shrink'>
{item.Overview} {item.Overview}
</Text> </Text>
</TouchableOpacity> </TouchableItemRouter>
); );
}; };

View File

@@ -1,19 +1,18 @@
import { useHaptic } from "@/hooks/useHaptic";
import { import {
ActionSheetProvider, ActionSheetProvider,
useActionSheet, useActionSheet,
} from "@expo/react-native-action-sheet"; } from "@expo/react-native-action-sheet";
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import type React from "react"; import type React from "react";
import { useCallback, useMemo } 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 { DownloadSize } from "@/components/downloads/DownloadSize";
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { Ionicons } from "@expo/vector-icons"; import { ProgressBar } from "../common/ProgressBar";
import { Image } from "expo-image"; import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText"; import { ItemCardText } from "../ItemCardText";
interface MovieCardProps { interface MovieCardProps {
@@ -27,16 +26,10 @@ interface MovieCardProps {
*/ */
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => { export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const { deleteFile } = useDownload(); const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const successHapticFeedback = useHaptic("success");
const handleOpenFile = useCallback(() => {
openFile(item);
}, [item, openFile]);
const base64Image = useMemo(() => { const base64Image = useMemo(() => {
return storage.getString(item.Id!); return storage.getString(item?.Id!);
}, []); }, []);
/** /**
@@ -44,8 +37,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
*/ */
const handleDeleteFile = useCallback(() => { const handleDeleteFile = useCallback(() => {
if (item.Id) { if (item.Id) {
deleteFile(item.Id); deleteFile(item.Id, "Movie");
successHapticFeedback();
} }
}, [deleteFile, item.Id]); }, [deleteFile, item.Id]);
@@ -75,9 +67,9 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
}, [showActionSheetWithOptions, handleDeleteFile]); }, [showActionSheetWithOptions, handleDeleteFile]);
return ( return (
<TouchableOpacity onPress={handleOpenFile} onLongPress={showActionSheet}> <TouchableItemRouter onLongPress={showActionSheet} item={item} isOffline>
{base64Image ? ( {base64Image ? (
<View className='w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'> <View className='relative w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
<Image <Image
source={{ source={{
uri: `data:image/jpeg;base64,${base64Image}`, uri: `data:image/jpeg;base64,${base64Image}`,
@@ -88,22 +80,24 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
resizeMode: "cover", resizeMode: "cover",
}} }}
/> />
<ProgressBar item={item} />
</View> </View>
) : ( ) : (
<View className='w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center'> <View className='relative w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center'>
<Ionicons <Ionicons
name='image-outline' name='image-outline'
size={24} size={24}
color='gray' color='gray'
className='self-center mt-16' className='self-center mt-16'
/> />
<ProgressBar item={item} />
</View> </View>
)} )}
<View className='w-28'> <View className='w-28'>
<ItemCardText item={item} /> <ItemCardText item={item} />
</View> </View>
<DownloadSize items={[item]} /> <DownloadSize items={[item]} />
</TouchableOpacity> </TouchableItemRouter>
); );
}; };

View File

@@ -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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { import {
type QueryFunction, type QueryFunction,
@@ -8,9 +6,11 @@ import {
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollView, View, type ViewProps } from "react-native"; 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 ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText";
import SeriesPoster from "../posters/SeriesPoster"; import SeriesPoster from "../posters/SeriesPoster";
interface Props extends ViewProps { interface Props extends ViewProps {
@@ -20,6 +20,7 @@ interface Props extends ViewProps {
queryKey: QueryKey; queryKey: QueryKey;
queryFn: QueryFunction<BaseItemDto[]>; queryFn: QueryFunction<BaseItemDto[]>;
hideIfEmpty?: boolean; hideIfEmpty?: boolean;
isOffline?: boolean;
} }
export const ScrollingCollectionList: React.FC<Props> = ({ export const ScrollingCollectionList: React.FC<Props> = ({
@@ -29,6 +30,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
queryFn, queryFn,
queryKey, queryKey,
hideIfEmpty = false, hideIfEmpty = false,
isOffline = false,
...props ...props
}) => { }) => {
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
@@ -90,6 +92,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
<TouchableItemRouter <TouchableItemRouter
item={item} item={item}
key={item.Id} key={item.Id}
isOffline={isOffline}
className={`mr-2 className={`mr-2
${orientation === "horizontal" ? "w-44" : "w-28"} ${orientation === "horizontal" ? "w-44" : "w-28"}
`} `}

View File

@@ -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 type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { router } from "expo-router"; import { router } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react"; 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 ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { import {
HorizontalScroll, HorizontalScroll,
type HorizontalScrollRef, type HorizontalScrollRef,
} from "../common/HorrizontalScroll"; } from "../common/HorrizontalScroll";
import { ItemCardText } from "../ItemCardText";
interface Props extends ViewProps { interface Props extends ViewProps {
item?: BaseItemDto | null; item?: BaseItemDto | null;
loading?: boolean; loading?: boolean;
isOffline?: boolean;
} }
export const SeasonEpisodesCarousel: React.FC<Props> = ({ export const SeasonEpisodesCarousel: React.FC<Props> = ({
item, item,
loading, loading,
isOffline,
...props ...props
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { downloadedFiles } = useDownload();
const scrollRef = useRef<HorizontalScrollRef>(null); const scrollRef = useRef<HorizontalScrollRef>(null);
@@ -41,24 +46,28 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
isLoading, isLoading,
isFetching, isFetching,
} = useQuery({ } = useQuery({
queryKey: ["episodes", seasonId], queryKey: ["episodes", seasonId, isOffline],
queryFn: async () => { queryFn: async () => {
if (!api || !user?.Id) return []; if (isOffline) {
const response = await api.axiosInstance.get( return downloadedFiles
`${api.basePath}/Shows/${item?.Id}/Episodes`, ?.filter(
{ (f) => f.item.Type === "Episode" && f.item.SeasonId === seasonId,
params: { )
userId: user?.Id, .map((f) => f.item);
seasonId, }
Fields: if (!api || !user?.Id || !item?.SeriesId) return [];
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount,Overview", const response = await getTvShowsApi(api).getEpisodes({
}, userId: user.Id,
headers: { seasonId: seasonId || undefined,
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`, seriesId: item.SeriesId,
}, fields: [
}, "ItemCounts",
); "PrimaryImageAspectRatio",
"CanDelete",
"MediaSourceCount",
"Overview",
],
});
return response.data.Items as BaseItemDto[]; return response.data.Items as BaseItemDto[];
}, },
enabled: !!api && !!user?.Id && !!seasonId, enabled: !!api && !!user?.Id && !!seasonId,
@@ -123,7 +132,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
data={episodes} data={episodes}
extraData={item} extraData={item}
loading={loading || isLoading || isFetching} loading={loading || isLoading || isFetching}
renderItem={(_item, idx) => ( renderItem={(_item, _idx) => (
<TouchableOpacity <TouchableOpacity
key={_item.Id} key={_item.Id}
onPress={() => { onPress={() => {

View File

@@ -86,7 +86,8 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
userId: user.Id, userId: user.Id,
seasonId: selectedSeasonId, seasonId: selectedSeasonId,
enableUserData: true, 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) if (res.data.TotalRecordCount === 0)
@@ -97,6 +98,10 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
return res.data.Items; return res.data.Items;
}, },
select: (data) =>
[...(data || [])].sort(
(a, b) => (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
),
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId, enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
}); });

View File

@@ -1,32 +1,20 @@
import { Stepper } from "@/components/inputs/Stepper"; import { Stepper } from "@/components/inputs/Stepper";
import { useDownload } from "@/providers/DownloadProvider";
import { import {
DownloadMethod,
type Settings, type Settings,
useSettings, useSettings,
} from "@/utils/atoms/settings"; } 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 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 DisabledSetting from "@/components/settings/DisabledSetting";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
export default function DownloadSettings({ ...props }) { export default function DownloadSettings({ ...props }) {
const [settings, updateSettings, pluginSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
const { setProcesses } = useDownload();
const router = useRouter();
const queryClient = useQueryClient();
const { t } = useTranslation(); const { t } = useTranslation();
const allDisabled = useMemo( const allDisabled = useMemo(
() => () =>
pluginSettings?.downloadMethod?.locked === true &&
pluginSettings?.remuxConcurrentLimit?.locked === true && pluginSettings?.remuxConcurrentLimit?.locked === true &&
pluginSettings?.autoDownload.locked === true, pluginSettings?.autoDownload.locked === true,
[pluginSettings], [pluginSettings],
@@ -37,69 +25,10 @@ export default function DownloadSettings({ ...props }) {
return ( return (
<DisabledSetting disabled={allDisabled} {...props} className='mb-4'> <DisabledSetting disabled={allDisabled} {...props} className='mb-4'>
<ListGroup title={t("home.settings.downloads.downloads_title")}> <ListGroup title={t("home.settings.downloads.downloads_title")}>
<ListItem
title={t("home.settings.downloads.download_method")}
disabled={pluginSettings?.downloadMethod?.locked}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{settings.downloadMethod === DownloadMethod.Remux
? t("home.settings.downloads.default")
: t("home.settings.downloads.optimized")}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>
{t("home.settings.downloads.download_method")}
</DropdownMenu.Label>
<DropdownMenu.Item
key='1'
onSelect={() => {
updateSettings({ downloadMethod: DownloadMethod.Remux });
setProcesses([]);
}}
>
<DropdownMenu.ItemTitle>
{t("home.settings.downloads.default")}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key='2'
onSelect={() => {
updateSettings({ downloadMethod: DownloadMethod.Optimized });
setProcesses([]);
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>
{t("home.settings.downloads.optimized")}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem>
<ListItem <ListItem
title={t("home.settings.downloads.remux_max_download")} title={t("home.settings.downloads.remux_max_download")}
disabled={ disabled={
pluginSettings?.remuxConcurrentLimit?.locked || pluginSettings?.remuxConcurrentLimit?.locked
settings.downloadMethod !== DownloadMethod.Remux
} }
> >
<Stepper <Stepper
@@ -114,33 +43,6 @@ export default function DownloadSettings({ ...props }) {
} }
/> />
</ListItem> </ListItem>
<ListItem
title={t("home.settings.downloads.auto_download")}
disabled={
pluginSettings?.autoDownload?.locked ||
settings.downloadMethod !== DownloadMethod.Optimized
}
>
<Switch
disabled={
pluginSettings?.autoDownload?.locked ||
settings.downloadMethod !== DownloadMethod.Optimized
}
value={settings.autoDownload}
onValueChange={(value) => updateSettings({ autoDownload: value })}
/>
</ListItem>
<ListItem
disabled={
pluginSettings?.optimizedVersionsServerUrl?.locked ||
settings.downloadMethod !== DownloadMethod.Optimized
}
onPress={() => router.push("/settings/optimized-server/page")}
showArrow
title={t("home.settings.downloads.optimized_versions_server")}
/>
</ListGroup> </ListGroup>
</DisabledSetting> </DisabledSetting>
); );

View File

@@ -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 { Feather, Ionicons } from "@expo/vector-icons";
import type { Api } from "@jellyfin/sdk"; import type { Api } from "@jellyfin/sdk";
import type { import type {
@@ -25,12 +13,7 @@ import {
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo"; import NetInfo from "@react-native-community/netinfo";
import { type QueryFunction, useQuery } from "@tanstack/react-query"; import { type QueryFunction, useQuery } from "@tanstack/react-query";
import { import { useNavigation, useRouter, useSegments } from "expo-router";
useNavigation,
usePathname,
useRouter,
useSegments,
} from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -43,6 +26,18 @@ import {
View, View,
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; 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 ScrollingCollectionListSection = {
type: "ScrollingCollectionList"; type: "ScrollingCollectionList";
@@ -71,9 +66,9 @@ export const HomeIndex = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [ const [
settings, settings,
updateSettings, _updateSettings,
pluginSettings, _pluginSettings,
setPluginSettings, _setPluginSettings,
refreshStreamyfinPluginSettings, refreshStreamyfinPluginSettings,
] = useSettings(); ] = useSettings();
@@ -87,6 +82,17 @@ export const HomeIndex = () => {
const scrollViewRef = useRef<ScrollView>(null); const scrollViewRef = useRef<ScrollView>(null);
const { downloadedFiles, cleanCacheDirectory } = useDownload(); const { downloadedFiles, cleanCacheDirectory } = useDownload();
const prevIsConnected = useRef<boolean | null>(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(() => { useEffect(() => {
if (Platform.isTV) { if (Platform.isTV) {
navigation.setOptions({ navigation.setOptions({
@@ -114,7 +120,7 @@ export const HomeIndex = () => {
}, [downloadedFiles, navigation, router]); }, [downloadedFiles, navigation, router]);
useEffect(() => { useEffect(() => {
cleanCacheDirectory().catch((e) => cleanCacheDirectory().catch((_e) =>
console.error("Something went wrong cleaning cache directory"), console.error("Something went wrong cleaning cache directory"),
); );
}, []); }, []);
@@ -149,10 +155,6 @@ export const HomeIndex = () => {
setIsConnected(state.isConnected); setIsConnected(state.isConnected);
}); });
// cleanCacheDirectory().catch((e) =>
// console.error("Something went wrong cleaning cache directory")
// );
return () => { return () => {
unsubscribe(); unsubscribe();
}; };
@@ -193,8 +195,6 @@ export const HomeIndex = () => {
); );
}, [userViews]); }, [userViews]);
const invalidateCache = useInvalidatePlaybackProgressCache();
const refetch = async () => { const refetch = async () => {
setLoading(true); setLoading(true);
await refreshStreamyfinPluginSettings(); await refreshStreamyfinPluginSettings();
@@ -232,166 +232,168 @@ export const HomeIndex = () => {
[api, user?.Id], [api, user?.Id],
); );
let sections: Section[] = []; const defaultSections = useMemo(() => {
if (!settings?.home || !settings?.home?.sections) { if (!api || !user?.Id) return [];
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => { const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] = const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"]; c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name }); const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey = [ const queryKey = [
"home", "home",
`recentlyAddedIn${c.CollectionType}`, `recentlyAddedIn${c.CollectionType}`,
user?.Id!, user?.Id!,
c.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",
},
]; ];
return ss; return createCollectionConfig(
}, [api, user?.Id, collections]); title || "",
} else { queryKey,
sections = useMemo(() => { includeItemTypes,
if (!api || !user?.Id) return []; c.Id,
const ss: Section[] = []; );
});
for (const key in settings.home?.sections) { const ss: Section[] = [
// @ts-expect-error {
const section = settings.home?.sections[key]; title: t("home.continue_watching"),
const id = section.title || key; queryKey: ["home", "resumeItems"],
ss.push({ queryFn: async () =>
title: id, (
queryKey: ["home", id], await getItemsApi(api).getResumeItems({
queryFn: async () => { userId: user.Id,
if (section.items) { enableImageTypes: ["Primary", "Backdrop", "Thumb"],
const response = await getItemsApi(api).getItems({ includeItemTypes: ["Movie", "Series", "Episode"],
userId: user?.Id, })
limit: section.items?.limit || 25, ).data.Items || [],
recursive: true, type: "ScrollingCollectionList",
includeItemTypes: section.items?.includeItemTypes, orientation: "horizontal",
sortBy: section.items?.sortBy, },
sortOrder: section.items?.sortOrder, {
filters: section.items?.filters, title: t("home.next_up"),
parentId: section.items?.parentId, queryKey: ["home", "nextUp-all"],
}); queryFn: async () =>
return response.data.Items || []; (
} await getTvShowsApi(api).getNextUp({
if (section.nextUp) { userId: user?.Id,
const response = await getTvShowsApi(api).getNextUp({ fields: ["MediaSourceCount"],
userId: user?.Id, limit: 20,
fields: ["MediaSourceCount"], enableImageTypes: ["Primary", "Backdrop", "Thumb"],
limit: section.items?.limit || 25, enableResumable: false,
enableImageTypes: ["Primary", "Backdrop", "Thumb"], })
enableResumable: section.items?.enableResumable, ).data.Items || [],
enableRewatching: section.items?.enableRewatching, type: "ScrollingCollectionList",
}); orientation: "horizontal",
return response.data.Items || []; },
} ...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) { return nextUpResults.filter((item) => item !== null) || [];
const response = await getUserLibraryApi(api).getLatestMedia({ } catch (error) {
userId: user?.Id, console.error("Error fetching data:", error);
includeItemTypes: section.latest?.includeItemTypes,
limit: section.latest?.limit || 25,
isPlayed: section.latest?.isPlayed,
groupItems: section.latest?.groupItems,
});
return response.data || [];
}
return []; return [];
}, }
type: "ScrollingCollectionList", },
orientation: section?.orientation || "vertical", type: "ScrollingCollectionList",
}); orientation: "horizontal",
} },
return ss; ];
}, [api, user?.Id, settings.home?.sections]); 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) { if (isConnected === false) {
return ( return (

View File

@@ -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<Props> = ({
value,
onChangeValue,
}) => {
const handleOpenLink = () => {
Linking.openURL("https://github.com/streamyfin/optimized-versions-server");
};
const { t } = useTranslation();
return (
<View>
<View className='flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'>
<View className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}>
<Text className='mr-4'>{t("home.settings.downloads.url")}</Text>
<TextInput
className='text-white'
placeholder={t("home.settings.downloads.server_url_placeholder")}
value={value}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={(text) => onChangeValue(text)}
/>
</View>
</View>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.downloads.optimized_version_hint")}{" "}
<Text className='text-blue-500' onPress={handleOpenLink}>
{t("home.settings.downloads.read_more_about_optimized_server")}
</Text>
</Text>
</View>
);
};

View File

@@ -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 { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useDownload } from "@/providers/DownloadProvider"; 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 { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
@@ -17,22 +16,15 @@ export const StorageSettings = () => {
const errorHapticFeedback = useHaptic("error"); const errorHapticFeedback = useHaptic("error");
const { data: size, isLoading: appSizeLoading } = useQuery({ const { data: size, isLoading: appSizeLoading } = useQuery({
queryKey: ["appSize", appSizeUsage], queryKey: ["appSize"],
queryFn: async () => { queryFn: appSizeUsage,
const app = await appSizeUsage;
const remaining = await FileSystem.getFreeDiskStorageAsync();
const total = await FileSystem.getTotalDiskCapacityAsync();
return { app, remaining, total, used: (total - remaining) / total };
},
}); });
const onDeleteClicked = async () => { const onDeleteClicked = async () => {
try { try {
await deleteAllFiles(); await deleteAllFiles();
successHapticFeedback(); successHapticFeedback();
} catch (e) { } catch (_e) {
errorHapticFeedback(); errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files")); toast.error(t("home.settings.toasts.error_deleting_files"));
} }
@@ -67,10 +59,7 @@ export const StorageSettings = () => {
/> />
<View <View
style={{ style={{
width: `${ width: `${((size.total - size.remaining - size.app) / size.total) * 100}%`,
((size.total - size.remaining - size.app) / size.total) *
100
}%`,
backgroundColor: Colors.primaryLightRGB, backgroundColor: Colors.primaryLightRGB,
}} }}
/> />

View File

@@ -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 { Ionicons, MaterialIcons } from "@expo/vector-icons";
import type { import type {
BaseItemDto, BaseItemDto,
@@ -29,7 +7,7 @@ import { Image } from "expo-image";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { debounce } from "lodash"; import { debounce } from "lodash";
import React, { import {
type Dispatch, type Dispatch,
type FC, type FC,
type MutableRefObject, type MutableRefObject,
@@ -42,27 +20,48 @@ import React, {
import { import {
Platform, Platform,
TouchableOpacity, TouchableOpacity,
View,
useWindowDimensions, useWindowDimensions,
View,
} from "react-native"; } from "react-native";
import { Slider } from "react-native-awesome-slider"; import { Slider } from "react-native-awesome-slider";
import { import {
type SharedValue,
runOnJS, runOnJS,
type SharedValue,
useAnimatedReaction, useAnimatedReaction,
useSharedValue, useSharedValue,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; 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 AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider"; 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 { ControlProvider } from "./contexts/ControlContext";
import { VideoProvider } from "./contexts/VideoContext"; import { VideoProvider } from "./contexts/VideoContext";
import DropdownView from "./dropdown/DropdownView"; import DropdownView from "./dropdown/DropdownView";
import { EpisodeList } from "./EpisodeList";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton";
import { useControlsTimeout } from "./useControlsTimeout"; import { useControlsTimeout } from "./useControlsTimeout";
import { VideoTouchOverlay } from "./VideoTouchOverlay";
interface Props { interface Props {
item: BaseItemDto; item: BaseItemDto;
@@ -119,7 +118,6 @@ export const Controls: FC<Props> = ({
setSubtitleTrack, setSubtitleTrack,
setAudioTrack, setAudioTrack,
offline = false, offline = false,
enableTrickplay = true,
isVlc = false, isVlc = false,
}) => { }) => {
const [settings, updateSettings] = useSettings(); const [settings, updateSettings] = useSettings();
@@ -134,13 +132,16 @@ export const Controls: FC<Props> = ({
const [showAudioSlider, setShowAudioSlider] = useState(false); const [showAudioSlider, setShowAudioSlider] = useState(false);
const { height: screenHeight, width: screenWidth } = useWindowDimensions(); const { height: screenHeight, width: screenWidth } = useWindowDimensions();
const { previousItem, nextItem } = useAdjacentItems({ item }); const { previousItem, nextItem } = useAdjacentItems({
item,
isOffline: offline,
});
const { const {
trickPlayUrl, trickPlayUrl,
calculateTrickplayUrl, calculateTrickplayUrl,
trickplayInfo, trickplayInfo,
prefetchAllTrickplayImages, prefetchAllTrickplayImages,
} = useTrickplay(item, !offline && enableTrickplay); } = useTrickplay(item);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY); const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
@@ -175,19 +176,21 @@ export const Controls: FC<Props> = ({
}>(); }>();
const { showSkipButton, skipIntro } = useIntroSkipper( const { showSkipButton, skipIntro } = useIntroSkipper(
offline ? undefined : item.Id, item?.Id!,
currentTime, currentTime,
seek, seek,
play, play,
isVlc, isVlc,
offline,
); );
const { showSkipCreditButton, skipCredit } = useCreditSkipper( const { showSkipCreditButton, skipCredit } = useCreditSkipper(
offline ? undefined : item.Id, item?.Id!,
currentTime, currentTime,
seek, seek,
play, play,
isVlc, isVlc,
offline,
); );
const goToItemCommon = useCallback( const goToItemCommon = useCallback(
@@ -195,9 +198,7 @@ export const Controls: FC<Props> = ({
if (!item || !settings) { if (!item || !settings) {
return; return;
} }
lightHapticFeedback(); lightHapticFeedback();
const previousIndexes = { const previousIndexes = {
subtitleIndex: subtitleIndex subtitleIndex: subtitleIndex
? Number.parseInt(subtitleIndex) ? Number.parseInt(subtitleIndex)
@@ -215,15 +216,18 @@ export const Controls: FC<Props> = ({
previousIndexes, previousIndexes,
mediaSource ?? undefined, mediaSource ?? undefined,
); );
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
itemId: item.Id ?? "", itemId: item.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "", audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "", subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "", mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString(), bitrateValue: bitrateValue?.toString(),
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString(); }).toString();
console.log("queryParams", queryParams);
// @ts-expect-error // @ts-expect-error
router.replace(`player/direct-player?${queryParams}`); router.replace(`player/direct-player?${queryParams}`);
}, },
@@ -241,7 +245,10 @@ export const Controls: FC<Props> = ({
({ ({
isAutoPlay, isAutoPlay,
resetWatchCount, resetWatchCount,
}: { isAutoPlay?: boolean; resetWatchCount?: boolean }) => { }: {
isAutoPlay?: boolean;
resetWatchCount?: boolean;
}) => {
if (!nextItem) { if (!nextItem) {
return; return;
} }
@@ -303,10 +310,18 @@ export const Controls: FC<Props> = ({
const goToItem = useCallback( const goToItem = useCallback(
async (itemId: string) => { async (itemId: string) => {
const gotoItem = await getItemById(api, itemId); if (offline) {
if (!gotoItem) { const queryParams = new URLSearchParams({
itemId: itemId,
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return; return;
} }
const gotoItem = await getItemById(api, itemId);
if (!gotoItem) return;
goToItemCommon(gotoItem); goToItemCommon(gotoItem);
}, },
[goToItemCommon, api], [goToItemCommon, api],
@@ -560,8 +575,8 @@ export const Controls: FC<Props> = ({
pointerEvents={showControls ? "auto" : "none"} pointerEvents={showControls ? "auto" : "none"}
className={"flex flex-row w-full pt-2"} className={"flex flex-row w-full pt-2"}
> >
{!Platform.isTV && ( <View className='mr-auto'>
<View className='mr-auto'> {!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
<VideoProvider <VideoProvider
getAudioTracks={getAudioTracks} getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks} getSubtitleTracks={getSubtitleTracks}
@@ -571,26 +586,25 @@ export const Controls: FC<Props> = ({
> >
<DropdownView /> <DropdownView />
</VideoProvider> </VideoProvider>
</View> )}
)} </View>
<View className='flex flex-row items-center space-x-2 '> <View className='flex flex-row items-center space-x-2 '>
{!Platform.isTV && {false && (
settings.defaultPlayer === VideoPlayer.VLC_4 && ( <TouchableOpacity
<TouchableOpacity onPress={startPictureInPicture}
onPress={startPictureInPicture} className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2' >
> <MaterialIcons
<MaterialIcons name='picture-in-picture'
name='picture-in-picture' size={24}
size={24} color='white'
color='white' style={{ opacity: showControls ? 1 : 0 }}
style={{ opacity: showControls ? 1 : 0 }} />
/> </TouchableOpacity>
</TouchableOpacity> )}
)}
{item?.Type === "Episode" && !offline && ( {item?.Type === "Episode" && (
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
switchOnEpisodeMode(); switchOnEpisodeMode();
@@ -629,16 +643,14 @@ export const Controls: FC<Props> = ({
color='white' color='white'
/> />
</TouchableOpacity> </TouchableOpacity>
{/* )} */}
<TouchableOpacity <TouchableOpacity
onPress={onClose} onPress={onClose}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2' className='aspect-square flex flex-col l items-center justify-center p-2'
> >
<Ionicons name='close' size={24} color='white' /> <Ionicons name='close' size={24} color='white' />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
<View <View
style={{ style={{
position: "absolute", position: "absolute",

View File

@@ -1,26 +1,30 @@
import { Ionicons } from "@expo/vector-icons";
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 { useGlobalSearchParams } from "expo-router";
import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { DownloadSingleItem } from "@/components/DownloadItem";
import { Loader } from "@/components/Loader";
import { import {
HorizontalScroll, HorizontalScroll,
type HorizontalScrollRef, type HorizontalScrollRef,
} from "@/components/common/HorrizontalScroll"; } from "@/components/common/HorrizontalScroll";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { DownloadSingleItem } from "@/components/DownloadItem";
import { Loader } from "@/components/Loader";
import { import {
SeasonDropdown, SeasonDropdown,
type SeasonIndexState, type SeasonIndexState,
} from "@/components/series/SeasonDropdown"; } from "@/components/series/SeasonDropdown";
import { useItemQuery } from "@/hooks/useItemQuery";
import { useDownload } from "@/providers/DownloadProvider";
import type { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { runtimeTicksToSeconds } from "@/utils/time"; import { runtimeTicksToSeconds } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
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 { atom, useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react";
import { TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
type Props = { type Props = {
item: BaseItemDto; item: BaseItemDto;
@@ -33,12 +37,15 @@ export const seasonIndexAtom = atom<SeasonIndexState>({});
export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => { export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets(); // Get safe area insets
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom); const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
const scrollViewRef = useRef<HorizontalScrollRef>(null); // Reference to the HorizontalScroll const scrollViewRef = useRef<HorizontalScrollRef>(null); // Reference to the HorizontalScroll
const scrollToIndex = (index: number) => { const scrollToIndex = (index: number) => {
scrollViewRef.current?.scrollToIndex(index, 100); scrollViewRef.current?.scrollToIndex(index, 100);
}; };
const { offline } = useGlobalSearchParams<{
offline: string;
}>();
const isOffline = offline === "true";
// Set the initial season index // Set the initial season index
useEffect(() => { useEffect(() => {
@@ -50,23 +57,35 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
} }
}, []); }, []);
const seasonIndex = seasonIndexState[item.SeriesId ?? ""]; const { downloadedFiles } = useDownload();
const [seriesItem, setSeriesItem] = useState<BaseItemDto | null>(null);
// This effect fetches the series item data/ const seasonIndex = seasonIndexState[item.SeriesId ?? ""];
useEffect(() => { const { data: seriesItem } = useItemQuery(item.SeriesId!, isOffline);
if (item.SeriesId) {
getUserItemData({ api, userId: user?.Id, itemId: item.SeriesId }).then(
(res) => {
setSeriesItem(res);
},
);
}
}, [item.SeriesId]);
const { data: seasons } = useQuery({ const { data: seasons } = useQuery({
queryKey: ["seasons", item.SeriesId], queryKey: ["seasons", item.SeriesId],
queryFn: async () => { 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 []; if (!api || !user?.Id || !item.SeriesId) return [];
const response = await api.axiosInstance.get( const response = await api.axiosInstance.get(
`${api.basePath}/Shows/${item.SeriesId}/Seasons`, `${api.basePath}/Shows/${item.SeriesId}/Seasons`,
@@ -93,9 +112,19 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
[seasons, seasonIndex], [seasons, seasonIndex],
); );
const { data: episodes, isFetching } = useQuery({ const { data: episodes } = useQuery({
queryKey: ["episodes", item.SeriesId, selectedSeasonId], queryKey: ["episodes", item.SeriesId, selectedSeasonId],
queryFn: async () => { queryFn: async () => {
if (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 []; if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
const res = await getTvShowsApi(api).getEpisodes({ const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.SeriesId || "", seriesId: item.SeriesId || "",
@@ -112,7 +141,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
useEffect(() => { useEffect(() => {
if (item?.Type === "Episode" && item.Id) { 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) { if (index !== undefined && index !== -1) {
setTimeout(() => { setTimeout(() => {
scrollToIndex(index); scrollToIndex(index);
@@ -155,7 +184,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
} }
return ( return (
<View <SafeAreaView
style={{ style={{
position: "absolute", position: "absolute",
backgroundColor: "black", backgroundColor: "black",
@@ -163,92 +192,81 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
width: "100%", width: "100%",
}} }}
> >
<> <View
<View style={{
style={{ justifyContent: "space-between",
justifyContent: "space-between", }}
}} className={"flex flex-row items-center space-x-2 z-10 p-4"}
className={"flex flex-row items-center space-x-2 z-10 p-4"} >
> {seriesItem && (
{seriesItem && ( <SeasonDropdown
<SeasonDropdown item={seriesItem}
item={seriesItem} seasons={seasons}
seasons={seasons} state={seasonIndexState}
state={seasonIndexState} onSelect={(season) => {
onSelect={(season) => { setSeasonIndexState((prev) => ({
setSeasonIndexState((prev) => ({ ...prev,
...prev, [item.SeriesId ?? ""]: season.IndexNumber,
[item.SeriesId ?? ""]: season.IndexNumber, }));
}));
}}
/>
)}
<TouchableOpacity
onPress={async () => {
close();
}} }}
className='aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2' />
> )}
<Ionicons name='close' size={24} color='white' /> <TouchableOpacity
</TouchableOpacity> onPress={close}
</View> className='aspect-square flex flex-col l items-center justify-center p-2'
>
<Ionicons name='close' size={24} color='white' />
</TouchableOpacity>
</View>
<HorizontalScroll <HorizontalScroll
ref={scrollViewRef} ref={scrollViewRef}
data={episodes} data={episodes}
extraData={item} extraData={item}
renderItem={(_item, idx) => ( renderItem={(_item, _idx) => (
<View <View
key={_item.Id} key={_item.Id}
style={{}} style={{}}
className={`flex flex-col w-44 ${ className={`flex flex-col w-44 ${item.Id !== _item.Id ? "opacity-75" : ""
item.Id !== _item.Id ? "opacity-75" : ""
}`} }`}
>
<TouchableOpacity
onPress={() => {
goToItem(_item.Id);
}}
> >
<TouchableOpacity <ContinueWatchingPoster
onPress={() => { item={_item}
goToItem(_item.Id); useEpisodePoster
showPlayButton={_item.Id !== item.Id}
/>
</TouchableOpacity>
<View className='shrink'>
<Text
numberOfLines={2}
style={{
lineHeight: 18, // Adjust this value based on your text size
height: 36, // lineHeight * 2 for consistent two-line space
}} }}
> >
<ContinueWatchingPoster {_item.Name}
item={_item} </Text>
useEpisodePoster <Text numberOfLines={1} className='text-xs text-neutral-475'>
showPlayButton={_item.Id !== item.Id} {`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`}
/> </Text>
</TouchableOpacity> <Text className='text-xs text-neutral-500'>
<View className='shrink'> {runtimeTicksToSeconds(_item.RunTimeTicks)}
<Text
numberOfLines={2}
style={{
lineHeight: 18, // Adjust this value based on your text size
height: 36, // lineHeight * 2 for consistent two-line space
}}
>
{_item.Name}
</Text>
<Text numberOfLines={1} className='text-xs text-neutral-475'>
{`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`}
</Text>
<Text className='text-xs text-neutral-500'>
{runtimeTicksToSeconds(_item.RunTimeTicks)}
</Text>
</View>
<View className='self-start mt-2'>
<DownloadSingleItem item={_item} />
</View>
<Text
numberOfLines={5}
className='text-xs text-neutral-500 shrink'
>
{_item.Overview}
</Text> </Text>
</View> </View>
)} <Text numberOfLines={5} className='text-xs text-neutral-500 shrink'>
keyExtractor={(e: BaseItemDto) => e.Id ?? ""} {_item.Overview}
estimatedItemSize={200} </Text>
showsHorizontalScrollIndicator={false} </View>
/> )}
</> keyExtractor={(e: BaseItemDto) => e.Id ?? ""}
</View> estimatedItemSize={200}
showsHorizontalScrollIndicator={false}
/>
</SafeAreaView>
); );
}; };

View File

@@ -1,3 +1,4 @@
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import type React from "react"; import type React from "react";
import { import {
@@ -9,7 +10,7 @@ import {
useState, useState,
} from "react"; } from "react";
import type { TrackInfo } from "@/modules/VlcPlayer.types"; import type { TrackInfo } from "@/modules/VlcPlayer.types";
import { useSettings, VideoPlayer } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import type { Track } from "../types"; import type { Track } from "../types";
import { useControlContext } from "./ControlContext"; import { useControlContext } from "./ControlContext";
@@ -48,7 +49,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
}) => { }) => {
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null); const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null); const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
const [settings] = useSettings(); const [_settings] = useSettings();
const ControlContext = useControlContext(); const ControlContext = useControlContext();
const isVideoLoaded = ControlContext?.isVideoLoaded; const isVideoLoaded = ControlContext?.isVideoLoaded;
@@ -67,13 +68,17 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
playbackPosition: string; playbackPosition: string;
}>(); }>();
const onTextBasedSubtitle = useMemo( const onTextBasedSubtitle = useMemo(() => {
() => return (
allSubs.find( allSubs.find(
(s) => s.Index?.toString() === subtitleIndex && s.IsTextSubtitleStream, (s) =>
) || subtitleIndex === "-1", s.Index?.toString() === subtitleIndex &&
[allSubs, subtitleIndex], (s.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
); s.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
s.DeliveryMethod === SubtitleDeliveryMethod.External),
) || subtitleIndex === "-1"
);
}, [allSubs, subtitleIndex]);
const setPlayerParams = ({ const setPlayerParams = ({
chosenAudioIndex = audioIndex, chosenAudioIndex = audioIndex,
@@ -128,30 +133,32 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
useEffect(() => { useEffect(() => {
const fetchTracks = async () => { const fetchTracks = async () => {
if (getSubtitleTracks) { 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 let embedSubIndex = 1;
const sortedSubs = allSubs.sort( const processedSubs: Track[] = allSubs?.map((sub) => {
(a, b) => Number(a.IsExternal) - Number(b.IsExternal), /** A boolean value determining if we should increment the embedSubIndex, currently only Embed and Hls subtitles are automatically added into VLC Player */
);
// 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
const shouldIncrement = const shouldIncrement =
!mediaSource?.TranscodingUrl || sub.IsTextSubtitleStream; sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
const vlcIndex = subtitleData?.at(textSubIndex)?.index ?? -1; sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
const finalIndex = shouldIncrement ? vlcIndex : (sub.Index ?? -1); sub.DeliveryMethod === SubtitleDeliveryMethod.External;
/** The index of subtitle inside VLC Player Itself */
if (shouldIncrement) textSubIndex++; const vlcIndex = subtitleData?.at(embedSubIndex)?.index ?? -1;
if (shouldIncrement) embedSubIndex++;
return { return {
name: sub.DisplayTitle || "Undefined Subtitle", name: sub.DisplayTitle || "Undefined Subtitle",
index: sub.Index ?? -1, index: sub.Index ?? -1,
setTrack: () => setTrack: () =>
shouldIncrement shouldIncrement
? setTrackParams("subtitle", finalIndex, sub.Index ?? -1) ? setTrackParams("subtitle", vlcIndex, sub.Index ?? -1)
: setPlayerParams({ : setPlayerParams({
chosenSubtitleIndex: sub.Index?.toString(), chosenSubtitleIndex: sub.Index?.toString(),
}), }),

View File

@@ -19,7 +19,7 @@ const DropdownView = () => {
]; ];
const router = useRouter(); const router = useRouter();
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } = const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } =
useLocalSearchParams<{ useLocalSearchParams<{
itemId: string; itemId: string;
audioIndex: string; audioIndex: string;
@@ -27,8 +27,11 @@ const DropdownView = () => {
mediaSourceId: string; mediaSourceId: string;
bitrateValue: string; bitrateValue: string;
playbackPosition: string; playbackPosition: string;
offline: string;
}>(); }>();
const isOffline = offline === "true";
const changeBitrate = useCallback( const changeBitrate = useCallback(
(bitrate: string) => { (bitrate: string) => {
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
@@ -61,32 +64,34 @@ const DropdownView = () => {
collisionPadding={8} collisionPadding={8}
sideOffset={8} sideOffset={8}
> >
<DropdownMenu.Sub> {!isOffline && (
<DropdownMenu.SubTrigger key='qualitytrigger'> <DropdownMenu.Sub>
Quality <DropdownMenu.SubTrigger key='qualitytrigger'>
</DropdownMenu.SubTrigger> Quality
<DropdownMenu.SubContent </DropdownMenu.SubTrigger>
alignOffset={-10} <DropdownMenu.SubContent
avoidCollisions={true} alignOffset={-10}
collisionPadding={0} avoidCollisions={true}
loop={true} collisionPadding={0}
sideOffset={10} loop={true}
> sideOffset={10}
{BITRATES?.map((bitrate, idx: number) => ( >
<DropdownMenu.CheckboxItem {BITRATES?.map((bitrate, idx: number) => (
key={`quality-item-${idx}`} <DropdownMenu.CheckboxItem
value={bitrateValue === (bitrate.value?.toString() ?? "")} key={`quality-item-${idx}`}
onValueChange={() => value={bitrateValue === (bitrate.value?.toString() ?? "")}
changeBitrate(bitrate.value?.toString() ?? "") onValueChange={() =>
} changeBitrate(bitrate.value?.toString() ?? "")
> }
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}> >
{bitrate.key} <DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
</DropdownMenu.ItemTitle> {bitrate.key}
</DropdownMenu.CheckboxItem> </DropdownMenu.ItemTitle>
))} </DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent> ))}
</DropdownMenu.Sub> </DropdownMenu.SubContent>
</DropdownMenu.Sub>
)}
<DropdownMenu.Sub> <DropdownMenu.Sub>
<DropdownMenu.SubTrigger key='subtitle-trigger'> <DropdownMenu.SubTrigger key='subtitle-trigger'>
Subtitle Subtitle

View File

@@ -1,21 +1,63 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useMemo } from "react"; import { useMemo } from "react";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom } from "@/providers/JellyfinProvider";
interface AdjacentEpisodesProps { interface AdjacentEpisodesProps {
item?: BaseItemDto | null; item?: BaseItemDto | null;
isOffline?: boolean;
} }
export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => { export const useAdjacentItems = ({
item,
isOffline = false,
}: AdjacentEpisodesProps) => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const { downloadedFiles } = useDownload();
const { data: adjacentItems } = useQuery({ const { data: adjacentItems } = useQuery({
queryKey: ["adjacentItems", item?.Id, item?.SeriesId], queryKey: ["adjacentItems", item?.Id, item?.SeriesId, isOffline],
queryFn: async (): Promise<BaseItemDto[] | null> => { queryFn: async (): Promise<BaseItemDto[] | null> => {
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; return null;
} }
@@ -29,7 +71,7 @@ export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
return res.data.Items || null; return res.data.Items || null;
}, },
enabled: enabled:
!!api && (isOffline || !!api) &&
!!item?.Id && !!item?.Id &&
!!item?.SeriesId && !!item?.SeriesId &&
(item?.Type === "Episode" || item?.Type === "Audio"), (item?.Type === "Episode" || item?.Type === "Audio"),

View File

@@ -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 { useCallback, useEffect, useState } from "react";
import { useSegments } from "@/utils/segments";
import { msToSeconds, secondsToMs } from "@/utils/time";
import { useHaptic } from "./useHaptic"; import { useHaptic } from "./useHaptic";
interface CreditTimestamps {
Introduction: {
Start: number;
End: number;
Valid: boolean;
};
Credits: {
Start: number;
End: number;
Valid: boolean;
};
}
export const useCreditSkipper = ( export const useCreditSkipper = (
itemId: string | undefined, itemId: string,
currentTime: number, currentTime: number,
seek: (time: number) => void, seek: (time: number) => void,
play: () => void, play: () => void,
isVlc = false, isVlc = false,
isOffline = false,
) => { ) => {
const [api] = useAtom(apiAtom);
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false); const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
@@ -43,50 +26,28 @@ export const useCreditSkipper = (
seek(seconds); seek(seconds);
}; };
const { data: creditTimestamps } = useQuery<CreditTimestamps | null>({ const { data: segments } = useSegments(itemId, isOffline);
queryKey: ["creditTimestamps", itemId], const creditTimestamps = segments?.creditSegments?.[0];
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,
});
useEffect(() => { useEffect(() => {
if (creditTimestamps) { if (creditTimestamps) {
setShowSkipCreditButton( setShowSkipCreditButton(
currentTime > creditTimestamps.Credits.Start && currentTime > creditTimestamps.startTime &&
currentTime < creditTimestamps.Credits.End, currentTime < creditTimestamps.endTime,
); );
} }
}, [creditTimestamps, currentTime]); }, [creditTimestamps, currentTime]);
const skipCredit = useCallback(() => { const skipCredit = useCallback(() => {
if (!creditTimestamps) return; if (!creditTimestamps) return;
console.log(`Skipping credits to ${creditTimestamps.Credits.End}`);
try { try {
lightHapticFeedback(); lightHapticFeedback();
wrappedSeek(creditTimestamps.Credits.End); wrappedSeek(creditTimestamps.endTime);
setTimeout(() => { setTimeout(() => {
play(); play();
}, 200); }, 200);
} catch (error) { } catch (error) {
writeToLog("ERROR", "Error skipping intro", error); console.error("Error skipping credit", error);
} }
}, [creditTimestamps]); }, [creditTimestamps]);

View File

@@ -1,31 +1,8 @@
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { writeToLog } from "@/utils/log";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useCallback } from "react"; import { useCallback } from "react";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
export const getDownloadedFileUrl = async (itemId: string): Promise<string> => { import { writeToLog } from "@/utils/log";
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}`;
};
export const useDownloadedFileOpener = () => { export const useDownloadedFileOpener = () => {
const router = useRouter(); const router = useRouter();
@@ -33,9 +10,19 @@ export const useDownloadedFileOpener = () => {
const openFile = useCallback( const openFile = useCallback(
async (item: BaseItemDto) => { 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 { try {
// @ts-expect-error router.push(`/player/direct-player?${queryParams.toString()}`);
router.push(`/player/direct-player?offline=true&itemId=${item.Id}`);
} catch (error) { } catch (error) {
writeToLog("ERROR", "Error opening file", error); writeToLog("ERROR", "Error opening file", error);
console.error("Error opening file:", error); console.error("Error opening file:", error);

View File

@@ -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 { useCallback, useEffect, useState } from "react";
import { useSegments } from "@/utils/segments";
import { msToSeconds, secondsToMs } from "@/utils/time";
import { useHaptic } from "./useHaptic"; 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. * Custom hook to handle skipping intros in a media player.
* *
* @param {number} currentTime - The current playback time in seconds. * @param {number} currentTime - The current playback time in seconds.
*/ */
export const useIntroSkipper = ( export const useIntroSkipper = (
itemId: string | undefined, itemId: string,
currentTime: number, currentTime: number,
seek: (ticks: number) => void, seek: (ticks: number) => void,
play: () => void, play: () => void,
isVlc = false, isVlc = false,
isOffline = false,
) => { ) => {
const [api] = useAtom(apiAtom);
const [showSkipButton, setShowSkipButton] = useState(false); const [showSkipButton, setShowSkipButton] = useState(false);
if (isVlc) { if (isVlc) {
currentTime = msToSeconds(currentTime); currentTime = msToSeconds(currentTime);
@@ -43,35 +30,14 @@ export const useIntroSkipper = (
seek(seconds); seek(seconds);
}; };
const { data: introTimestamps } = useQuery<IntroTimestamps | null>({ const { data: segments } = useSegments(itemId, isOffline);
queryKey: ["introTimestamps", itemId], const introTimestamps = segments?.introSegments?.[0];
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,
});
useEffect(() => { useEffect(() => {
if (introTimestamps) { if (introTimestamps) {
setShowSkipButton( setShowSkipButton(
currentTime > introTimestamps.ShowSkipPromptAt && currentTime > introTimestamps.startTime &&
currentTime < introTimestamps.HideSkipPromptAt, currentTime < introTimestamps.endTime,
); );
} }
}, [introTimestamps, currentTime]); }, [introTimestamps, currentTime]);
@@ -80,12 +46,12 @@ export const useIntroSkipper = (
if (!introTimestamps) return; if (!introTimestamps) return;
try { try {
lightHapticFeedback(); lightHapticFeedback();
wrappedSeek(introTimestamps.IntroEnd); wrappedSeek(introTimestamps.endTime);
setTimeout(() => { setTimeout(() => {
play(); play();
}, 200); }, 200);
} catch (error) { } catch (error) {
writeToLog("ERROR", "Error skipping intro", error); console.error("Error skipping intro", error);
} }
}, [introTimestamps]); }, [introTimestamps]);

30
hooks/useItemQuery.ts Normal file
View File

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

View File

@@ -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 type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query"; import { QueryKey, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useHaptic } from "./useHaptic"; import { useHaptic } from "./useHaptic";
import { usePlaybackManager } from "./usePlaybackManager";
import { useInvalidatePlaybackProgressCache } from "./useRevalidatePlaybackProgressCache";
export const useMarkAsPlayed = (items: BaseItemDto[]) => { export const useMarkAsPlayed = (items: BaseItemDto[]) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const { markItemPlayed, markItemUnplayed } = usePlaybackManager();
const invalidateQueries = () => { const invalidatePlaybackProgressCache = useInvalidatePlaybackProgressCache();
const queriesToInvalidate = [ const invalidateQueries = async () => {
["resumeItems"], const queriesToInvalidate: QueryKey[] = [];
["continueWatching"],
["nextUp-all"],
["nextUp"],
["episodes"],
["seasons"],
["home"],
];
items.forEach((item) => { items.forEach((item) => {
if (!item.Id) return; if (!item.Id) return;
queriesToInvalidate.push(["item", item.Id]); queriesToInvalidate.push(["item", item.Id]);
}); });
await Promise.all(
queriesToInvalidate.forEach((queryKey) => { queriesToInvalidate.map((queryKey) =>
queryClient.invalidateQueries({ queryKey }); queryClient.invalidateQueries({ queryKey }),
}); ),
);
}; };
const markAsPlayedStatus = async (played: boolean) => { const toggle = async (played: boolean) => {
lightHapticFeedback(); lightHapticFeedback();
// Process all items
items.forEach((item) => { await Promise.all(
// Optimistic update items.map((item) => {
queryClient.setQueryData( if (!item.Id) return Promise.resolve();
["item", item.Id], return played ? markItemPlayed(item.Id) : markItemUnplayed(item.Id);
(oldData: BaseItemDto | undefined) => { }),
if (oldData) { );
return { invalidatePlaybackProgressCache();
...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);
}
invalidateQueries(); invalidateQueries();
}; };
return markAsPlayedStatus; return toggle;
}; };

213
hooks/usePlaybackManager.ts Normal file
View File

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

View File

@@ -1,10 +1,14 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useDownload } from "@/providers/DownloadProvider";
import { useTwoWaySync } from "./useTwoWaySync";
/** /**
* useRevalidatePlaybackProgressCache invalidates queries related to playback progress. * useRevalidatePlaybackProgressCache invalidates queries related to playback progress.
*/ */
export function useInvalidatePlaybackProgressCache() { export function useInvalidatePlaybackProgressCache() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { downloadedFiles } = useDownload();
const { syncPlaybackState } = useTwoWaySync();
const revalidate = async () => { const revalidate = async () => {
// List of all the queries to invalidate // List of all the queries to invalidate
@@ -17,11 +21,33 @@ export function useInvalidatePlaybackProgressCache() {
["episodes"], ["episodes"],
["seasons"], ["seasons"],
["home"], ["home"],
["downloadedItems"],
]; ];
// Invalidate each query // We Invalidate all the queries to the latest server versions
for (const queryKey of queriesToInvalidate) { await Promise.all(
await queryClient.invalidateQueries({ queryKey }); 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 }),
);
}
} }
}; };

View File

@@ -1,11 +1,69 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { ticksToMs } from "@/utils/time";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react"; 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<TrickplayUrl | null>(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; Interval?: number;
TileWidth?: number; TileWidth?: number;
TileHeight?: number; TileHeight?: number;
@@ -14,141 +72,93 @@ interface TrickplayData {
ThumbnailCount?: number; ThumbnailCount?: number;
} }
interface TrickplayInfo { export interface TrickplayInfo {
resolution: string; resolution: string;
aspectRatio: number; aspectRatio: number;
data: TrickplayData; data: TrickplayData;
totalImageSheets: number;
} }
interface TrickplayUrl { /** Generates a trickplay URL based on the item, resolution, and sheet index. */
x: number; export const generateTrickplayUrl = (item: BaseItemDto, sheetIndex: number) => {
y: number; const api = store.get(apiAtom);
url: string; 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); * Parses the trickplay metadata from a BaseItemDto.
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null); * @param item The Jellyfin media item.
const lastCalculationTime = useRef(0); * @returns Parsed trickplay information or null if not available.
const throttleDelay = 200; // 200ms throttle */
export const getTrickplayInfo = (item: BaseItemDto): TrickplayInfo | null => {
if (!item.Id || !item.Trickplay) return null;
const trickplayInfo = useMemo(() => { const mediaSourceId = item.Id;
if (!enabled || !item.Id || !item.Trickplay) { const trickplayDataForSource = item.Trickplay[mediaSourceId];
return null;
}
const mediaSourceId = item.Id; if (!trickplayDataForSource) {
const trickplayData = item.Trickplay[mediaSourceId]; return null;
}
if (!trickplayData) { const firstResolution = Object.keys(trickplayDataForSource)[0];
return null; if (!firstResolution) {
} return null;
}
// Get the first available resolution const data = trickplayDataForSource[firstResolution];
const firstResolution = Object.keys(trickplayData)[0]; const { Interval, TileWidth, TileHeight, Width, Height } = data;
return firstResolution
? {
resolution: firstResolution,
aspectRatio:
trickplayData[firstResolution].Width! /
trickplayData[firstResolution].Height!,
data: trickplayData[firstResolution],
}
: null;
}, [item, enabled]);
// Takes in ticks. if (
const calculateTrickplayUrl = useCallback( !Interval ||
(progress: number) => { !TileWidth ||
if (!enabled) { !TileHeight ||
return null; !Width ||
} !Height ||
!item.RunTimeTicks
) {
return null;
}
const now = Date.now(); const tilesPerSheet = TileWidth * TileHeight;
if (now - lastCalculationTime.current < throttleDelay) { const totalTiles = Math.ceil(ticksToMs(item.RunTimeTicks) / Interval);
return null; const totalImageSheets = Math.ceil(totalTiles / tilesPerSheet);
}
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]);
return { return {
trickPlayUrl: enabled ? trickPlayUrl : null, resolution: firstResolution,
calculateTrickplayUrl: enabled ? calculateTrickplayUrl : () => null, aspectRatio: Width / Height,
prefetchAllTrickplayImages: enabled data,
? prefetchAllTrickplayImages totalImageSheets,
: () => null,
trickplayInfo: enabled ? trickplayInfo : null,
}; };
}; };
/**
* 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 };
};

81
hooks/useTwoWaySync.ts Normal file
View File

@@ -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<boolean> indicating whether a server update was made (true) or not (false).
*/
const syncPlaybackState = async (itemId: string): Promise<boolean> => {
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 };
};

View File

@@ -41,10 +41,10 @@ export type VlcPlayerSource = {
type?: string; type?: string;
isNetwork?: boolean; isNetwork?: boolean;
autoplay?: boolean; autoplay?: boolean;
externalSubtitles: { name: string; DeliveryUrl: string }[]; startPosition?: number;
externalSubtitles?: { name: string; DeliveryUrl: string }[];
initOptions?: any[]; initOptions?: any[];
mediaOptions?: { [key: string]: any }; mediaOptions?: { [key: string]: any };
startPosition?: number;
}; };
export type TrackInfo = { export type TrackInfo = {
@@ -94,5 +94,5 @@ export interface VlcPlayerViewRef {
getChapters: () => Promise<ChapterInfo[] | null>; getChapters: () => Promise<ChapterInfo[] | null>;
setVideoCropGeometry: (geometry: string | null) => Promise<void>; setVideoCropGeometry: (geometry: string | null) => Promise<void>;
getVideoCropGeometry: () => Promise<string | null>; getVideoCropGeometry: () => Promise<string | null>;
setSubtitleURL: (url: string, name: string) => Promise<void>; setSubtitleURL: (url: string) => Promise<void>;
} }

View File

@@ -1,8 +1,6 @@
import { requireNativeViewManager } from "expo-modules-core"; import { requireNativeViewManager } from "expo-modules-core";
import * as React from "react"; import * as React from "react";
import { ViewStyle } from "react-native";
import { VideoPlayer, useSettings } from "@/utils/atoms/settings";
import { Platform, ViewStyle } from "react-native";
import type { import type {
VlcPlayerSource, VlcPlayerSource,
VlcPlayerViewProps, VlcPlayerViewProps,
@@ -13,22 +11,12 @@ interface NativeViewRef extends VlcPlayerViewRef {
setNativeProps?: (props: Partial<VlcPlayerViewProps>) => void; setNativeProps?: (props: Partial<VlcPlayerViewProps>) => void;
} }
const VLCViewManager = requireNativeViewManager("VlcPlayer");
const VLC3ViewManager = requireNativeViewManager("VlcPlayer3"); const VLC3ViewManager = requireNativeViewManager("VlcPlayer3");
// Create a forwarded ref version of the native view // Create a forwarded ref version of the native view
const NativeView = React.forwardRef<NativeViewRef, VlcPlayerViewProps>( const NativeView = React.forwardRef<NativeViewRef, VlcPlayerViewProps>(
(props, ref) => { (props, ref) => {
const [settings] = useSettings(); return <VLC3ViewManager {...props} ref={ref} />;
if (Platform.OS === "ios" || Platform.isTVOS) {
if (settings.defaultPlayer === VideoPlayer.VLC_3) {
console.log("[Apple] Using Vlc Player 3");
return <VLC3ViewManager {...props} ref={ref} />;
}
}
console.log("Using default Vlc Player");
return <VLCViewManager {...props} ref={ref} />;
}, },
); );
@@ -95,8 +83,8 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
const geometry = await nativeRef.current?.getVideoCropGeometry(); const geometry = await nativeRef.current?.getVideoCropGeometry();
return geometry ?? null; return geometry ?? null;
}, },
setSubtitleURL: async (url: string, name: string) => { setSubtitleURL: async (url: string) => {
await nativeRef.current?.setSubtitleURL(url, name); await nativeRef.current?.setSubtitleURL(url);
}, },
})); }));

View File

@@ -54,6 +54,10 @@ public class VlcPlayer3Module: Module {
return view.getAudioTracks() return view.getAudioTracks()
} }
AsyncFunction("setSubtitleURL") { (view: VlcPlayer3View, url: String, name: String) in
view.setSubtitleURL(url, name: name)
}
AsyncFunction("setSubtitleTrack") { (view: VlcPlayer3View, trackIndex: Int) in AsyncFunction("setSubtitleTrack") { (view: VlcPlayer3View, trackIndex: Int) in
view.setSubtitleTrack(trackIndex) view.setSubtitleTrack(trackIndex)
} }
@@ -61,11 +65,6 @@ public class VlcPlayer3Module: Module {
AsyncFunction("getSubtitleTracks") { (view: VlcPlayer3View) -> [[String: Any]]? in AsyncFunction("getSubtitleTracks") { (view: VlcPlayer3View) -> [[String: Any]]? in
return view.getSubtitleTracks() return view.getSubtitleTracks()
} }
AsyncFunction("setSubtitleURL") {
(view: VlcPlayer3View, url: String, name: String) in
view.setSubtitleURL(url, name: name)
}
} }
} }
} }

View File

@@ -22,6 +22,8 @@ class VlcPlayer3View: ExpoView {
private var isStopping: Bool = false // Define isStopping here private var isStopping: Bool = false // Define isStopping here
private var lastProgressCall = Date().timeIntervalSince1970 private var lastProgressCall = Date().timeIntervalSince1970
var hasSource = false var hasSource = false
var isTranscoding = false
private var initialSeekPerformed: Bool = false
// MARK: - Initialization // MARK: - Initialization
@@ -88,7 +90,6 @@ class VlcPlayer3View: ExpoView {
// If the specified time is greater than the duration, seek to the end // If the specified time is greater than the duration, seek to the end
let seekTime = time > duration ? duration - 1000 : time let seekTime = time > duration ? duration - 1000 : time
player.time = VLCTime(int: seekTime) player.time = VLCTime(int: seekTime)
if wasPlaying { if wasPlaying {
self.play() self.play()
} }
@@ -110,13 +111,18 @@ class VlcPlayer3View: ExpoView {
var initOptions = source["initOptions"] as? [Any] ?? [] var initOptions = source["initOptions"] as? [Any] ?? []
self.startPosition = source["startPosition"] as? Int32 ?? 0 self.startPosition = source["startPosition"] as? Int32 ?? 0
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]] self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
initOptions.append("--start-time=\(self.startPosition)")
guard let uri = source["uri"] as? String, !uri.isEmpty else { guard let uri = source["uri"] as? String, !uri.isEmpty else {
print("Error: Invalid or empty URI") print("Error: Invalid or empty URI")
self.onVideoError?(["error": "Invalid or empty URI"]) self.onVideoError?(["error": "Invalid or empty URI"])
return 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 autoplay = source["autoplay"] as? Bool ?? false
let isNetwork = source["isNetwork"] as? Bool ?? false let isNetwork = source["isNetwork"] as? Bool ?? false
@@ -126,6 +132,7 @@ class VlcPlayer3View: ExpoView {
self.mediaPlayer?.delegate = self self.mediaPlayer?.delegate = self
self.mediaPlayer?.drawable = self.videoView self.mediaPlayer?.drawable = self.videoView
self.mediaPlayer?.scaleFactor = 0 self.mediaPlayer?.scaleFactor = 0
self.initialSeekPerformed = false
let media: VLCMedia let media: VLCMedia
if isNetwork { if isNetwork {
@@ -287,9 +294,14 @@ class VlcPlayer3View: ExpoView {
let currentTimeMs = player.time.intValue let currentTimeMs = player.time.intValue
let durationMs = player.media?.length.intValue ?? 0 let durationMs = player.media?.length.intValue ?? 0
print("Debug: Current time: \(currentTimeMs)") print("Debug: Current time: \(currentTimeMs)")
if currentTimeMs >= 0 && currentTimeMs < durationMs { 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?([ self.onVideoProgress?([
"currentTime": currentTimeMs, "currentTime": currentTimeMs,
"duration": durationMs, "duration": durationMs,

View File

@@ -137,10 +137,7 @@ extension VLCPlayerWrapper: VLCMediaPlayerDelegate {
} }
} }
// MARK: - VLCMediaDelegate
extension VLCPlayerWrapper: VLCMediaDelegate {
// Implement VLCMediaDelegate methods if needed
}
class VlcPlayerView: ExpoView { class VlcPlayerView: ExpoView {
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VlcPlayerView") 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 isStopping: Bool = false // Define isStopping here
private var externalSubtitles: [[String: String]]? private var externalSubtitles: [[String: String]]?
var hasSource = false 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 // MARK: - Initialization
required init(appContext: AppContext? = nil) { 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() { private func setupNotifications() {
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, selector: #selector(applicationWillResignActive), self, selector: #selector(applicationWillResignActive),
@@ -254,6 +268,8 @@ class VlcPlayerView: ExpoView {
let autoplay = source["autoplay"] as? Bool ?? false let autoplay = source["autoplay"] as? Bool ?? false
let isNetwork = source["isNetwork"] 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()]) self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
let media: VLCMedia! let media: VLCMedia!
@@ -277,8 +293,11 @@ class VlcPlayerView: ExpoView {
self.hasSource = true self.hasSource = true
if autoplay { if autoplay {
logger.info("Playing...") 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.play()
self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000))
} }
} }
} }
@@ -415,6 +434,9 @@ class VlcPlayerView: ExpoView {
private func updatePlayerState() { private func updatePlayerState() {
let player = self.vlc.player let player = self.vlc.player
if player.isPlaying {
performInitialSeek()
}
self.onVideoStateChange?([ self.onVideoStateChange?([
"target": self.reactTag ?? NSNull(), "target": self.reactTag ?? NSNull(),
"currentTime": player.time.intValue, "currentTime": player.time.intValue,

View File

@@ -18,7 +18,7 @@
"lint": "biome check --write --unsafe" "lint": "biome check --write --unsafe"
}, },
"dependencies": { "dependencies": {
"@bottom-tabs/react-navigation": "0.8.6", "@bottom-tabs/react-navigation": "0.9.2",
"@expo/config-plugins": "~9.0.15", "@expo/config-plugins": "~9.0.15",
"@expo/react-native-action-sheet": "^4.1.1", "@expo/react-native-action-sheet": "^4.1.1",
"@expo/vector-icons": "^14.0.4", "@expo/vector-icons": "^14.0.4",
@@ -73,27 +73,27 @@
"react-i18next": "^15.4.0", "react-i18next": "^15.4.0",
"react-native": "npm:react-native-tvos@~0.77.2-0", "react-native": "npm:react-native-tvos@~0.77.2-0",
"react-native-awesome-slider": "^2.9.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-circular-progress": "^1.4.1",
"react-native-collapsible": "^1.6.2", "react-native-collapsible": "^1.6.2",
"react-native-compressor": "^1.10.3", "react-native-compressor": "^1.10.3",
"react-native-country-flag": "^2.0.2", "react-native-country-flag": "^2.0.2",
"react-native-device-info": "^14.0.4", "react-native-device-info": "^14.0.4",
"react-native-edge-to-edge": "^1.4.3", "react-native-edge-to-edge": "^1.4.3",
"react-native-gesture-handler": "~2.20.2", "react-native-gesture-handler": "~2.24.0",
"react-native-get-random-values": "^1.11.0", "react-native-get-random-values": "^1.11.0",
"react-native-google-cast": "^4.8.3", "react-native-google-cast": "^4.8.3",
"react-native-image-colors": "^2.4.0", "react-native-image-colors": "^2.4.0",
"react-native-ios-context-menu": "^3.1.0", "react-native-ios-context-menu": "^3.1.0",
"react-native-ios-utilities": "5.1.1", "react-native-ios-utilities": "5.1.1",
"react-native-mmkv": "^2.12.2", "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-progress": "^5.0.1",
"react-native-reanimated": "~3.16.7", "react-native-reanimated": "~3.16.7",
"react-native-reanimated-carousel": "3.5.1", "react-native-reanimated-carousel": "3.5.1",
"react-native-safe-area-context": "4.12.0", "react-native-safe-area-context": "5.2.0",
"react-native-screens": "~4.4.0", "react-native-screens": "4.10.0",
"react-native-svg": "15.8.0", "react-native-svg": "15.11.2",
"react-native-tab-view": "^4.0.5", "react-native-tab-view": "^4.0.5",
"react-native-udp": "^4.1.7", "react-native-udp": "^4.1.7",
"react-native-uitextview": "^1.4.0", "react-native-uitextview": "^1.4.0",
@@ -102,7 +102,7 @@
"react-native-video": "6.10.0", "react-native-video": "6.10.0",
"react-native-volume-manager": "^2.0.8", "react-native-volume-manager": "^2.0.8",
"react-native-web": "~0.19.13", "react-native-web": "~0.19.13",
"react-native-webview": "13.12.5", "react-native-webview": "13.13.0",
"sonner-native": "^0.17.0", "sonner-native": "^0.17.0",
"tailwindcss": "3.3.2", "tailwindcss": "3.3.2",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",

File diff suppressed because it is too large Load Diff

View File

@@ -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<number, DownloadedItem>;
}
/**
* 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<number, DownloadedItem>;
}
>;
}
/**
* 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<string, DownloadedItem>;
/** A map of series IDs to their downloaded series data. */
series: Record<string, DownloadedSeries>;
}
/**
* 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;
};

View File

@@ -91,9 +91,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const headers = useMemo(() => { const headers = useMemo(() => {
if (!deviceId) return {}; if (!deviceId) return {};
return { return {
authorization: `MediaBrowser Client="Streamyfin", Device=${ authorization: `MediaBrowser Client="Streamyfin", Device=${Platform.OS === "android" ? "Android" : "iOS"
Platform.OS === "android" ? "Android" : "iOS" }, DeviceId="${deviceId}", Version="0.28.1"`,
}, DeviceId="${deviceId}", Version="0.28.1"`,
}; };
}, [deviceId]); }, [deviceId]);
@@ -380,8 +379,6 @@ function useProtectedRoute(user: UserDto | null, loaded = false) {
useEffect(() => { useEffect(() => {
if (loaded === false) return; if (loaded === false) return;
console.log("Loaded", user);
const inAuthGroup = segments[0] === "(auth)"; const inAuthGroup = segments[0] === "(auth)";
if (!user?.Id && inAuthGroup) { if (!user?.Id && inAuthGroup) {

View File

@@ -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 (
<JobQueueContext.Provider value={null}>{children}</JobQueueContext.Provider>
);
};

View File

@@ -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 { import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import type React from "react"; import type React from "react";
import { import { createContext, useCallback, useContext, useState } from "react";
createContext, import type { Bitrate } from "@/components/BitrateSelector";
useCallback, import { settingsAtom } from "@/utils/atoms/settings";
useContext, import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
useEffect, import { generateDeviceProfile } from "@/utils/profiles/native";
useState,
} from "react";
import { apiAtom, userAtom } from "./JellyfinProvider"; import { apiAtom, userAtom } from "./JellyfinProvider";
export type PlaybackType = { export type PlaybackType = {

View File

@@ -408,6 +408,7 @@
"download_episode": "Download Episode", "download_episode": "Download Episode",
"download_movie": "Download Movie", "download_movie": "Download Movie",
"download_x_item": "Download {{item_count}} items", "download_x_item": "Download {{item_count}} items",
"download_unwatched_only": "Unwatched Only",
"download_button": "Download", "download_button": "Download",
"using_optimized_server": "Using optimized server", "using_optimized_server": "Using optimized server",
"using_default_method": "Using default method" "using_default_method": "Using default method"

View File

@@ -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 type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { useEffect } from "react"; import { useEffect } from "react";
import { processesAtom } from "@/providers/DownloadProvider";
import { useSettings } from "@/utils/atoms/settings";
import { JobStatus } from "@/providers/Downloads/types";
export interface Job { export interface Job {
id: string; id: string;
@@ -68,5 +68,5 @@ export const useJobProcessor = () => {
console.info("Processing queue", queue); console.info("Processing queue", queue);
queueActions.processJob(queue, setQueue, setRunning); queueActions.processJob(queue, setQueue, setRunning);
} }
}, [processes, queue, running, setQueue, setRunning]); }, [processes, queue, running, setQueue, setRunning, settings]);
}; };

View File

@@ -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 { import {
type BaseItemKind, type BaseItemKind,
type CultureDto, type CultureDto,
@@ -14,9 +9,13 @@ import {
import { atom, useAtom, useAtomValue } from "jotai"; import { atom, useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { Platform } from "react-native"; 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"; import { storage } from "../mmkv";
const STREAMYFIN_PLUGIN_ID = "1e9e5d386e6746158719e98a5c34f004"; const _STREAMYFIN_PLUGIN_ID = "1e9e5d386e6746158719e98a5c34f004";
const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS"; const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS";
export type DownloadQuality = "original" | "high" | "low"; export type DownloadQuality = "original" | "high" | "low";
@@ -82,7 +81,6 @@ export type DefaultLanguageOption = {
export enum DownloadMethod { export enum DownloadMethod {
Remux = "remux", Remux = "remux",
Optimized = "optimized",
} }
export type Home = { export type Home = {
@@ -156,7 +154,6 @@ export type Settings = {
defaultVideoOrientation: ScreenOrientation.OrientationLock; defaultVideoOrientation: ScreenOrientation.OrientationLock;
forwardSkipTime: number; forwardSkipTime: number;
rewindSkipTime: number; rewindSkipTime: number;
optimizedVersionsServerUrl?: string | null;
downloadMethod: DownloadMethod; downloadMethod: DownloadMethod;
autoDownload: boolean; autoDownload: boolean;
showCustomMenuLinks: boolean; showCustomMenuLinks: boolean;
@@ -213,7 +210,6 @@ const defaultValues: Settings = {
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT, defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
forwardSkipTime: 30, forwardSkipTime: 30,
rewindSkipTime: 10, rewindSkipTime: 10,
optimizedVersionsServerUrl: null,
downloadMethod: DownloadMethod.Remux, downloadMethod: DownloadMethod.Remux,
autoDownload: false, autoDownload: false,
showCustomMenuLinks: false, showCustomMenuLinks: false,
@@ -224,7 +220,7 @@ const defaultValues: Settings = {
jellyseerrServerUrl: undefined, jellyseerrServerUrl: undefined,
hiddenLibraries: [], hiddenLibraries: [],
enableH265ForChromecast: false, 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 }, maxAutoPlayEpisodeCount: { key: "3", value: 3 },
autoPlayEpisodeCount: 0, autoPlayEpisodeCount: 0,
}; };
@@ -288,7 +284,7 @@ export const useSettings = () => {
writeInfoLog("Got plugin settings", data?.settings); writeInfoLog("Got plugin settings", data?.settings);
return data?.settings; return data?.settings;
}, },
(err) => undefined, (_err) => undefined,
); );
setPluginSettings(settings); setPluginSettings(settings);
return settings; return settings;

View File

@@ -1,10 +1,11 @@
// utils/getDefaultPlaySettings.ts // utils/getDefaultPlaySettings.ts
import { BITRATES } from "@/components/BitrateSelector";
import type { import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client"; } 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 { import {
AudioStreamRanker, AudioStreamRanker,
StreamRanker, StreamRanker,
@@ -50,18 +51,9 @@ export function getDefaultPlaySettings(
const mediaSource = item.MediaSources?.[0]; 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. // We prefer the previous track over the default track.
const trackOptions: TrackOptions = { const trackOptions: TrackOptions = {
DefaultAudioStreamIndex: defaultAudioIndex ?? -1, DefaultAudioStreamIndex: mediaSource?.DefaultAudioStreamIndex ?? -1,
DefaultSubtitleStreamIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1, DefaultSubtitleStreamIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1,
}; };

View File

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

View File

@@ -1,12 +1,10 @@
import generateDeviceProfile from "@/utils/profiles/native";
import type { Api } from "@jellyfin/sdk"; import type { Api } from "@jellyfin/sdk";
import type { import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
PlaybackInfoResponse,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { Alert } from "react-native"; import download from "@/utils/profiles/download";
export const getStreamUrl = async ({ export const getStreamUrl = async ({
api, api,
@@ -15,11 +13,10 @@ export const getStreamUrl = async ({
startTimeTicks = 0, startTimeTicks = 0,
maxStreamingBitrate, maxStreamingBitrate,
playSessionId, playSessionId,
deviceProfile = generateDeviceProfile(), deviceProfile,
audioStreamIndex = 0, audioStreamIndex = 0,
subtitleStreamIndex = undefined, subtitleStreamIndex = undefined,
mediaSourceId, mediaSourceId,
download = false,
deviceId, deviceId,
}: { }: {
api: Api | null | undefined; api: Api | null | undefined;
@@ -28,12 +25,11 @@ export const getStreamUrl = async ({
startTimeTicks: number; startTimeTicks: number;
maxStreamingBitrate?: number; maxStreamingBitrate?: number;
playSessionId?: string | null; playSessionId?: string | null;
deviceProfile?: any; deviceProfile: any;
audioStreamIndex?: number; audioStreamIndex?: number;
subtitleStreamIndex?: number; subtitleStreamIndex?: number;
height?: number; height?: number;
mediaSourceId?: string | null; mediaSourceId?: string | null;
download?: bool;
deviceId?: string | null; deviceId?: string | null;
}): Promise<{ }): Promise<{
url: string | null; url: string | null;
@@ -73,12 +69,16 @@ export const getStreamUrl = async ({
} }
sessionId = res.data.PlaySessionId || null; sessionId = res.data.PlaySessionId || null;
mediaSource = res.data.MediaSources[0]; mediaSource = res.data.MediaSources?.[0];
let transcodeUrl = mediaSource.TranscodingUrl; let transcodeUrl = mediaSource?.TranscodingUrl;
if (transcodeUrl) { if (transcodeUrl) {
if (download) { // We need to change the subtitle method to hls for the transcoded url.
transcodeUrl = transcodeUrl.replace("master.m3u8", "stream"); if (subtitleStreamIndex === -1) {
transcodeUrl = transcodeUrl.replace(
"SubtitleMethod=Encode",
"SubtitleMethod=Hls",
);
} }
console.log("Video is being transcoded:", transcodeUrl); console.log("Video is being transcoded:", transcodeUrl);
return { 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({ const streamParams = new URLSearchParams({
static: "true", static: "true",
container: "mp4", container: "mp4",
@@ -114,7 +99,6 @@ export const getStreamUrl = async ({
startTimeTicks: startTimeTicks.toString(), startTimeTicks: startTimeTicks.toString(),
maxStreamingBitrate: maxStreamingBitrate?.toString() || "", maxStreamingBitrate: maxStreamingBitrate?.toString() || "",
userId: userId || "", userId: userId || "",
...downloadParams,
}); });
const directPlayUrl = `${ const directPlayUrl = `${
@@ -125,7 +109,113 @@ export const getStreamUrl = async ({
return { return {
url: directPlayUrl, 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, mediaSource,
}; };
}; };

View File

@@ -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<void> => {
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;
}
};

View File

@@ -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<boolean> => {
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;
}
};

View File

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

View File

@@ -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 { Api } from "@jellyfin/sdk";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import type { Settings } from "@/utils/atoms/settings";
import { generateDeviceProfile } from "@/utils/profiles/native";
import { getAuthHeaders } from "../jellyfin"; import { getAuthHeaders } from "../jellyfin";
interface PostCapabilitiesParams { interface PostCapabilitiesParams {
@@ -43,14 +43,14 @@ export const postCapabilities = async ({
], ],
supportsMediaControl: true, supportsMediaControl: true,
id: sessionId, id: sessionId,
DeviceProfile: generateDeviceProfile(), DeviceProfile: await generateDeviceProfile(),
}, },
{ {
headers: getAuthHeaders(api), headers: getAuthHeaders(api),
}, },
); );
return d; return d;
} catch (error) { } catch (_error) {
throw new Error("Failed to mark as not played"); throw new Error("Failed to mark as not played");
} }
}; };

View File

@@ -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<JobStatus[]>} 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<JobStatus[]> {
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<boolean> {
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<any | null>} 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<any | null> {
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;
}
}

View File

@@ -59,80 +59,55 @@ export default {
], ],
SubtitleProfiles: [ SubtitleProfiles: [
// Official foramts // Official foramts
{ Format: "vtt", Method: "Embed" },
{ Format: "vtt", Method: "Encode" }, { Format: "vtt", Method: "Encode" },
{ Format: "webvtt", Method: "Embed" },
{ Format: "webvtt", Method: "Encode" }, { Format: "webvtt", Method: "Encode" },
{ Format: "srt", Method: "Embed" },
{ Format: "srt", Method: "Encode" }, { Format: "srt", Method: "Encode" },
{ Format: "subrip", Method: "Embed" },
{ Format: "subrip", Method: "Encode" }, { Format: "subrip", Method: "Encode" },
{ Format: "ttml", Method: "Embed" },
{ Format: "ttml", Method: "Encode" }, { Format: "ttml", Method: "Encode" },
{ Format: "dvbsub", Method: "Embed" },
{ Format: "dvdsub", Method: "Encode" }, { Format: "dvdsub", Method: "Encode" },
{ Format: "ass", Method: "Embed" },
{ Format: "ass", Method: "Encode" }, { Format: "ass", Method: "Encode" },
{ Format: "idx", Method: "Embed" },
{ Format: "idx", Method: "Encode" }, { Format: "idx", Method: "Encode" },
{ Format: "pgs", Method: "Embed" },
{ Format: "pgs", Method: "Encode" }, { Format: "pgs", Method: "Encode" },
{ Format: "pgssub", Method: "Embed" },
{ Format: "pgssub", Method: "Encode" }, { Format: "pgssub", Method: "Encode" },
{ Format: "ssa", Method: "Embed" },
{ Format: "ssa", Method: "Encode" }, { Format: "ssa", Method: "Encode" },
// Other formats // Other formats
{ Format: "microdvd", Method: "Embed" },
{ Format: "microdvd", Method: "Encode" }, { Format: "microdvd", Method: "Encode" },
{ Format: "mov_text", Method: "Embed" },
{ Format: "mov_text", Method: "Encode" }, { Format: "mov_text", Method: "Encode" },
{ Format: "mpl2", Method: "Embed" },
{ Format: "mpl2", Method: "Encode" }, { Format: "mpl2", Method: "Encode" },
{ Format: "pjs", Method: "Embed" },
{ Format: "pjs", Method: "Encode" }, { Format: "pjs", Method: "Encode" },
{ Format: "realtext", Method: "Embed" },
{ Format: "realtext", Method: "Encode" }, { Format: "realtext", Method: "Encode" },
{ Format: "scc", Method: "Embed" },
{ Format: "scc", Method: "Encode" }, { Format: "scc", Method: "Encode" },
{ Format: "smi", Method: "Embed" },
{ Format: "smi", Method: "Encode" }, { Format: "smi", Method: "Encode" },
{ Format: "stl", Method: "Embed" },
{ Format: "stl", Method: "Encode" }, { Format: "stl", Method: "Encode" },
{ Format: "sub", Method: "Embed" },
{ Format: "sub", Method: "Encode" }, { Format: "sub", Method: "Encode" },
{ Format: "subviewer", Method: "Embed" },
{ Format: "subviewer", Method: "Encode" }, { Format: "subviewer", Method: "Encode" },
{ Format: "teletext", Method: "Embed" },
{ Format: "teletext", Method: "Encode" }, { Format: "teletext", Method: "Encode" },
{ Format: "text", Method: "Embed" },
{ Format: "text", Method: "Encode" }, { Format: "text", Method: "Encode" },
{ Format: "vplayer", Method: "Embed" },
{ Format: "vplayer", Method: "Encode" }, { Format: "vplayer", Method: "Encode" },
{ Format: "xsub", Method: "Embed" },
{ Format: "xsub", Method: "Encode" }, { Format: "xsub", Method: "Encode" },
], ],
}; };

View File

@@ -6,6 +6,7 @@ import DeviceInfo from "react-native-device-info";
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import MediaTypes from "../../constants/MediaTypes"; import MediaTypes from "../../constants/MediaTypes";
import { getSubtitleProfiles } from "./subtitles";
// Helper function to detect Dolby Vision support // Helper function to detect Dolby Vision support
const supportsDolbyVision = async () => { const supportsDolbyVision = async () => {
@@ -27,13 +28,14 @@ const supportsDolbyVision = async () => {
return false; return false;
}; };
export const generateDeviceProfile = async () => { export const generateDeviceProfile = async ({ transcode = false } = {}) => {
console.log("generating device profile", { transcode });
const dolbyVisionSupported = await supportsDolbyVision(); const dolbyVisionSupported = await supportsDolbyVision();
/** /**
* Device profile for Native video player * Device profile for Native video player
*/ */
const profile = { const profile = {
Name: "1. Vlc Player", Name: `1. Vlc Player${transcode ? " (Transcoding)" : ""}`,
MaxStaticBitrate: 999_999_999, MaxStaticBitrate: 999_999_999,
MaxStreamingBitrate: 999_999_999, MaxStreamingBitrate: 999_999_999,
CodecProfiles: [ CodecProfiles: [
@@ -62,7 +64,7 @@ export const generateDeviceProfile = async () => {
DirectPlayProfiles: [ DirectPlayProfiles: [
{ {
Type: MediaTypes.Video, 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: VideoCodec:
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video", "h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma,dts", AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma,dts",
@@ -79,7 +81,7 @@ export const generateDeviceProfile = async () => {
Type: MediaTypes.Video, Type: MediaTypes.Video,
Context: "Streaming", Context: "Streaming",
Protocol: "hls", Protocol: "hls",
Container: "ts", Container: transcode ? "fmp4" : "ts",
VideoCodec: "h264, hevc", VideoCodec: "h264, hevc",
AudioCodec: "aac,mp3,ac3,dts", AudioCodec: "aac,mp3,ac3,dts",
}, },
@@ -92,84 +94,7 @@ export const generateDeviceProfile = async () => {
MaxAudioChannels: "2", MaxAudioChannels: "2",
}, },
], ],
SubtitleProfiles: [ SubtitleProfiles: getSubtitleProfiles(transcode ? "hls" : "External"),
// 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" },
],
}; };
// Add Dolby Vision restriction if not supported // Add Dolby Vision restriction if not supported
@@ -192,5 +117,5 @@ export const generateDeviceProfile = async () => {
}; };
export default async () => { export default async () => {
return await generateDeviceProfile(); return await generateDeviceProfile({ transcode: false });
}; };

View File

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

114
utils/segments.ts Normal file
View File

@@ -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<IntroTimestamps>(
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
{
headers: getAuthHeaders(api),
},
),
api.axiosInstance.get<CreditTimestamps>(
`${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 };
};