Compare commits

...

23 Commits

Author SHA1 Message Date
Fredrik Burmester
744c35d71c fix: design 2025-10-06 10:16:56 +02:00
Fredrik Burmester
f28d6ca56d fix: patch for image 2025-10-06 10:14:13 +02:00
Fredrik Burmester
23406b957d fix: design 2025-10-06 10:13:59 +02:00
Fredrik Burmester
ec7954036e fix: delete entire season wrong params 2025-10-06 10:13:49 +02:00
Fredrik Burmester
87716aff92 fix: deisng 2025-10-04 09:20:01 +02:00
Fredrik Burmester
380f5cbf70 fix: design 2025-10-04 09:13:49 +02:00
Fredrik Burmester
23c1c817a0 feat: upgrade to native wind v5 2025-10-03 19:34:58 +02:00
Fredrik Burmester
3a8fb0a5e5 fix: remove unused code 2025-10-03 14:47:02 +02:00
Fredrik Burmester
06e19bd7e6 feat: download finish never registered 2025-10-03 14:03:49 +02:00
Fredrik Burmester
ac0f088ee3 Remove build artifacts from git tracking 2025-10-03 13:29:22 +02:00
Fredrik Burmester
930c98caec feat: enhance background downloader with internal storage support and logging 2025-10-03 13:29:14 +02:00
Fredrik Burmester
5894272149 fix: don't commit some files 2025-10-03 13:22:45 +02:00
Fredrik Burmester
0b39ab0212 fix: remove all references to old background downloader 2025-10-03 13:03:42 +02:00
Fredrik Burmester
e905737d5b wip: android support 2025-10-03 11:15:33 +02:00
Fredrik Burmester
4517fe354b fix: download status 2025-10-03 11:02:58 +02:00
Fredrik Burmester
d764e5f9d2 fix: speed calculation 2025-10-03 08:52:39 +02:00
Fredrik Burmester
7fef2ed5e2 fix: auto update on download/file actions 2025-10-03 07:57:45 +02:00
Fredrik Burmester
c36cd66e36 fix: delete items 2025-10-03 07:54:39 +02:00
Fredrik Burmester
1363c3137e fix: download speed 2025-10-03 07:45:18 +02:00
Fredrik Burmester
e55f2462e5 fix: download metadata 2025-10-03 07:24:59 +02:00
Fredrik Burmester
c88de0250f fix: working downloads 2025-10-03 07:07:28 +02:00
Fredrik Burmester
8d59065c49 fix: building 2025-10-02 20:54:25 +02:00
Fredrik Burmester
ec622aba55 wip: bg downloader module 2025-10-02 20:12:02 +02:00
72 changed files with 3904 additions and 1800 deletions

View File

@@ -0,0 +1,5 @@
---
alwaysApply: true
---
Don't run the development server or build anything. Assume the user has a separate terminal. Tell the user what to execute.

3
.gitignore vendored
View File

@@ -46,4 +46,5 @@ streamyfin-4fec1-firebase-adminsdk.json
.env.local
*.aab
/version-backup-*
bun.lockb
bun.lockb
modules/background-downloader/android/build/*

View File

@@ -6,9 +6,6 @@ module.exports = ({ config }) => {
"react-native-google-cast",
{ useDefaultExpandedMediaControls: true },
]);
// Add the background downloader plugin only for non-TV builds
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
}
return {
android: {

View File

@@ -23,12 +23,12 @@ export default function page() {
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
{},
);
const { getDownloadedItems, deleteItems } = useDownload();
const { downloadedItems, deleteItems } = useDownload();
const series = useMemo(() => {
try {
return (
getDownloadedItems()
downloadedItems
?.filter((f) => f.item.SeriesId === seriesId)
?.sort(
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
@@ -37,7 +37,7 @@ export default function page() {
} catch {
return [];
}
}, [getDownloadedItems]);
}, [downloadedItems, seriesId]);
// Group episodes by season in a single pass
const seasonGroups = useMemo(() => {
@@ -107,7 +107,12 @@ export default function page() {
},
{
text: "Delete",
onPress: () => deleteItems(groupBySeason),
onPress: () =>
deleteItems(
groupBySeason
.map((episode) => episode.Id!)
.filter((id) => id !== undefined),
),
style: "destructive",
},
],
@@ -140,7 +145,7 @@ export default function page() {
</View>
</View>
)}
<ScrollView key={seasonIndex} className='px-4'>
<ScrollView key={seasonIndex} style={{ paddingHorizontal: 16 }}>
{groupBySeason.map((episode, index) => (
<EpisodeCard key={index} item={episode} />
))}

View File

@@ -27,12 +27,8 @@ export default function page() {
const navigation = useNavigation();
const { t } = useTranslation();
const [queue, setQueue] = useAtom(queueAtom);
const {
removeProcess,
getDownloadedItems,
deleteFileByType,
deleteAllFiles,
} = useDownload();
const { removeProcess, downloadedItems, deleteFileByType, deleteAllFiles } =
useDownload();
const router = useRouter();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -62,10 +58,7 @@ export default function page() {
);
};
const downloadedFiles = useMemo(
() => getDownloadedItems(),
[getDownloadedItems],
);
const downloadedFiles = downloadedItems;
const movies = useMemo(() => {
try {
@@ -109,7 +102,10 @@ export default function page() {
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
<TouchableOpacity
onPress={bottomSheetModalRef.current?.present}
className='px-2'
>
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
</TouchableOpacity>
),
@@ -146,23 +142,25 @@ export default function page() {
});
const deleteOtherMedia = () =>
Promise.all(
otherMedia.map((item) =>
deleteFileByType(item.item.Type)
.then(() =>
toast.success(
t("home.downloads.toasts.deleted_media_successfully", {
type: item.item.Type,
}),
),
)
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error(
t("home.downloads.toasts.failed_to_delete_media", {
type: item.item.Type,
}),
);
}),
otherMedia.map(
(item) =>
item.item.Type &&
deleteFileByType(item.item.Type)
.then(() =>
toast.success(
t("home.downloads.toasts.deleted_media_successfully", {
type: item.item.Type,
}),
),
)
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error(
t("home.downloads.toasts.failed_to_delete_media", {
type: item.item.Type,
}),
);
}),
),
);
@@ -174,7 +172,7 @@ export default function page() {
<View style={{ flex: 1 }}>
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
<View className='py-4'>
<View className='mb-4 flex flex-col space-y-4 px-4'>
<View className='mb-4 flex flex-col gap-y-4 px-4'>
<View className='bg-neutral-900 p-4 rounded-2xl'>
<Text className='text-lg font-bold'>
{t("home.downloads.queue")}
@@ -182,7 +180,7 @@ export default function page() {
<Text className='text-xs opacity-70 text-red-600'>
{t("home.downloads.queue_hint")}
</Text>
<View className='flex flex-col space-y-2 mt-2'>
<View className='flex flex-col gap-y-2 mt-2'>
{queue.map((q, index) => (
<TouchableOpacity
onPress={() =>

View File

@@ -258,7 +258,7 @@ const SessionCard = ({ session }: SessionCardProps) => {
</View>
{/* Session controls */}
<View className='flex flex-row mt-2 space-x-4 justify-center'>
<View className='flex flex-row mt-2 gap-x-4 justify-center'>
<TouchableOpacity
onPress={handlePrevious}
disabled={isControlLoading[PlaystateCommand.PreviousTrack]}

View File

@@ -232,7 +232,7 @@ const Page: React.FC = () => {
}
>
<View className='flex flex-col'>
<View className='space-y-4'>
<View className='gap-y-4'>
<View className='px-4'>
<View className='flex flex-row justify-between w-full'>
<View className='flex flex-col w-56'>

View File

@@ -105,7 +105,7 @@ const page: React.FC = () => {
/>
}
>
<View className='flex flex-col space-y-4 my-4'>
<View className='flex flex-col gap-y-4 my-4'>
<View className='px-4 mb-4'>
<MoviesTitleHeader item={item} className='mb-4' />
<OverviewText text={item.Overview} />

View File

@@ -1,6 +1,11 @@
import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device";
import { Paths } from "expo-file-system";
import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal";
import i18n from "@/i18n";
@@ -28,18 +33,6 @@ import {
} from "@/utils/log";
import { storage } from "@/utils/mmkv";
const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
: null;
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device";
import { Paths } from "expo-file-system";
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
import { getLocales } from "expo-localization";
@@ -50,7 +43,7 @@ import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef, useState } from "react";
import { I18nextProvider } from "react-i18next";
import { Appearance, AppState } from "react-native";
import { Appearance } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
@@ -66,6 +59,7 @@ import { useAtom } from "jotai";
import { Toaster } from "sonner-native";
import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store";
import "../global.css";
if (!Platform.isTV) {
Notifications.setNotificationHandler({
@@ -228,7 +222,6 @@ function Layout() {
const { settings } = useSettings();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const appState = useRef(AppState.currentState);
const segments = useSegments();
useEffect(() => {
@@ -387,34 +380,6 @@ function Layout() {
segments,
]);
useEffect(() => {
if (Platform.isTV || !BackGroundDownloader) {
return;
}
const subscription = AppState.addEventListener("change", (nextAppState) => {
if (
appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
BackGroundDownloader?.checkForExistingDownloads().catch(
(error: unknown) => {
writeErrorLog("Failed to resume background downloads", error);
},
);
}
});
BackGroundDownloader?.checkForExistingDownloads().catch(
(error: unknown) => {
writeErrorLog("Failed to resume background downloads", error);
},
);
return () => {
subscription.remove();
};
}, []);
return (
<QueryClientProvider client={queryClient}>
<JellyfinProvider>

View File

@@ -375,7 +375,7 @@ const Login: React.FC = () => {
{api?.basePath ? (
<View className='flex flex-col h-full relative items-center justify-center'>
<View className='px-4 -mt-20 w-full'>
<View className='flex flex-col space-y-2'>
<View className='flex flex-col gap-y-2'>
<Text className='text-2xl font-bold -mb-2'>
{serverName ? (
<>

View File

@@ -2,6 +2,6 @@ module.exports = (api) => {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: ["nativewind/babel", "react-native-worklets/plugin"],
plugins: ["react-native-worklets/plugin"],
};
};

251
bun.lock
View File

@@ -11,11 +11,11 @@
"@expo/vector-icons": "^15.0.2",
"@gorhom/bottom-sheet": "^5.1.0",
"@jellyfin/sdk": "^0.11.0",
"@kesha-antonov/react-native-background-downloader": "github:fredrikburmester/react-native-background-downloader#d78699b60866062f6d95887412cee3649a548bf2",
"@react-native-community/netinfo": "^11.4.1",
"@react-navigation/material-top-tabs": "^7.2.14",
"@react-navigation/native": "^7.0.14",
"@shopify/flash-list": "2.0.2",
"@tailwindcss/postcss": "^4.1.14",
"@tanstack/react-query": "^5.66.0",
"axios": "^1.7.9",
"expo": "^54.0.10",
@@ -47,8 +47,9 @@
"i18next": "^25.0.0",
"jotai": "^2.12.5",
"lodash": "^4.17.21",
"nativewind": "^2.0.11",
"nativewind": "^5.0.0-preview.1",
"patch-package": "^8.0.0",
"postcss": "^8.5.6",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^15.4.0",
@@ -58,6 +59,7 @@
"react-native-circular-progress": "^1.4.1",
"react-native-collapsible": "^1.6.2",
"react-native-country-flag": "^2.0.2",
"react-native-css": "^3.0.0",
"react-native-device-info": "^14.0.4",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.28.0",
@@ -68,7 +70,7 @@
"react-native-mmkv": "4.0.0-beta.12",
"react-native-nitro-modules": "^0.29.1",
"react-native-pager-view": "^6.9.1",
"react-native-reanimated": "~4.1.0",
"react-native-reanimated": "~4.1.1",
"react-native-reanimated-carousel": "4.0.2",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
@@ -81,7 +83,6 @@
"react-native-web": "^0.21.0",
"react-native-worklets": "0.5.1",
"sonner-native": "^0.21.0",
"tailwindcss": "3.3.2",
"use-debounce": "^10.0.4",
"zod": "^4.1.3",
},
@@ -100,6 +101,7 @@
"lint-staged": "^16.1.5",
"postinstall-postinstall": "^2.1.0",
"react-test-renderer": "19.1.1",
"tailwindcss": "^4.1.14",
"typescript": "~5.9.2",
},
},
@@ -107,6 +109,9 @@
"trustedDependencies": [
"postinstall-postinstall",
],
"overrides": {
"lightningcss": "1.30.1",
},
"packages": {
"@0no-co/graphql.web": ["@0no-co/graphql.web@1.2.0", "", { "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw=="],
@@ -134,7 +139,7 @@
"@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
@@ -450,8 +455,6 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@kesha-antonov/react-native-background-downloader": ["@kesha-antonov/react-native-background-downloader@github:fredrikburmester/react-native-background-downloader#d78699b", { "peerDependencies": { "react-native": ">=0.57.0" } }, "fredrikburmester-react-native-background-downloader-d78699b"],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -580,6 +583,36 @@
"@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.14", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.0", "lightningcss": "1.30.1", "magic-string": "^0.30.19", "source-map-js": "^1.2.1", "tailwindcss": "4.1.14" } }, "sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.14", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.5.1" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.14", "@tailwindcss/oxide-darwin-arm64": "4.1.14", "@tailwindcss/oxide-darwin-x64": "4.1.14", "@tailwindcss/oxide-freebsd-x64": "4.1.14", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.14", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.14", "@tailwindcss/oxide-linux-arm64-musl": "4.1.14", "@tailwindcss/oxide-linux-x64-gnu": "4.1.14", "@tailwindcss/oxide-linux-x64-musl": "4.1.14", "@tailwindcss/oxide-wasm32-wasi": "4.1.14", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.14", "@tailwindcss/oxide-win32-x64-msvc": "4.1.14" } }, "sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.14", "", { "os": "android", "cpu": "arm64" }, "sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14", "", { "os": "linux", "cpu": "arm" }, "sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.14", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.5", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.14", "", { "os": "win32", "cpu": "x64" }, "sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA=="],
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.14", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.14", "@tailwindcss/oxide": "4.1.14", "postcss": "^8.4.41", "tailwindcss": "4.1.14" } }, "sha512-BdMjIxy7HUNThK87C7BC8I1rE8BVUsfNQSI5siQ4JK3iIa3w0XyVvVL9SXLWO//CtYTcp1v7zci0fYwJOjB+Zg=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.2", "", {}, "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.2", "", { "dependencies": { "@tanstack/query-core": "5.90.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw=="],
@@ -690,6 +723,8 @@
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"array-timsort": ["array-timsort@1.0.3", "", {}, "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ=="],
"asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
"assert": ["assert@2.1.0", "", { "dependencies": { "call-bind": "^1.0.2", "is-nan": "^1.3.2", "object-is": "^1.1.5", "object.assign": "^4.1.4", "util": "^0.12.5" } }, "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw=="],
@@ -742,8 +777,6 @@
"big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"bmp-js": ["bmp-js@0.1.0", "", {}, "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw=="],
@@ -784,16 +817,10 @@
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001746", "", {}, "sha512-eA7Ys/DGw+pnkWWSE/id29f2IcPHVoE8wxtvE5JdvD2V28VTDPy1yEeo11Guz0sJ4ZeGRcm3uaTcAqK1LXaphA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001747", "", {}, "sha512-mzFa2DGIhuc5490Nd/G31xN1pnBnYMadtkyTjefPI7wzypqgCEpeWu9bJr0OnDsyKrW75zA9ZAt7pbQFmwLsQg=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="],
@@ -824,12 +851,16 @@
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
"colorjs.io": ["colorjs.io@0.6.0-alpha.1", "", {}, "sha512-c/h/8uAmPydQcriRdX8UTAFHj6SpSHFHBA8LvMikvYWAVApPTwg/pyOXNsGmaCBd6L/EeDlRHSNhTtnIFp/qsg=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"command-exists": ["command-exists@1.2.9", "", {}, "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w=="],
"commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
"comment-json": ["comment-json@4.4.1", "", { "dependencies": { "array-timsort": "^1.0.3", "core-util-is": "^1.0.3", "esprima": "^4.0.1" } }, "sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg=="],
"compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="],
"compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="],
@@ -844,6 +875,8 @@
"core-js-compat": ["core-js-compat@3.45.1", "", { "dependencies": { "browserslist": "^4.25.3" } }, "sha512-tqTt5T4PzsMIZ430XGviK4vzYSoeNJ6CXODi6c/voxOT6IZqBht5/EKaSNnYiEjjRYxjVz7DQIsOsY0XNi8PIA=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="],
"cross-env": ["cross-env@10.1.0", "", { "dependencies": { "@epic-web/invariant": "^1.0.0", "cross-spawn": "^7.0.6" }, "bin": { "cross-env": "dist/bin/cross-env.js", "cross-env-shell": "dist/bin/cross-env-shell.js" } }, "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw=="],
@@ -854,22 +887,14 @@
"crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="],
"css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="],
"css-in-js-utils": ["css-in-js-utils@3.1.0", "", { "dependencies": { "hyphenate-style-name": "^1.0.3" } }, "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A=="],
"css-mediaquery": ["css-mediaquery@0.1.2", "", {}, "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q=="],
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
"css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="],
"css-tree": ["css-tree@1.1.3", "", { "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" } }, "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q=="],
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"dayjs": ["dayjs@1.11.18", "", {}, "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA=="],
@@ -902,12 +927,8 @@
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
@@ -926,12 +947,14 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"electron-to-chromium": ["electron-to-chromium@1.5.228", "", {}, "sha512-nxkiyuqAn4MJ1QbobwqJILiDtu/jk14hEAWaMiJmNPh1Z+jqoFlBFZjdXwLWGeVSeu9hGLg6+2G9yJaW8rBIFA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.230", "", {}, "sha512-A6A6Fd3+gMdaed9wX83CvHYJb4UuapPD5X5SLq72VZJzxHSY0/LUweGXRWmQlh2ln7KV7iw7jnwXK7dlPoOnHQ=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"env-editor": ["env-editor@0.4.2", "", {}, "sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA=="],
@@ -1058,8 +1081,6 @@
"exponential-backoff": ["exponential-backoff@3.1.2", "", {}, "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
@@ -1138,7 +1159,7 @@
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"global-dirs": ["global-dirs@0.1.1", "", { "dependencies": { "ini": "^1.3.4" } }, "sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg=="],
@@ -1206,8 +1227,6 @@
"is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
@@ -1276,7 +1295,7 @@
"jimp-compact": ["jimp-compact@0.16.1", "", {}, "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww=="],
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"joi": ["joi@17.13.3", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA=="],
@@ -1318,31 +1337,27 @@
"lighthouse-logger": ["lighthouse-logger@1.4.2", "", { "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" } }, "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
@@ -1368,6 +1383,8 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
"makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="],
"marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="],
@@ -1442,7 +1459,7 @@
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nativewind": ["nativewind@2.0.11", "", { "dependencies": { "@babel/generator": "^7.18.7", "@babel/helper-module-imports": "7.18.6", "@babel/types": "7.19.0", "css-mediaquery": "^0.1.2", "css-to-react-native": "^3.0.0", "micromatch": "^4.0.5", "postcss": "^8.4.12", "postcss-calc": "^8.2.4", "postcss-color-functional-notation": "^4.2.2", "postcss-css-variables": "^0.18.0", "postcss-nested": "^5.0.6", "react-is": "^18.1.0", "use-sync-external-store": "^1.1.0" }, "peerDependencies": { "tailwindcss": "~3" } }, "sha512-qCEXUwKW21RYJ33KRAJl3zXq2bCq82WoI564fI21D/TiqhfmstZOqPN53RF8qK1NDK6PGl56b2xaTxgObEePEg=="],
"nativewind": ["nativewind@5.0.0-preview.1", "", { "dependencies": { "tailwindcss-safe-area": "^1.1.0" }, "peerDependencies": { "lightningcss": ">=1.27.0", "react-native-css": "^3.0.0", "tailwindcss": ">4.1.11" } }, "sha512-Vw1b4dRtfNgZdV/FFqsPkD0LYVDffGl0zCTbEb2fuqFoUClxeIXYjw0FE+SfhxmrMMNKOrWNGWwlqp83lBco/Q=="],
"negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="],
@@ -1476,8 +1493,6 @@
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"object-is": ["object-is@1.1.6", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1" } }, "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q=="],
@@ -1538,8 +1553,6 @@
"pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"pixelmatch": ["pixelmatch@4.0.2", "", { "dependencies": { "pngjs": "^3.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-J8B6xqiO37sU/gkcMglv6h5Jbd9xNER7aHzpfRdNmV4IbQBzBpe4l9XmbG+xPF/znacgu2jfEw+wHffaq/YkXA=="],
@@ -1552,22 +1565,6 @@
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-calc": ["postcss-calc@8.2.4", "", { "dependencies": { "postcss-selector-parser": "^6.0.9", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.2.2" } }, "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q=="],
"postcss-color-functional-notation": ["postcss-color-functional-notation@4.2.4", "", { "dependencies": { "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.2" } }, "sha512-2yrTAUZUab9s6CpxkxC4rVgFEVaR6/2Pipvi6qcgvnYiVqZcbDHEoBDhrXzyb7Efh2CCfHQNtcqWcIruDTIUeg=="],
"postcss-css-variables": ["postcss-css-variables@0.18.0", "", { "dependencies": { "balanced-match": "^1.0.0", "escape-string-regexp": "^1.0.3", "extend": "^3.0.1" }, "peerDependencies": { "postcss": "^8.2.6" } }, "sha512-lYS802gHbzn1GI+lXvy9MYIYDuGnl1WB4FTKoqMQqJ3Mab09A7a/1wZvGTkCEZJTM8mSbIyb1mJYn8f0aPye0Q=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
"postcss-load-config": ["postcss-load-config@4.0.2", "", { "dependencies": { "lilconfig": "^3.0.0", "yaml": "^2.3.4" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ=="],
"postcss-nested": ["postcss-nested@5.0.6", "", { "dependencies": { "postcss-selector-parser": "^6.0.6" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA=="],
"postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"postinstall-postinstall": ["postinstall-postinstall@2.1.0", "", {}, "sha512-7hQX6ZlZXIoRiWNrbMQaLzUUfH+sSx39u8EJ9HYuDc1kLo9IXKWjM5RSquZN1ad5GnH8CGFM78fsAAQi3OKEEQ=="],
@@ -1634,6 +1631,8 @@
"react-native-country-flag": ["react-native-country-flag@2.0.2", "", {}, "sha512-5LMWxS79ZQ0Q9ntYgDYzWp794+HcQGXQmzzZNBR1AT7z5HcJHtX7rlk8RHi7RVzfp5gW6plWSZ4dKjRpu/OafQ=="],
"react-native-css": ["react-native-css@3.0.0", "", { "dependencies": { "babel-plugin-react-compiler": "^19.1.0-rc.2", "colorjs.io": "0.6.0-alpha.1", "comment-json": "^4.2.5", "debug": "^4.4.1" }, "peerDependencies": { "@expo/metro-config": ">=54", "react": ">=19", "react-native": ">=0.81" } }, "sha512-4se5q78MmkH5C+9bClPcks+yo6lB8VZ/fYWDCyhgnRtNDg3H7DQsuYaZYN44r3GW6lnLQfSQKkHHm8sh68kipw=="],
"react-native-device-info": ["react-native-device-info@14.1.1", "", { "peerDependencies": { "react-native": "*" } }, "sha512-lXFpe6DJmzbQXNLWxlMHP2xuTU5gwrKAvI8dCAZuERhW9eOXSubOQIesk9lIBnsi9pI19GMrcpJEvs4ARPRYmw=="],
"react-native-edge-to-edge": ["react-native-edge-to-edge@1.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ERegbsq28yoMndn/Uq49i4h6aAhMvTEjOfkFh50yX9H/dMjjCr/Tix/es/9JcPRvC+q7VzCMWfxWDUb6Jrq1OQ=="],
@@ -1692,14 +1691,10 @@
"react-test-renderer": ["react-test-renderer@19.1.1", "", { "dependencies": { "react-is": "^19.1.1", "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-aGRXI+zcBTtg0diHofc7+Vy97nomBs9WHHFY1Csl3iV0x6xucjNYZZAkiVKGiNYUv23ecOex5jE67t8ZzqYObA=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="],
"regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="],
@@ -1858,7 +1853,11 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
"tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="],
"tailwindcss-safe-area": ["tailwindcss-safe-area@1.1.0", "", { "peerDependencies": { "tailwindcss": "^4.0.0" } }, "sha512-wuPUeW5BhWNv9yr3OzaGgpqImQG9FBM4mQIQh2C6yjHmOOZsJ3gh5RfNHt+TM16TMtpQs/8k2TWx8yQTFG7Fcw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tar": ["tar@7.5.1", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g=="],
@@ -1884,8 +1883,6 @@
"tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="],
"to-fast-properties": ["to-fast-properties@2.0.0", "", {}, "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
@@ -2014,16 +2011,8 @@
"zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="],
"@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@babel/highlight/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
"@babel/plugin-transform-async-to-generator/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@babel/plugin-transform-react-jsx/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@babel/plugin-transform-runtime/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@expo/cli/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
"@expo/cli/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
@@ -2122,6 +2111,12 @@
"@react-native-community/cli-tools/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"@react-native/community-cli-plugin/metro": ["metro@0.83.3", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.32.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.83.3", "metro-cache": "0.83.3", "metro-cache-key": "0.83.3", "metro-config": "0.83.3", "metro-core": "0.83.3", "metro-file-map": "0.83.3", "metro-resolver": "0.83.3", "metro-runtime": "0.83.3", "metro-source-map": "0.83.3", "metro-symbolicate": "0.83.3", "metro-transform-plugins": "0.83.3", "metro-transform-worker": "0.83.3", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-+rP+/GieOzkt97hSJ0MrPOuAH/jpaS21ZDvL9DJ35QYRDlQcwzcvUlGUf79AnQxq/2NPiS/AULhhM4TKutIt8Q=="],
"@react-native/community-cli-plugin/metro-config": ["metro-config@0.83.3", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.83.3", "metro-cache": "0.83.3", "metro-core": "0.83.3", "metro-runtime": "0.83.3", "yaml": "^2.6.1" } }, "sha512-mTel7ipT0yNjKILIan04bkJkuCzUUkm2SeEaTads8VfEecCh+ltXchdq6DovXJqzQAXuR2P9cxZB47Lg4klriA=="],
"@react-native/community-cli-plugin/metro-core": ["metro-core@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.83.3" } }, "sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw=="],
"@react-native/community-cli-plugin/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"@react-navigation/bottom-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
@@ -2130,6 +2125,18 @@
"@react-navigation/material-top-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.6", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-DXj75ewm11LIWUk198QSKUTxjyRjsBwk09MuMk5DGK+GDUtyPhhEHOGP/Xwwj3DjQXXkivoBirmOnKrLfc0+9g=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"ansi-fragments/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
@@ -2142,16 +2149,12 @@
"babel-jest/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"babel-preset-expo/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"caller-callsite/callsites": ["callsites@2.0.0", "", {}, "sha512-ksWePWBloaWPxJYQ8TL0JHvtci6G5QTKwQ95RcWAa/lzoAKuAOflGdAK92hpHXjkwb8zLxoLNUoNYZgVsaJzvQ=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"cli-truncate/string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="],
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
@@ -2172,8 +2175,6 @@
"expo-router/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fbjs/promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="],
"fbjs/ua-parser-js": ["ua-parser-js@1.0.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug=="],
@@ -2228,10 +2229,6 @@
"metro-transform-worker/metro-source-map": ["metro-source-map@0.83.1", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.1", "nullthrows": "^1.1.1", "ob1": "0.83.1", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-De7Vbeo96fFZ2cqmI0fWwVJbtHIwPZv++LYlWSwzTiCzxBDJORncN0LcT48Vi2UlQLzXJg+/CuTAcy7NBVh69A=="],
"nativewind/@babel/types": ["@babel/types@7.19.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.18.10", "@babel/helper-validator-identifier": "^7.18.6", "to-fast-properties": "^2.0.0" } }, "sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA=="],
"nativewind/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"node-vibrant/@types/node": ["@types/node@18.19.129", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-hrmi5jWt2w60ayox3iIXwpMEnfUvOLJCRtrOPbHtH15nTjvO7uhnelvrdAs0dO0/zl5DZ3ZbahiaXEVb54ca/A=="],
"npm-package-arg/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
@@ -2242,10 +2239,6 @@
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
"postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
@@ -2290,8 +2283,6 @@
"sucrase/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
"tailwindcss/postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"terminal-link/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
@@ -2348,6 +2339,30 @@
"@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
"@react-native/community-cli-plugin/metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="],
"@react-native/community-cli-plugin/metro/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="],
"@react-native/community-cli-plugin/metro/metro-babel-transformer": ["metro-babel-transformer@0.83.3", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.32.0", "nullthrows": "^1.1.1" } }, "sha512-1vxlvj2yY24ES1O5RsSIvg4a4WeL7PFXgKOHvXTXiW0deLvQr28ExXj6LjwCCDZ4YZLhq6HddLpZnX4dEdSq5g=="],
"@react-native/community-cli-plugin/metro/metro-cache": ["metro-cache@0.83.3", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.3" } }, "sha512-3jo65X515mQJvKqK3vWRblxDEcgY55Sk3w4xa6LlfEXgQ9g1WgMh9m4qVZVwgcHoLy0a2HENTPCCX4Pk6s8c8Q=="],
"@react-native/community-cli-plugin/metro/metro-cache-key": ["metro-cache-key@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-59ZO049jKzSmvBmG/B5bZ6/dztP0ilp0o988nc6dpaDsU05Cl1c/lRf+yx8m9WW/JVgbmfO5MziBU559XjI5Zw=="],
"@react-native/community-cli-plugin/metro/metro-file-map": ["metro-file-map@0.83.3", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-jg5AcyE0Q9Xbbu/4NAwwZkmQn7doJCKGW0SLeSJmzNB9Z24jBe0AL2PHNMy4eu0JiKtNWHz9IiONGZWq7hjVTA=="],
"@react-native/community-cli-plugin/metro/metro-resolver": ["metro-resolver@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-0js+zwI5flFxb1ktmR///bxHYg7OLpRpWZlBBruYG8OKYxeMP7SV0xQ/o/hUelrEMdK4LJzqVtHAhBm25LVfAQ=="],
"@react-native/community-cli-plugin/metro/metro-transform-plugins": ["metro-transform-plugins@0.83.3", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-eRGoKJU6jmqOakBMH5kUB7VitEWiNrDzBHpYbkBXW7C5fUGeOd2CyqrosEzbMK5VMiZYyOcNFEphvxk3OXey2A=="],
"@react-native/community-cli-plugin/metro/metro-transform-worker": ["metro-transform-worker@0.83.3", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "metro": "0.83.3", "metro-babel-transformer": "0.83.3", "metro-cache": "0.83.3", "metro-cache-key": "0.83.3", "metro-minify-terser": "0.83.3", "metro-source-map": "0.83.3", "metro-transform-plugins": "0.83.3", "nullthrows": "^1.1.1" } }, "sha512-Ztekew9t/gOIMZX1tvJOgX7KlSLL5kWykl0Iwu2cL2vKMKVALRl1hysyhUw0vjpAvLFx+Kfq9VLjnHIkW32fPA=="],
"@react-native/community-cli-plugin/metro/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
"@react-native/community-cli-plugin/metro-config/metro-cache": ["metro-cache@0.83.3", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.3" } }, "sha512-3jo65X515mQJvKqK3vWRblxDEcgY55Sk3w4xa6LlfEXgQ9g1WgMh9m4qVZVwgcHoLy0a2HENTPCCX4Pk6s8c8Q=="],
"@react-native/community-cli-plugin/metro-core/metro-resolver": ["metro-resolver@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-0js+zwI5flFxb1ktmR///bxHYg7OLpRpWZlBBruYG8OKYxeMP7SV0xQ/o/hUelrEMdK4LJzqVtHAhBm25LVfAQ=="],
"@react-navigation/bottom-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"@react-navigation/bottom-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
@@ -2360,6 +2375,22 @@
"@react-navigation/material-top-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
"ansi-fragments/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@2.0.0", "", {}, "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w=="],
@@ -2456,6 +2487,10 @@
"@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"@react-native/community-cli-plugin/metro/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="],
"@react-native/community-cli-plugin/metro/metro-transform-worker/metro-minify-terser": ["metro-minify-terser@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-O2BmfWj6FSfzBLrNCXt/rr2VYZdX5i6444QJU0fFoc7Ljg+Q+iqebwE3K0eTvkI6TRjELsXk1cjU+fXwAR4OjQ=="],
"@react-navigation/bottom-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"@react-navigation/bottom-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
@@ -2468,6 +2503,16 @@
"@react-navigation/material-top-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
@@ -2504,6 +2549,8 @@
"@istanbuljs/load-nyc-config/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"ansi-fragments/slice-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],

View File

@@ -69,27 +69,28 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
return (
<View
className={`
relative w-44 aspect-video rounded-lg overflow-hidden border border-neutral-800
relative w-44 aspect-video rounded-xl overflow-hidden border border-neutral-800
${size === "small" ? "w-32" : "w-44"}
`}
>
<View className='w-full h-full flex items-center justify-center'>
<Image
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit='cover'
className='w-full h-full'
/>
{showPlayButton && (
<View className='absolute inset-0 flex items-center justify-center'>
<Ionicons name='play-circle' size={40} color='white' />
</View>
)}
</View>
<Image
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
height: "100%",
width: "100%",
}}
/>
{showPlayButton && (
<View className='absolute inset-0 flex items-center justify-center'>
<Ionicons name='play-circle' size={40} color='white' />
</View>
)}
{!item.UserData?.Played && <WatchedIndicator item={item} />}
<ProgressBar item={item} />
</View>

View File

@@ -64,12 +64,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const { settings } = useSettings();
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
const { processes, startBackgroundDownload, getDownloadedItems } =
useDownload();
const downloadedFiles = useMemo(
() => getDownloadedItems(),
[getDownloadedItems],
);
const { processes, startBackgroundDownload, downloadedItems } = useDownload();
const downloadedFiles = downloadedItems;
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined

View File

@@ -22,9 +22,7 @@ export const Tag: React.FC<
> = ({ text, textClass, textStyle, ...props }) => {
return (
<View className='bg-neutral-800 rounded-full px-2 py-1' {...props}>
<Text className={textClass} style={textStyle}>
{text}
</Text>
<Text className='text-white'>{text}</Text>
</View>
);
};

View File

@@ -201,10 +201,10 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
}
>
<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 pt-2 mb-2 shrink'>
<ItemHeader item={item} className='mb-2' />
{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 mb-2'>
<BitrateSheet
className='mr-1'
onChange={(val) =>
@@ -287,21 +287,21 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
{item.Type !== "Program" && (
<>
{item.Type === "Episode" && !isOffline && (
<CurrentSeries item={item} className='mb-4' />
<CurrentSeries item={item} className='' />
)}
{!isOffline && (
<CastAndCrew item={item} className='mb-4' loading={loading} />
<CastAndCrew item={item} className='' loading={loading} />
)}
{item.People && item.People.length > 0 && !isOffline && (
<View className='mb-4'>
<View className=''>
{item.People.slice(0, 3).map((person, idx) => (
<MoreMoviesWithActor
currentItem={item}
key={idx}
actorId={person.Id!}
className='mb-4'
className=''
/>
))}
</View>

View File

@@ -29,7 +29,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
<View className='px-4 mt-2 mb-4'>
<Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text>
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
<View className='flex flex-row space-x-2'>
<View className='flex flex-row mb-2'>
<VideoStreamInfo source={source} />
</View>
<Text className='text-purple-600'>{t("item_card.more_details")}</Text>

View File

@@ -82,7 +82,7 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
<HorizontalScroll
data={items}
loading={isLoading}
height={247}
height={218}
renderItem={(item: BaseItemDto, idx: number) => (
<TouchableItemRouter
key={idx}

View File

@@ -377,7 +377,7 @@ export const PlayButton: React.FC<Props> = ({
onPress={onPress}
color={effectiveColors.primary}
>
<View className='flex flex-row items-center space-x-2 h-full w-full justify-center -mb-3.5 '>
<View className='flex flex-row items-center gap-x-2 h-full w-full justify-center -mb-3.5 '>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text>

View File

@@ -61,7 +61,7 @@ const PLAYED_STATUS_SKELETON_SIZE = 40;
const TEXT_SKELETON_HEIGHT = 20;
const TEXT_SKELETON_WIDTH = 250;
const OVERVIEW_SKELETON_HEIGHT = 16;
const OVERVIEW_SKELETON_WIDTH = 400;
const OVERVIEW_SKELETON_WIDTH = 300;
const _EMPTY_STATE_ICON_SIZE = 64;
// Spacing Constants

View File

@@ -26,7 +26,7 @@ export const HeaderBackButton: React.FC<Props> = ({
className='flex items-center justify-center w-9 h-9'
{...touchableOpacityProps}
>
<Ionicons name='arrow-back' size={24} color='white' />
<Ionicons name='chevron-back' size={24} color='white' />
</TouchableOpacity>
);
}

View File

@@ -3,17 +3,12 @@ import React, { useImperativeHandle, useRef } from "react";
import { View, type ViewStyle } from "react-native";
import { Text } from "./Text";
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
export interface HorizontalScrollRef {
scrollToIndex: (index: number, viewOffset: number) => void;
}
interface HorizontalScrollProps<T>
extends PartialExcept<
Omit<FlashListProps<T>, "renderItem">,
"estimatedItemSize"
> {
extends Omit<FlashListProps<T>, "renderItem" | "data"> {
data?: T[] | null;
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor?: (item: T, index: number) => string;
@@ -44,7 +39,7 @@ export const HorizontalScroll = <T,>(
...restProps
} = props;
const flashListRef = useRef<FlashList<T>>(null);
const flashListRef = useRef<React.ComponentRef<typeof FlashList<T>>>(null);
useImperativeHandle(ref!, () => ({
scrollToIndex: (index: number, viewOffset: number) => {
@@ -78,7 +73,6 @@ export const HorizontalScroll = <T,>(
extraData={extraData}
renderItem={renderFlashListItem}
horizontal
estimatedItemSize={200}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: 16,

View File

@@ -1,20 +1,29 @@
import { Platform, Text as RNText, type TextProps } from "react-native";
export function Text(props: TextProps) {
const { style, ...otherProps } = props;
interface CustomTextProps extends TextProps {
className?: string;
}
export function Text({ className, ...props }: CustomTextProps) {
if (Platform.isTV)
return (
<RNText
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}
/>
<RNText allowFontScaling={false} className={clsx(className)} {...props} />
);
return (
<RNText
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}
/>
<RNText allowFontScaling={false} className={clsx(className)} {...props} />
);
}
const clsx = (className?: string) => {
const colorClassRegex = /\btext-[a-z]+-\d+\b/;
const hasColorClass = className ? colorClassRegex.test(className) : false;
const defaultClassName = "text-white";
const classes = [
...(hasColorClass ? [] : [defaultClassName]),
...(className ? [className] : []),
]
.filter(Boolean)
.join(" ");
return classes;
};

View File

@@ -26,7 +26,7 @@ export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
<Text className='text-lg font-bold mb-2'>
{t("home.downloads.active_downloads")}
</Text>
<View className='space-y-2'>
<View className='gap-y-2'>
{processes?.map((p: JobStatus) => (
<DownloadCard key={p.item.Id} process={p} />
))}

View File

@@ -6,7 +6,6 @@ import { t } from "i18next";
import { useMemo } from "react";
import {
ActivityIndicator,
Platform,
TouchableOpacity,
type TouchableOpacityProps,
View,
@@ -14,10 +13,10 @@ import {
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";
import { JobStatus } from "@/providers/Downloads/types";
import { storage } from "@/utils/mmkv";
import { formatTimeString } from "@/utils/time";
import { Button } from "../Button";
const bytesToMB = (bytes: number) => {
return bytes / 1024 / 1024;
@@ -28,31 +27,10 @@ interface DownloadCardProps extends TouchableOpacityProps {
}
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { startDownload, pauseDownload, resumeDownload, removeProcess } =
useDownload();
const { removeProcess } = useDownload();
const router = useRouter();
const queryClient = useQueryClient();
const handlePause = async (id: string) => {
try {
await pauseDownload(id);
toast.success(t("home.downloads.toasts.download_paused"));
} catch (error) {
console.error("Error pausing download:", error);
toast.error(t("home.downloads.toasts.could_not_pause_download"));
}
};
const handleResume = async (id: string) => {
try {
await resumeDownload(id);
toast.success(t("home.downloads.toasts.download_resumed"));
} catch (error) {
console.error("Error resuming download:", error);
toast.error(t("home.downloads.toasts.could_not_resume_download"));
}
};
const handleDelete = async (id: string) => {
try {
await removeProcess(id);
@@ -64,16 +42,23 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
}
};
const eta = (p: JobStatus) => {
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
const eta = useMemo(() => {
if (!process.estimatedTotalSizeBytes || !process.bytesDownloaded) {
return null;
}
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
if (bytesRemaining <= 0) return null;
const secondsRemaining = calculateSmoothedETA(
process.id,
process.bytesDownloaded,
process.estimatedTotalSizeBytes,
);
const secondsRemaining = bytesRemaining / p.speed;
if (!secondsRemaining || secondsRemaining <= 0) {
return null;
}
return formatTimeString(secondsRemaining, "s");
};
}, [process.id, process.bytesDownloaded, process.estimatedTotalSizeBytes]);
const base64Image = useMemo(() => {
return storage.getString(process.item.Id!);
@@ -111,26 +96,10 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
)}
{/* Action buttons in bottom right corner */}
<View className='absolute bottom-2 right-2 flex flex-row items-center space-x-2 z-10'>
{process.status === "downloading" && Platform.OS !== "ios" && (
<TouchableOpacity
onPress={() => handlePause(process.id)}
className='p-1'
>
<Ionicons name='pause' size={20} color='white' />
</TouchableOpacity>
)}
{process.status === "paused" && Platform.OS !== "ios" && (
<TouchableOpacity
onPress={() => handleResume(process.id)}
className='p-1'
>
<Ionicons name='play' size={20} color='white' />
</TouchableOpacity>
)}
<View className='absolute bottom-2 right-2 flex flex-row items-center z-10'>
<TouchableOpacity
onPress={() => handleDelete(process.id)}
className='p-1'
className='p-2 bg-neutral-800 rounded-full'
>
<Ionicons name='close' size={20} color='red' />
</TouchableOpacity>
@@ -158,7 +127,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
<Text className='text-xs opacity-50'>
{process.item.ProductionYear}
</Text>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
<View className='flex flex-row items-center gap-x-2 mt-1 text-purple-600'>
{sanitizedProgress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
@@ -169,30 +138,18 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
{bytesToMB(process.speed).toFixed(2)} MB/s
</Text>
)}
{eta(process) && (
{eta && (
<Text className='text-xs'>
{t("home.downloads.eta", { eta: eta(process) })}
{t("home.downloads.eta", { eta: eta })}
</Text>
)}
</View>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
<View className='flex flex-row items-center gap-x-2 mt-1 text-purple-600'>
<Text className='text-xs capitalize'>{process.status}</Text>
</View>
</View>
</View>
{process.status === "completed" && (
<View className='flex flex-row mt-4 space-x-4'>
<Button
onPress={() => {
startDownload(process);
}}
className='w-full'
>
Download now
</Button>
</View>
)}
</View>
</TouchableOpacity>
);

View File

@@ -13,17 +13,13 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
items,
...props
}) => {
const { getDownloadedItemSize, getDownloadedItems } = useDownload();
const downloadedFiles = useMemo(
() => getDownloadedItems(),
[getDownloadedItems],
);
const { getDownloadedItemSize, downloadedItems } = useDownload();
const [size, setSize] = useState<string | undefined>();
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
useEffect(() => {
if (!downloadedFiles) return;
if (!downloadedItems) return;
let s = 0;
@@ -35,7 +31,7 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
}
}
setSize(s.bytesToReadable());
}, [itemIds]);
}, [itemIds, downloadedItems, getDownloadedItemSize]);
const sizeText = useMemo(() => {
if (!size) return "...";

View File

@@ -28,7 +28,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
*/
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id, "Episode");
deleteFile(item.Id);
successHapticFeedback();
}
}, [deleteFile, item.Id]);

View File

@@ -19,7 +19,13 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
return storage.getString(items[0].SeriesId!);
}, []);
const deleteSeries = useCallback(async () => deleteItems(items), [items]);
const deleteSeries = useCallback(
async () =>
deleteItems(
items.map((item) => item.Id).filter((id) => id !== undefined),
),
[items],
);
const showActionSheet = useCallback(() => {
const options = ["Delete", "Cancel"];

View File

@@ -72,7 +72,7 @@ export const HomeIndex = () => {
const scrollViewRef = useRef<ScrollView>(null);
const { getDownloadedItems, cleanCacheDirectory } = useDownload();
const { downloadedItems, cleanCacheDirectory } = useDownload();
const prevIsConnected = useRef<boolean | null>(false);
const {
isConnected,
@@ -92,8 +92,8 @@ export const HomeIndex = () => {
const hasDownloads = useMemo(() => {
if (Platform.isTV) return false;
return getDownloadedItems().length > 0;
}, [getDownloadedItems]);
return downloadedItems.length > 0;
}, [downloadedItems]);
useEffect(() => {
if (Platform.isTV) {
@@ -472,7 +472,7 @@ export const HomeIndex = () => {
paddingBottom: 16,
}}
>
<View className='flex flex-col space-y-4'>
<View className='flex flex-col gap-4'>
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (

View File

@@ -31,7 +31,7 @@ const Discover: React.FC<Props> = ({ sliders }) => {
if (!hasSliders) return null;
return (
<View className='flex flex-col space-y-4 mb-8'>
<View className='flex flex-col gap-y-4 mb-8'>
{sortedSliders.map((slide) => {
switch (slide.type) {
case DiscoverSliderType.RECENT_REQUESTS:

View File

@@ -27,18 +27,23 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
{title}
</Text>
<View
style={[]}
style={{
borderRadius: 12,
}}
className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900'
>
{Children.map(childrenArray, (child, index) => {
if (isValidElement<{ style?: ViewStyle }>(child)) {
const isLastItem = index === childrenArray.length - 1;
return cloneElement(child as any, {
style: StyleSheet.compose(
child.props.style,
index < childrenArray.length - 1
? styles.borderBottom
: undefined,
),
...(isLastItem
? {}
: {
style: StyleSheet.compose(
child.props.style,
styles.borderBottom,
),
}),
});
}
return child;

View File

@@ -107,7 +107,7 @@ const ListItemContent = ({
</Text>
{subtitle && (
<Text
className='text-[#9899A1] text-[12px] mt-0.5'
className='text-neutral-500 text-[11px] mt-0.5'
numberOfLines={2}
>
{subtitle}

View File

@@ -25,7 +25,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
</Text>
<HorizontalScroll
data={[item]}
height={247}
height={218}
renderItem={(item, _index) => (
<TouchableOpacity
key={item?.Id}
@@ -38,7 +38,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
id={item?.Id}
url={getPrimaryImageUrlById({ api, id: item?.ParentId })}
/>
<Text>{item?.SeriesName}</Text>
<Text className='mt-2'>{item?.SeriesName}</Text>
</TouchableOpacity>
)}
/>

View File

@@ -28,11 +28,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { getDownloadedItems } = useDownload();
const downloadedFiles = useMemo(
() => getDownloadedItems(),
[getDownloadedItems],
);
const { downloadedItems } = useDownload();
const scrollRef = useRef<HorizontalScrollRef>(null);
@@ -45,10 +41,10 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
}, [item]);
const { data: episodes, isPending } = useQuery({
queryKey: ["episodes", seasonId, isOffline],
queryKey: ["episodes", seasonId, isOffline, downloadedItems],
queryFn: async () => {
if (isOffline) {
return downloadedFiles
return downloadedItems
?.filter(
(f) => f.item.Type === "Episode" && f.item.SeasonId === seasonId,
)

View File

@@ -55,11 +55,8 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
}
}, []);
const { getDownloadedItems } = useDownload();
const downloadedFiles = useMemo(
() => getDownloadedItems(),
[getDownloadedItems],
);
const { downloadedItems } = useDownload();
const downloadedFiles = downloadedItems;
const seasonIndex = seasonIndexState[item.ParentId ?? ""];

5
global.css Normal file
View File

@@ -0,0 +1,5 @@
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/preflight.css" layer(base);
@import "tailwindcss/utilities.css";
@import "nativewind/theme";

View File

@@ -69,7 +69,7 @@ export const usePlaybackManager = ({
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { isConnected } = useNetworkStatus();
const { getDownloadedItemById, updateDownloadedItem, getDownloadedItems } =
const { getDownloadedItemById, updateDownloadedItem, downloadedItems } =
useDownload();
/** Whether the device is online. actually it's connected to the internet. */
@@ -77,14 +77,20 @@ export const usePlaybackManager = ({
// Adjacent episodes logic
const { data: adjacentItems } = useQuery({
queryKey: ["adjacentItems", item?.Id, item?.SeriesId, isOffline],
queryKey: [
"adjacentItems",
item?.Id,
item?.SeriesId,
isOffline,
downloadedItems,
],
queryFn: async (): Promise<BaseItemDto[] | null> => {
if (!item || !item.SeriesId) {
return null;
}
if (isOffline) {
return getOfflineAdjacentItems(item, getDownloadedItems() || []);
return getOfflineAdjacentItems(item, downloadedItems || []);
}
if (!api) {

View File

@@ -1,5 +1,6 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require("expo/metro-config");
const { withNativewind } = require("nativewind/metro");
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
@@ -25,4 +26,4 @@ if (process.env?.EXPO_TV === "1") {
// config.resolver.unstable_enablePackageExports = false;
module.exports = config;
module.exports = withNativewind(config, { input: "./global.css" });

View File

@@ -0,0 +1,258 @@
# Background Downloader Module
A native iOS and Android module for downloading large files in the background using `NSURLSession` (iOS) and `DownloadManager` (Android).
## Features
- **Background Downloads**: Downloads continue even when the app is backgrounded or suspended
- **Progress Tracking**: Real-time progress updates via events
- **Multiple Downloads**: Support for concurrent downloads
- **Cancellation**: Cancel individual or all downloads
- **Custom Destination**: Optionally specify custom file paths
- **Error Handling**: Comprehensive error reporting
- **Cross-Platform**: Works on both iOS and Android
## Usage
### Basic Example
```typescript
import { BackgroundDownloader } from '@/modules';
// Start a download
const taskId = await BackgroundDownloader.startDownload(
'https://example.com/largefile.mp4'
);
// Listen for progress updates
const progressSub = BackgroundDownloader.addProgressListener((event) => {
console.log(`Progress: ${Math.floor(event.progress * 100)}%`);
console.log(`Downloaded: ${event.bytesWritten} / ${event.totalBytes}`);
});
// Listen for completion
const completeSub = BackgroundDownloader.addCompleteListener((event) => {
console.log('Download complete!');
console.log('File saved to:', event.filePath);
console.log('Task ID:', event.taskId);
});
// Listen for errors
const errorSub = BackgroundDownloader.addErrorListener((event) => {
console.error('Download failed:', event.error);
});
// Cancel a download
BackgroundDownloader.cancelDownload(taskId);
// Get all active downloads
const activeDownloads = await BackgroundDownloader.getActiveDownloads();
// Cleanup listeners when done
progressSub.remove();
completeSub.remove();
errorSub.remove();
```
### Custom Destination Path
```typescript
import { BackgroundDownloader } from '@/modules';
import * as FileSystem from 'expo-file-system';
const destinationPath = `${FileSystem.documentDirectory}myfile.mp4`;
const taskId = await BackgroundDownloader.startDownload(
'https://example.com/video.mp4',
destinationPath
);
```
### Managing Multiple Downloads
```typescript
import { BackgroundDownloader } from '@/modules';
const downloads = new Map();
async function startMultipleDownloads(urls: string[]) {
for (const url of urls) {
const taskId = await BackgroundDownloader.startDownload(url);
downloads.set(taskId, { url, progress: 0 });
}
}
// Track progress for each download
const progressSub = BackgroundDownloader.addProgressListener((event) => {
const download = downloads.get(event.taskId);
if (download) {
download.progress = event.progress;
}
});
// Cancel all downloads
BackgroundDownloader.cancelAllDownloads();
```
## API Reference
### Methods
#### `startDownload(url: string, destinationPath?: string): Promise<number>`
Starts a new background download.
- **Parameters:**
- `url`: The URL of the file to download
- `destinationPath`: (Optional) Custom file path for the downloaded file
- **Returns:** Promise that resolves to the task ID (number)
#### `cancelDownload(taskId: number): void`
Cancels a specific download by task ID.
- **Parameters:**
- `taskId`: The task ID returned by `startDownload`
#### `cancelAllDownloads(): void`
Cancels all active downloads.
#### `getActiveDownloads(): Promise<ActiveDownload[]>`
Gets information about all active downloads.
- **Returns:** Promise that resolves to an array of active downloads
### Event Listeners
#### `addProgressListener(listener: (event: DownloadProgressEvent) => void): Subscription`
Listens for download progress updates.
- **Event payload:**
- `taskId`: number
- `bytesWritten`: number
- `totalBytes`: number
- `progress`: number (0.0 to 1.0)
#### `addCompleteListener(listener: (event: DownloadCompleteEvent) => void): Subscription`
Listens for download completion.
- **Event payload:**
- `taskId`: number
- `filePath`: string
- `url`: string
#### `addErrorListener(listener: (event: DownloadErrorEvent) => void): Subscription`
Listens for download errors.
- **Event payload:**
- `taskId`: number
- `error`: string
#### `addStartedListener(listener: (event: DownloadStartedEvent) => void): Subscription`
Listens for download start confirmation.
- **Event payload:**
- `taskId`: number
- `url`: string
## Types
```typescript
interface DownloadProgressEvent {
taskId: number;
bytesWritten: number;
totalBytes: number;
progress: number;
}
interface DownloadCompleteEvent {
taskId: number;
filePath: string;
url: string;
}
interface DownloadErrorEvent {
taskId: number;
error: string;
}
interface DownloadStartedEvent {
taskId: number;
url: string;
}
interface ActiveDownload {
taskId: number;
url: string;
state: 'running' | 'suspended' | 'canceling' | 'completed' | 'unknown';
}
```
## Implementation Details
### iOS Background Downloads
- Uses `NSURLSession` with background configuration
- Session identifier: `com.fredrikburmester.streamyfin.backgrounddownloader`
- Downloads continue when app is backgrounded or suspended
- System may terminate downloads if app is force-quit
### Android Background Downloads
- Uses Android's `DownloadManager` API
- Downloads are managed by the system and continue in the background
- Shows download notification in the notification tray
- Downloads continue even if the app is closed
- Requires `INTERNET` permission (automatically added by Expo)
### Background Modes
The app's `Info.plist` already includes the required background mode for iOS:
- `UIBackgroundModes`: `["audio", "fetch"]`
### File Storage
**iOS:** By default, downloaded files are saved to the app's Documents directory.
**Android:** By default, files are saved to the app's external files directory (accessible via `FileSystem.documentDirectory` in Expo).
You can specify a custom path using the `destinationPath` parameter on both platforms.
## Building
After adding this module, rebuild the app:
```bash
# iOS
npx expo prebuild -p ios
npx expo run:ios
# Android
npx expo prebuild -p android
npx expo run:android
```
Or install manually:
```bash
# iOS
cd ios
pod install
cd ..
# Android - prebuild handles everything
npx expo prebuild -p android
```
## Notes
- Background downloads may be cancelled if the user force-quits the app (iOS)
- The OS manages download priority and may pause downloads to save battery
- Android shows a system notification for ongoing downloads
- Downloads over cellular are allowed by default on both platforms

View File

@@ -0,0 +1,46 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
}
group = 'expo.modules.backgrounddownloader'
version = '1.0.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useDefaultAndroidSdkVersions()
useCoreDependencies()
useExpoPublishing()
android {
namespace "expo.modules.backgrounddownloader"
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
lintOptions {
abortOnError false
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
kotlinOptions {
freeCompilerArgs += ["-Xshow-kotlin-compiler-errors"]
jvmTarget = "17"
}
}

View File

@@ -0,0 +1,3 @@
<manifest>
</manifest>

View File

@@ -0,0 +1,459 @@
package expo.modules.backgrounddownloader
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
import expo.modules.kotlin.Promise
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.io.File
class BackgroundDownloaderModule : Module() {
companion object {
private const val TAG = "BackgroundDownloader"
}
private val context
get() = requireNotNull(appContext.reactContext)
private val downloadManager: DownloadManager by lazy {
context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
}
private val downloadTasks = mutableMapOf<Long, DownloadTaskInfo>()
private val progressHandler = Handler(Looper.getMainLooper())
private val progressRunnables = mutableMapOf<Long, Runnable>()
private val downloadCompleteReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val downloadId = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) ?: -1
Log.d(TAG, "Broadcast received for downloadId: $downloadId, action: ${intent?.action}")
if (downloadId != -1L && downloadTasks.containsKey(downloadId)) {
Log.d(TAG, "Calling handleDownloadComplete for task: $downloadId")
handleDownloadComplete(downloadId)
} else if (downloadId != -1L) {
Log.w(TAG, "Received broadcast for unknown downloadId: $downloadId (not in our task map)")
} else {
Log.w(TAG, "Received broadcast with invalid downloadId: $downloadId")
}
}
}
override fun definition() = ModuleDefinition {
Name("BackgroundDownloader")
Events(
"onDownloadProgress",
"onDownloadComplete",
"onDownloadError",
"onDownloadStarted"
)
OnCreate {
registerDownloadReceiver()
}
OnDestroy {
unregisterDownloadReceiver()
progressRunnables.values.forEach { progressHandler.removeCallbacks(it) }
progressRunnables.clear()
}
AsyncFunction("startDownload") { urlString: String, destinationPath: String?, promise: Promise ->
try {
val uri = Uri.parse(urlString)
val request = DownloadManager.Request(uri).apply {
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
setAllowedNetworkTypes(
DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE
)
setAllowedOverMetered(true)
setAllowedOverRoaming(true)
if (destinationPath != null) {
val file = File(destinationPath)
val fileName = file.name
// Check if destination is in internal storage (starts with /data/data/ or /data/user/)
// DownloadManager can't write to internal storage directly
val isInternalPath = destinationPath.startsWith("/data/data/") ||
destinationPath.startsWith("/data/user/")
if (isInternalPath) {
// Download to external files dir, we'll move it later
setDestinationInExternalFilesDir(
context,
null,
fileName
)
} else {
// External path - create directory and set destination
val directory = file.parentFile
if (directory != null && !directory.exists()) {
directory.mkdirs()
}
setDestinationUri(Uri.fromFile(file))
}
} else {
val fileName = uri.lastPathSegment ?: "download_${System.currentTimeMillis()}"
setDestinationInExternalFilesDir(
context,
null,
fileName
)
}
}
val downloadId = downloadManager.enqueue(request)
downloadTasks[downloadId] = DownloadTaskInfo(
url = urlString,
destinationPath = destinationPath
)
startProgressTracking(downloadId)
sendEvent("onDownloadStarted", mapOf(
"taskId" to downloadId.toInt(),
"url" to urlString
))
promise.resolve(downloadId.toInt())
} catch (e: Exception) {
promise.reject("DOWNLOAD_ERROR", "Failed to start download: ${e.message}", e)
}
}
Function("cancelDownload") { taskId: Int ->
val downloadId = taskId.toLong()
if (downloadTasks.containsKey(downloadId)) {
downloadManager.remove(downloadId)
stopProgressTracking(downloadId)
downloadTasks.remove(downloadId)
}
}
Function("cancelAllDownloads") {
val downloadIds = downloadTasks.keys.toList()
downloadIds.forEach { downloadId ->
downloadManager.remove(downloadId)
stopProgressTracking(downloadId)
}
downloadTasks.clear()
}
AsyncFunction("getActiveDownloads") { promise: Promise ->
try {
val activeDownloads = mutableListOf<Map<String, Any>>()
downloadTasks.forEach { (downloadId, taskInfo) ->
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = downloadManager.query(query)
if (cursor.moveToFirst()) {
val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
val status = if (statusIndex >= 0) cursor.getInt(statusIndex) else -1
activeDownloads.add(mapOf(
"taskId" to downloadId.toInt(),
"url" to taskInfo.url,
"state" to getStateString(status)
))
}
cursor.close()
}
promise.resolve(activeDownloads)
} catch (e: Exception) {
promise.reject("GET_DOWNLOADS_ERROR", "Failed to get active downloads: ${e.message}", e)
}
}
}
private fun registerDownloadReceiver() {
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ContextCompat.registerReceiver(
context,
downloadCompleteReceiver,
filter,
ContextCompat.RECEIVER_NOT_EXPORTED
)
} else {
context.registerReceiver(downloadCompleteReceiver, filter)
}
}
private fun unregisterDownloadReceiver() {
try {
context.unregisterReceiver(downloadCompleteReceiver)
} catch (e: IllegalArgumentException) {
// Receiver not registered, ignore
}
}
private fun startProgressTracking(downloadId: Long) {
val runnable = object : Runnable {
override fun run() {
if (!downloadTasks.containsKey(downloadId)) {
Log.d(TAG, "Task $downloadId no longer in map, stopping progress tracking")
return
}
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = downloadManager.query(query)
if (cursor.moveToFirst()) {
val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
val status = if (statusIndex >= 0) cursor.getInt(statusIndex) else -1
val bytesDownloadedIndex = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
val bytesDownloaded = if (bytesDownloadedIndex >= 0) cursor.getLong(bytesDownloadedIndex) else 0L
val totalBytesIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
val totalBytes = if (totalBytesIndex >= 0) cursor.getLong(totalBytesIndex) else 0L
val statusString = when (status) {
DownloadManager.STATUS_RUNNING -> "RUNNING"
DownloadManager.STATUS_PAUSED -> "PAUSED"
DownloadManager.STATUS_PENDING -> "PENDING"
DownloadManager.STATUS_SUCCESSFUL -> "SUCCESSFUL"
DownloadManager.STATUS_FAILED -> "FAILED"
else -> "UNKNOWN($status)"
}
// Log status periodically for debugging
val progress = if (totalBytes > 0) (bytesDownloaded.toDouble() / totalBytes.toDouble() * 100).toInt() else 0
if (progress % 10 == 0 || status != DownloadManager.STATUS_RUNNING) {
Log.d(TAG, "Task $downloadId: status=$statusString, progress=$progress%, bytes=$bytesDownloaded/$totalBytes")
}
if (status == DownloadManager.STATUS_RUNNING && totalBytes > 0) {
val progressRatio = bytesDownloaded.toDouble() / totalBytes.toDouble()
sendEvent("onDownloadProgress", mapOf(
"taskId" to downloadId.toInt(),
"bytesWritten" to bytesDownloaded,
"totalBytes" to totalBytes,
"progress" to progressRatio
))
}
// Check if download completed but broadcast was missed
if (status == DownloadManager.STATUS_SUCCESSFUL) {
Log.w(TAG, "Task $downloadId: Download is SUCCESSFUL but completion handler wasn't called! Calling manually.")
cursor.close()
stopProgressTracking(downloadId)
handleDownloadComplete(downloadId)
return
}
// Check for errors
if (status == DownloadManager.STATUS_FAILED) {
val reasonIndex = cursor.getColumnIndex(DownloadManager.COLUMN_REASON)
val reason = if (reasonIndex >= 0) cursor.getInt(reasonIndex) else -1
Log.e(TAG, "Task $downloadId: Download FAILED with reason code: $reason")
cursor.close()
stopProgressTracking(downloadId)
sendEvent("onDownloadError", mapOf(
"taskId" to downloadId.toInt(),
"error" to getErrorString(reason)
))
downloadTasks.remove(downloadId)
return
}
// Check if download is paused or pending for too long
if (status == DownloadManager.STATUS_PAUSED || status == DownloadManager.STATUS_PENDING) {
Log.w(TAG, "Task $downloadId: Download is $statusString")
}
} else {
Log.e(TAG, "Task $downloadId: No cursor data found in DownloadManager")
}
cursor.close()
// Continue tracking if still in progress
if (downloadTasks.containsKey(downloadId)) {
progressHandler.postDelayed(this, 500)
}
}
}
progressRunnables[downloadId] = runnable
progressHandler.post(runnable)
}
private fun stopProgressTracking(downloadId: Long) {
progressRunnables[downloadId]?.let { runnable ->
progressHandler.removeCallbacks(runnable)
progressRunnables.remove(downloadId)
}
}
private fun handleDownloadComplete(downloadId: Long) {
stopProgressTracking(downloadId)
val taskInfo = downloadTasks[downloadId]
if (taskInfo == null) {
return
}
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = downloadManager.query(query)
if (cursor.moveToFirst()) {
val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
val status = if (statusIndex >= 0) cursor.getInt(statusIndex) else -1
if (status == DownloadManager.STATUS_SUCCESSFUL) {
val uriIndex = cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
val localUri = if (uriIndex >= 0) cursor.getString(uriIndex) else null
if (localUri != null) {
val downloadedFilePath = Uri.parse(localUri).path ?: localUri
// If we have a custom destination path for internal storage, move the file in background
if (taskInfo.destinationPath != null) {
val isInternalPath = taskInfo.destinationPath.startsWith("/data/data/") ||
taskInfo.destinationPath.startsWith("/data/user/")
if (isInternalPath) {
Log.d(TAG, "Starting file move in background thread for taskId: ${downloadId.toInt()}")
// Move file in background thread to avoid blocking
Thread {
try {
val sourceFile = File(downloadedFilePath)
val destFile = File(taskInfo.destinationPath)
Log.d(TAG, "Moving file from $downloadedFilePath to ${taskInfo.destinationPath}")
// Create destination directory if needed
val destDir = destFile.parentFile
if (destDir != null && !destDir.exists()) {
destDir.mkdirs()
}
// Try to move file (fast if on same filesystem)
val moveSuccessful = sourceFile.renameTo(destFile)
if (moveSuccessful) {
Log.d(TAG, "File moved successfully via rename")
sendEvent("onDownloadComplete", mapOf(
"taskId" to downloadId.toInt(),
"filePath" to taskInfo.destinationPath,
"url" to taskInfo.url
))
} else {
// Rename failed (likely different filesystems), need to copy
Log.d(TAG, "Rename failed, copying file (this may take a while for large files)")
sourceFile.inputStream().use { input ->
destFile.outputStream().use { output ->
input.copyTo(output)
}
}
// Delete source file after successful copy
if (sourceFile.delete()) {
Log.d(TAG, "File copied and source deleted successfully")
} else {
Log.w(TAG, "File copied but failed to delete source file")
}
sendEvent("onDownloadComplete", mapOf(
"taskId" to downloadId.toInt(),
"filePath" to taskInfo.destinationPath,
"url" to taskInfo.url
))
}
} catch (e: Exception) {
Log.e(TAG, "Failed to move file to internal storage: ${e.message}", e)
sendEvent("onDownloadError", mapOf(
"taskId" to downloadId.toInt(),
"error" to "Failed to move file to destination: ${e.message}"
))
}
}.start()
cursor.close()
downloadTasks.remove(downloadId)
return
}
}
// No internal path or external path - send completion immediately
sendEvent("onDownloadComplete", mapOf(
"taskId" to downloadId.toInt(),
"filePath" to downloadedFilePath,
"url" to taskInfo.url
))
} else {
sendEvent("onDownloadError", mapOf(
"taskId" to downloadId.toInt(),
"error" to "Could not retrieve downloaded file path"
))
}
} else if (status == DownloadManager.STATUS_FAILED) {
val reasonIndex = cursor.getColumnIndex(DownloadManager.COLUMN_REASON)
val reason = if (reasonIndex >= 0) cursor.getInt(reasonIndex) else -1
sendEvent("onDownloadError", mapOf(
"taskId" to downloadId.toInt(),
"error" to getErrorString(reason)
))
}
}
cursor.close()
downloadTasks.remove(downloadId)
}
private fun getStateString(status: Int): String {
return when (status) {
DownloadManager.STATUS_RUNNING -> "running"
DownloadManager.STATUS_PAUSED -> "suspended"
DownloadManager.STATUS_PENDING -> "suspended"
DownloadManager.STATUS_SUCCESSFUL -> "completed"
DownloadManager.STATUS_FAILED -> "completed"
else -> "unknown"
}
}
private fun getErrorString(reason: Int): String {
return when (reason) {
DownloadManager.ERROR_CANNOT_RESUME -> "Cannot resume download"
DownloadManager.ERROR_DEVICE_NOT_FOUND -> "No external storage device found"
DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "File already exists"
DownloadManager.ERROR_FILE_ERROR -> "Storage error"
DownloadManager.ERROR_HTTP_DATA_ERROR -> "HTTP data error"
DownloadManager.ERROR_INSUFFICIENT_SPACE -> "Insufficient storage space"
DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "Too many redirects"
DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "Unhandled HTTP response code"
DownloadManager.ERROR_UNKNOWN -> "Unknown error"
else -> "Download failed (code: $reason)"
}
}
}
data class DownloadTaskInfo(
val url: String,
val destinationPath: String?
)

View File

@@ -0,0 +1,98 @@
import type {
DownloadCompleteEvent,
DownloadErrorEvent,
DownloadProgressEvent,
} from "@/modules";
import { BackgroundDownloader } from "@/modules";
export class DownloadManager {
private progressSubscription: any;
private completeSubscription: any;
private errorSubscription: any;
private activeDownloads = new Map<
number,
{ url: string; progress: number }
>();
constructor() {
this.setupListeners();
}
private setupListeners() {
this.progressSubscription = BackgroundDownloader.addProgressListener(
(event: DownloadProgressEvent) => {
const download = this.activeDownloads.get(event.taskId);
if (download) {
download.progress = event.progress;
console.log(
`Download ${event.taskId}: ${Math.floor(event.progress * 100)}%`,
);
}
},
);
this.completeSubscription = BackgroundDownloader.addCompleteListener(
(event: DownloadCompleteEvent) => {
console.log("Download complete:", event.filePath);
this.activeDownloads.delete(event.taskId);
},
);
this.errorSubscription = BackgroundDownloader.addErrorListener(
(event: DownloadErrorEvent) => {
console.error("Download error:", event.error);
this.activeDownloads.delete(event.taskId);
},
);
}
async startDownload(url: string, destinationPath?: string): Promise<number> {
const taskId = await BackgroundDownloader.startDownload(
url,
destinationPath,
);
this.activeDownloads.set(taskId, { url, progress: 0 });
return taskId;
}
cancelDownload(taskId: number): void {
BackgroundDownloader.cancelDownload(taskId);
this.activeDownloads.delete(taskId);
}
cancelAllDownloads(): void {
BackgroundDownloader.cancelAllDownloads();
this.activeDownloads.clear();
}
async getActiveDownloads() {
return await BackgroundDownloader.getActiveDownloads();
}
cleanup(): void {
this.progressSubscription?.remove();
this.completeSubscription?.remove();
this.errorSubscription?.remove();
}
}
const downloadManager = new DownloadManager();
export async function downloadFile(
url: string,
destinationPath?: string,
): Promise<number> {
return await downloadManager.startDownload(url, destinationPath);
}
export function cancelDownload(taskId: number): void {
downloadManager.cancelDownload(taskId);
}
export function cancelAllDownloads(): void {
downloadManager.cancelAllDownloads();
}
export async function getActiveDownloads() {
return await downloadManager.getActiveDownloads();
}

View File

@@ -0,0 +1,12 @@
{
"name": "background-downloader",
"version": "1.0.0",
"platforms": ["ios", "android"],
"ios": {
"modules": ["BackgroundDownloaderModule"],
"appDelegateSubscribers": ["BackgroundDownloaderAppDelegate"]
},
"android": {
"modules": ["expo.modules.backgrounddownloader.BackgroundDownloaderModule"]
}
}

View File

@@ -0,0 +1,91 @@
import type { Subscription } from "expo-modules-core";
import type {
ActiveDownload,
DownloadCompleteEvent,
DownloadErrorEvent,
DownloadProgressEvent,
DownloadStartedEvent,
} from "./src/BackgroundDownloader.types";
import BackgroundDownloaderModule from "./src/BackgroundDownloaderModule";
export interface BackgroundDownloader {
startDownload(url: string, destinationPath?: string): Promise<number>;
cancelDownload(taskId: number): void;
cancelAllDownloads(): void;
getActiveDownloads(): Promise<ActiveDownload[]>;
addProgressListener(
listener: (event: DownloadProgressEvent) => void,
): Subscription;
addCompleteListener(
listener: (event: DownloadCompleteEvent) => void,
): Subscription;
addErrorListener(listener: (event: DownloadErrorEvent) => void): Subscription;
addStartedListener(
listener: (event: DownloadStartedEvent) => void,
): Subscription;
}
const BackgroundDownloader: BackgroundDownloader = {
async startDownload(url: string, destinationPath?: string): Promise<number> {
return await BackgroundDownloaderModule.startDownload(url, destinationPath);
},
cancelDownload(taskId: number): void {
BackgroundDownloaderModule.cancelDownload(taskId);
},
cancelAllDownloads(): void {
BackgroundDownloaderModule.cancelAllDownloads();
},
async getActiveDownloads(): Promise<ActiveDownload[]> {
return await BackgroundDownloaderModule.getActiveDownloads();
},
addProgressListener(
listener: (event: DownloadProgressEvent) => void,
): Subscription {
return BackgroundDownloaderModule.addListener(
"onDownloadProgress",
listener,
);
},
addCompleteListener(
listener: (event: DownloadCompleteEvent) => void,
): Subscription {
return BackgroundDownloaderModule.addListener(
"onDownloadComplete",
listener,
);
},
addErrorListener(
listener: (event: DownloadErrorEvent) => void,
): Subscription {
return BackgroundDownloaderModule.addListener("onDownloadError", listener);
},
addStartedListener(
listener: (event: DownloadStartedEvent) => void,
): Subscription {
return BackgroundDownloaderModule.addListener(
"onDownloadStarted",
listener,
);
},
};
export default BackgroundDownloader;
export type {
DownloadProgressEvent,
DownloadCompleteEvent,
DownloadErrorEvent,
DownloadStartedEvent,
ActiveDownload,
};

View File

@@ -0,0 +1,21 @@
Pod::Spec.new do |s|
s.name = 'BackgroundDownloader'
s.version = '1.0.0'
s.summary = 'Background file downloader for iOS'
s.description = 'Native iOS module for downloading large files in the background using NSURLSession'
s.author = ''
s.homepage = 'https://docs.expo.dev/modules/'
s.platforms = { :ios => '15.6', :tvos => '15.0' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end

View File

@@ -0,0 +1,15 @@
import ExpoModulesCore
import UIKit
public class BackgroundDownloaderAppDelegate: ExpoAppDelegateSubscriber {
public func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
if identifier == "com.fredrikburmester.streamyfin.backgrounddownloader" {
BackgroundDownloaderModule.setBackgroundCompletionHandler(completionHandler)
}
}
}

View File

@@ -0,0 +1,394 @@
import ExpoModulesCore
import Foundation
enum DownloadError: Error {
case invalidURL
case fileOperationFailed
case downloadFailed
}
struct DownloadTaskInfo {
let url: String
let destinationPath: String?
}
// Separate delegate class to handle URLSession callbacks
class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate {
weak var module: BackgroundDownloaderModule?
init(module: BackgroundDownloaderModule) {
self.module = module
super.init()
print("[DownloadSessionDelegate] Delegate initialized with module: \(String(describing: module))")
}
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64
) {
let progress = totalBytesExpectedToWrite > 0 ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0.0
print("[BackgroundDownloader] Progress callback: taskId=\(downloadTask.taskIdentifier), written=\(totalBytesWritten), total=\(totalBytesExpectedToWrite), progress=\(progress)")
module?.handleProgress(
taskId: downloadTask.taskIdentifier,
bytesWritten: totalBytesWritten,
totalBytes: totalBytesExpectedToWrite
)
}
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {
print("[BackgroundDownloader] Download finished callback: taskId=\(downloadTask.taskIdentifier)")
module?.handleDownloadComplete(
taskId: downloadTask.taskIdentifier,
location: location,
downloadTask: downloadTask
)
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?
) {
print("[BackgroundDownloader] Task completed: taskId=\(task.taskIdentifier), error=\(String(describing: error))")
if let httpResponse = task.response as? HTTPURLResponse {
print("[BackgroundDownloader] HTTP Status: \(httpResponse.statusCode)")
print("[BackgroundDownloader] Content-Length: \(httpResponse.expectedContentLength)")
}
if let error = error {
print("[BackgroundDownloader] Task error: \(error.localizedDescription)")
module?.handleError(taskId: task.taskIdentifier, error: error)
}
}
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
DispatchQueue.main.async {
if let completion = BackgroundDownloaderModule.backgroundCompletionHandler {
completion()
BackgroundDownloaderModule.backgroundCompletionHandler = nil
}
}
}
}
public class BackgroundDownloaderModule: Module {
private var session: URLSession?
private var sessionDelegate: DownloadSessionDelegate?
fileprivate static var backgroundCompletionHandler: (() -> Void)?
private var downloadTasks: [Int: DownloadTaskInfo] = [:]
public func definition() -> ModuleDefinition {
Name("BackgroundDownloader")
Events(
"onDownloadProgress",
"onDownloadComplete",
"onDownloadError",
"onDownloadStarted"
)
OnCreate {
self.initializeSession()
}
AsyncFunction("startDownload") { (urlString: String, destinationPath: String?) -> Int in
guard let url = URL(string: urlString) else {
throw DownloadError.invalidURL
}
if self.session == nil {
self.initializeSession()
}
guard let session = self.session else {
throw DownloadError.downloadFailed
}
// Create a URLRequest to ensure proper handling
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.timeoutInterval = 300
let task = session.downloadTask(with: request)
let taskId = task.taskIdentifier
print("[BackgroundDownloader] Starting download: taskId=\(taskId), url=\(urlString)")
print("[BackgroundDownloader] Destination: \(destinationPath ?? "default")")
self.downloadTasks[taskId] = DownloadTaskInfo(
url: urlString,
destinationPath: destinationPath
)
task.resume()
print("[BackgroundDownloader] Task resumed with state: \(self.taskStateString(task.state))")
print("[BackgroundDownloader] Sending started event")
self.sendEvent("onDownloadStarted", [
"taskId": taskId,
"url": urlString
])
// Check task state after a brief delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
session.getAllTasks { tasks in
if let downloadTask = tasks.first(where: { $0.taskIdentifier == taskId }) {
print("[BackgroundDownloader] === 0.5s CHECK ===")
print("[BackgroundDownloader] Task state: \(self.taskStateString(downloadTask.state))")
if let response = downloadTask.response as? HTTPURLResponse {
print("[BackgroundDownloader] Response status: \(response.statusCode)")
print("[BackgroundDownloader] Expected content length: \(response.expectedContentLength)")
} else {
print("[BackgroundDownloader] No HTTP response yet after 0.5s")
}
} else {
print("[BackgroundDownloader] Task not found after 0.5s")
}
}
}
// Additional diagnostics at 1s, 2s, and 3s
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
session.getAllTasks { tasks in
if let downloadTask = tasks.first(where: { $0.taskIdentifier == taskId }) {
print("[BackgroundDownloader] === 1s CHECK ===")
print("[BackgroundDownloader] Task state: \(self.taskStateString(downloadTask.state))")
print("[BackgroundDownloader] Task error: \(String(describing: downloadTask.error))")
print("[BackgroundDownloader] Current request URL: \(downloadTask.currentRequest?.url?.absoluteString ?? "nil")")
print("[BackgroundDownloader] Original request URL: \(downloadTask.originalRequest?.url?.absoluteString ?? "nil")")
if let response = downloadTask.response as? HTTPURLResponse {
print("[BackgroundDownloader] HTTP Status: \(response.statusCode)")
print("[BackgroundDownloader] Expected content length: \(response.expectedContentLength)")
print("[BackgroundDownloader] All headers: \(response.allHeaderFields)")
} else {
print("[BackgroundDownloader] ⚠️ STILL NO HTTP RESPONSE after 1s")
}
let countOfBytesReceived = downloadTask.countOfBytesReceived
if countOfBytesReceived > 0 {
print("[BackgroundDownloader] Bytes received: \(countOfBytesReceived)")
} else {
print("[BackgroundDownloader] ⚠️ NO BYTES RECEIVED YET")
}
} else {
print("[BackgroundDownloader] ⚠️ Task disappeared after 1s")
}
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
session.getAllTasks { tasks in
if let downloadTask = tasks.first(where: { $0.taskIdentifier == taskId }) {
print("[BackgroundDownloader] === 2s CHECK ===")
print("[BackgroundDownloader] Task state: \(self.taskStateString(downloadTask.state))")
let countOfBytesReceived = downloadTask.countOfBytesReceived
print("[BackgroundDownloader] Bytes received: \(countOfBytesReceived)")
if downloadTask.error != nil {
print("[BackgroundDownloader] ⚠️ Task has error: \(String(describing: downloadTask.error))")
}
}
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
session.getAllTasks { tasks in
if let downloadTask = tasks.first(where: { $0.taskIdentifier == taskId }) {
print("[BackgroundDownloader] === 3s CHECK ===")
print("[BackgroundDownloader] Task state: \(self.taskStateString(downloadTask.state))")
let countOfBytesReceived = downloadTask.countOfBytesReceived
print("[BackgroundDownloader] Bytes received: \(countOfBytesReceived)")
if downloadTask.error != nil {
print("[BackgroundDownloader] ⚠️ Task has error: \(String(describing: downloadTask.error))")
}
}
}
}
return taskId
}
Function("cancelDownload") { (taskId: Int) in
self.session?.getAllTasks { tasks in
for task in tasks where task.taskIdentifier == taskId {
task.cancel()
self.downloadTasks.removeValue(forKey: taskId)
}
}
}
Function("cancelAllDownloads") {
self.session?.getAllTasks { tasks in
for task in tasks {
task.cancel()
}
self.downloadTasks.removeAll()
}
}
AsyncFunction("getActiveDownloads") { () -> [[String: Any]] in
return try await withCheckedThrowingContinuation { continuation in
let downloadTasks = self.downloadTasks
let taskStateString = self.taskStateString
self.session?.getAllTasks { tasks in
let activeDownloads = tasks.compactMap { task -> [String: Any]? in
guard task is URLSessionDownloadTask,
let info = downloadTasks[task.taskIdentifier] else {
return nil
}
return [
"taskId": task.taskIdentifier,
"url": info.url,
"state": taskStateString(task.state)
]
}
continuation.resume(returning: activeDownloads)
}
}
}
}
private func initializeSession() {
print("[BackgroundDownloader] Initializing URLSession")
let config = URLSessionConfiguration.background(
withIdentifier: "com.fredrikburmester.streamyfin.backgrounddownloader"
)
config.allowsCellularAccess = true
config.sessionSendsLaunchEvents = true
config.isDiscretionary = false
self.sessionDelegate = DownloadSessionDelegate(module: self)
self.session = URLSession(
configuration: config,
delegate: self.sessionDelegate,
delegateQueue: nil
)
print("[BackgroundDownloader] URLSession initialized with delegate: \(String(describing: self.sessionDelegate))")
print("[BackgroundDownloader] Session identifier: \(config.identifier ?? "nil")")
print("[BackgroundDownloader] Delegate queue: nil (uses default)")
// Verify delegate is connected
if let session = self.session, session.delegate != nil {
print("[BackgroundDownloader] ✅ Delegate successfully attached to session")
} else {
print("[BackgroundDownloader] ⚠️ DELEGATE NOT ATTACHED!")
}
}
private func taskStateString(_ state: URLSessionTask.State) -> String {
switch state {
case .running:
return "running"
case .suspended:
return "suspended"
case .canceling:
return "canceling"
case .completed:
return "completed"
@unknown default:
return "unknown"
}
}
// Handler methods called by the delegate
func handleProgress(taskId: Int, bytesWritten: Int64, totalBytes: Int64) {
let progress = totalBytes > 0
? Double(bytesWritten) / Double(totalBytes)
: 0.0
print("[BackgroundDownloader] Sending progress event: taskId=\(taskId), progress=\(progress)")
self.sendEvent("onDownloadProgress", [
"taskId": taskId,
"bytesWritten": bytesWritten,
"totalBytes": totalBytes,
"progress": progress
])
}
func handleDownloadComplete(taskId: Int, location: URL, downloadTask: URLSessionDownloadTask) {
guard let taskInfo = downloadTasks[taskId] else {
self.sendEvent("onDownloadError", [
"taskId": taskId,
"error": "Download task info not found"
])
return
}
let fileManager = FileManager.default
do {
let destinationURL: URL
if let customPath = taskInfo.destinationPath {
destinationURL = URL(fileURLWithPath: customPath)
} else {
let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
let filename = downloadTask.response?.suggestedFilename
?? downloadTask.originalRequest?.url?.lastPathComponent
?? "download_\(taskId)"
destinationURL = documentsDir.appendingPathComponent(filename)
}
if fileManager.fileExists(atPath: destinationURL.path) {
try fileManager.removeItem(at: destinationURL)
}
let destinationDirectory = destinationURL.deletingLastPathComponent()
if !fileManager.fileExists(atPath: destinationDirectory.path) {
try fileManager.createDirectory(
at: destinationDirectory,
withIntermediateDirectories: true,
attributes: nil
)
}
try fileManager.moveItem(at: location, to: destinationURL)
self.sendEvent("onDownloadComplete", [
"taskId": taskId,
"filePath": destinationURL.path,
"url": taskInfo.url
])
downloadTasks.removeValue(forKey: taskId)
} catch {
self.sendEvent("onDownloadError", [
"taskId": taskId,
"error": "File operation failed: \(error.localizedDescription)"
])
}
}
func handleError(taskId: Int, error: Error) {
let isCancelled = (error as NSError).code == NSURLErrorCancelled
if !isCancelled {
self.sendEvent("onDownloadError", [
"taskId": taskId,
"error": error.localizedDescription
])
}
downloadTasks.removeValue(forKey: taskId)
}
static func setBackgroundCompletionHandler(_ handler: @escaping () -> Void) {
BackgroundDownloaderModule.backgroundCompletionHandler = handler
}
}

View File

@@ -0,0 +1,35 @@
export interface DownloadProgressEvent {
taskId: number;
bytesWritten: number;
totalBytes: number;
progress: number;
}
export interface DownloadCompleteEvent {
taskId: number;
filePath: string;
url: string;
}
export interface DownloadErrorEvent {
taskId: number;
error: string;
}
export interface DownloadStartedEvent {
taskId: number;
url: string;
}
export interface ActiveDownload {
taskId: number;
url: string;
state: "running" | "suspended" | "canceling" | "completed" | "unknown";
}
export interface BackgroundDownloaderModuleType {
startDownload(url: string, destinationPath?: string): Promise<number>;
cancelDownload(taskId: number): void;
cancelAllDownloads(): void;
getActiveDownloads(): Promise<ActiveDownload[]>;
}

View File

@@ -0,0 +1,7 @@
import { requireNativeModule } from "expo-modules-core";
import type { BackgroundDownloaderModuleType } from "./BackgroundDownloader.types";
const BackgroundDownloaderModule: BackgroundDownloaderModuleType =
requireNativeModule("BackgroundDownloader");
export default BackgroundDownloaderModule;

View File

@@ -1,3 +1,11 @@
import type {
ActiveDownload,
DownloadCompleteEvent,
DownloadErrorEvent,
DownloadProgressEvent,
DownloadStartedEvent,
} from "./background-downloader";
import BackgroundDownloader from "./background-downloader";
import {
ChapterInfo,
PlaybackStatePayload,
@@ -12,8 +20,8 @@ import {
} from "./VlcPlayer.types";
import VlcPlayerView from "./VlcPlayerView";
export {
VlcPlayerView,
export { VlcPlayerView, BackgroundDownloader };
export type {
VlcPlayerViewProps,
VlcPlayerViewRef,
PlaybackStatePayload,
@@ -24,4 +32,9 @@ export {
VlcPlayerSource,
TrackInfo,
ChapterInfo,
DownloadProgressEvent,
DownloadCompleteEvent,
DownloadErrorEvent,
DownloadStartedEvent,
ActiveDownload,
};

3
nativewind-env.d.ts vendored
View File

@@ -1,2 +1,3 @@
/// <reference types="nativewind/types" />
/// <reference types="react-native-css/types" />
// NOTE: This file should not be edited and should be committed with your source code. It is generated by react-native-css. If you need to move or disable this file, please see the documentation.

View File

@@ -29,7 +29,6 @@
"@expo/vector-icons": "^15.0.2",
"@gorhom/bottom-sheet": "^5.1.0",
"@jellyfin/sdk": "^0.11.0",
"@kesha-antonov/react-native-background-downloader": "github:fredrikburmester/react-native-background-downloader#d78699b60866062f6d95887412cee3649a548bf2",
"@react-native-community/netinfo": "^11.4.1",
"@react-navigation/material-top-tabs": "^7.2.14",
"@react-navigation/native": "^7.0.14",
@@ -65,7 +64,7 @@
"i18next": "^25.0.0",
"jotai": "^2.12.5",
"lodash": "^4.17.21",
"nativewind": "^2.0.11",
"nativewind": "^5.0.0-preview.1",
"patch-package": "^8.0.0",
"react": "19.1.0",
"react-dom": "19.1.0",
@@ -76,6 +75,7 @@
"react-native-circular-progress": "^1.4.1",
"react-native-collapsible": "^1.6.2",
"react-native-country-flag": "^2.0.2",
"react-native-css": "^3.0.0",
"react-native-device-info": "^14.0.4",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.28.0",
@@ -86,7 +86,7 @@
"react-native-mmkv": "4.0.0-beta.12",
"react-native-nitro-modules": "^0.29.1",
"react-native-pager-view": "^6.9.1",
"react-native-reanimated": "~4.1.0",
"react-native-reanimated": "~4.1.1",
"react-native-reanimated-carousel": "4.0.2",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
@@ -99,7 +99,8 @@
"react-native-web": "^0.21.0",
"react-native-worklets": "0.5.1",
"sonner-native": "^0.21.0",
"tailwindcss": "3.3.2",
"@tailwindcss/postcss": "^4.1.14",
"postcss": "^8.5.6",
"use-debounce": "^10.0.4",
"zod": "^4.1.3"
},
@@ -108,6 +109,7 @@
"@biomejs/biome": "^2.2.4",
"@react-native-community/cli": "^20.0.0",
"@react-native-tvos/config-tv": "^0.1.1",
"tailwindcss": "^4.1.14",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.15",
"@types/react": "~19.1.10",
@@ -140,9 +142,6 @@
}
},
"private": true,
"disabledDependencies": {
"@kesha-antonov/react-native-background-downloader": "^3.2.6"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"biome check --write --unsafe --no-errors-on-unmatched"
@@ -154,5 +153,8 @@
"trustedDependencies": [
"postinstall-postinstall",
"unrs-resolver"
]
],
"overrides": {
"lightningcss": "1.30.1"
}
}

View File

@@ -1,68 +0,0 @@
const { withAppDelegate, withXcodeProject } = require("expo/config-plugins");
const fs = require("node:fs");
const path = require("node:path");
/** @param {import("expo/config-plugins").ExpoConfig} config */
function withRNBackgroundDownloader(config) {
/* 1⃣ Add handleEventsForBackgroundURLSession to AppDelegate.swift */
config = withAppDelegate(config, (mod) => {
const tag = "handleEventsForBackgroundURLSession";
if (!mod.modResults.contents.includes(tag)) {
mod.modResults.contents = mod.modResults.contents.replace(
/\}\s*$/, // insert before final }
`
func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
RNBackgroundDownloader.setCompletionHandlerWithIdentifier(identifier, completionHandler: completionHandler)
}
}`,
);
}
return mod;
});
/* 2⃣ Ensure bridging header exists & is attached to *every* app target */
config = withXcodeProject(config, (mod) => {
const project = mod.modResults;
const projectName = config.name || "App";
// Fix: Go up one more directory to get to ios/, not ios/ProjectName.xcodeproj/
const iosDir = path.dirname(path.dirname(project.filepath));
const headerRel = `${projectName}/${projectName}-Bridging-Header.h`;
const headerAbs = path.join(iosDir, headerRel);
// create / append import if missing
let headerText = "";
try {
headerText = fs.readFileSync(headerAbs, "utf8");
} catch (error) {
if (error.code !== "ENOENT") {
throw error;
}
}
if (!headerText.includes("RNBackgroundDownloader.h")) {
fs.mkdirSync(path.dirname(headerAbs), { recursive: true });
fs.appendFileSync(headerAbs, '#import "RNBackgroundDownloader.h"\n');
}
// Expo 53's xcodejs doesn't expose pbxTargets().
// Setting the property once at the project level is sufficient.
["Debug", "Release"].forEach((cfg) => {
// Use the detected projectName to set the bridging header path instead of a hardcoded value
const bridgingHeaderPath = `${projectName}/${projectName}-Bridging-Header.h`;
project.updateBuildProperty(
"SWIFT_OBJC_BRIDGING_HEADER",
bridgingHeaderPath,
cfg,
);
});
return mod;
});
return config;
}
module.exports = withRNBackgroundDownloader;

5
postcss.config.mjs Normal file
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,188 @@
# Download Provider Migration Guide
## Overview
The DownloadProvider has been completely rewritten to use the new native `BackgroundDownloader` module instead of the third-party `@kesha-antonov/react-native-background-downloader` library.
## What Changed
### New Implementation
- **Native Module**: Uses our custom `BackgroundDownloader` Expo module built with NSURLSession
- **Simplified**: Focuses only on downloading video files
- **Background Support**: True iOS background downloads with system integration
- **Event-Driven**: Uses native event emitters for progress, completion, and errors
### Removed Features (For Now)
The following features from the old implementation have been temporarily removed to simplify the initial version:
- ✗ Trickplay image downloads
- ✗ Subtitle downloads
- ✗ Series primary image caching
- ✗ Intro/credit segment fetching
- ✗ Download queue management with concurrent limits
- ✗ Pause/Resume functionality
- ✗ Speed calculation and ETA
- ✗ Cache directory management
### Maintained Features
- ✓ Download video files with progress tracking
- ✓ Database persistence (same structure)
- ✓ Movies and Episodes support
- ✓ Download notifications
- ✓ File deletion and management
- ✓ Downloaded items listing
- ✓ Same context API
## API Compatibility
The public API remains mostly the same to avoid breaking existing code:
### Working Methods
```typescript
const {
// Core functionality
startBackgroundDownload,
cancelDownload,
// Database operations
getDownloadedItems,
getDownloadsDatabase,
getDownloadedItemById,
getDownloadedItemSize,
// File management
deleteFile,
deleteItems,
deleteAllFiles,
// State
processes,
APP_CACHE_DOWNLOAD_DIRECTORY,
appSizeUsage,
} = useDownload();
```
### Deprecated (No-op) Methods
These methods exist but do nothing in the new version:
- `startDownload()` - Use `startBackgroundDownload()` instead
- `pauseDownload()` - Not supported yet
- `resumeDownload()` - Not supported yet
- `deleteFileByType()` - Not needed (only video files)
- `cleanCacheDirectory()` - Not needed
- `updateDownloadedItem()` - Not needed
- `dumpDownloadDiagnostics()` - Not needed
## Migration Steps
### For Developers
1. **No code changes needed** if you're using `startBackgroundDownload()` and basic file management
2. **Remove calls** to deprecated methods (they won't break but do nothing)
3. **Test downloads** to ensure they work in your workflows
### For Users
- **No action required** - the new system uses the same database format
- **Existing downloads** will still be accessible
- **New downloads** will use the improved background system
## Future Enhancements
Planned features to add back:
1. **Pause/Resume**: Using NSURLSession's built-in pause/resume
2. **Queue Management**: Better control over concurrent downloads
3. **Trickplay**: Re-add trickplay image downloading
4. **Subtitles**: Download and link subtitle files
5. **Progress Persistence**: Resume downloads after app restart
6. **Cellular Control**: Respect cellular data settings
7. **Speed/ETA**: Better download metrics
## Database Structure
The database structure remains unchanged:
```typescript
interface DownloadsDatabase {
movies: Record<string, DownloadedItem>;
series: Record<string, DownloadedSeries>;
other: Record<string, DownloadedItem>;
}
interface DownloadedItem {
item: BaseItemDto;
mediaSource: MediaSourceInfo;
videoFilePath: string;
videoFileSize: number;
videoFileName?: string;
trickPlayData?: TrickPlayData;
introSegments?: MediaTimeSegment[];
creditSegments?: MediaTimeSegment[];
userData: UserData;
}
```
## Known Differences
1. **Progress Updates**: More frequent and accurate with native module
2. **Background Handling**: Better iOS background download support
3. **Error Messages**: Different error format from native module
4. **File Paths**: Uses `Paths.document` instead of cache directory
5. **No Queue**: Downloads start immediately (no queuing system yet)
## Troubleshooting
### Downloads not starting
- Check that the iOS app has been rebuilt with the new native module
- Verify network permissions
- Check console logs for errors
### Progress not updating
- Ensure event listeners are properly registered
- Check that the task ID mapping is correct
- Verify the download is still active
### Files not found
- Old downloads might be in a different location
- Re-download content if files are missing
- Check file permissions
## Old Implementation
The old implementation has been preserved at:
- `providers/DownloadProvider.deprecated.tsx`
You can reference it if needed, but it should not be used in production.
## Testing
After migration, test these scenarios:
- [ ] Download a movie
- [ ] Download an episode
- [ ] Download multiple items
- [ ] Cancel a download
- [ ] Delete a downloaded item
- [ ] View downloaded items list
- [ ] Background app during download
- [ ] Force quit and restart app
- [ ] Verify notifications appear
- [ ] Check file sizes are correct
## Questions?
If you encounter issues with the migration, please:
1. Check the console logs
2. Verify the native module is installed
3. Review the old implementation for reference
4. Open an issue with details

View File

@@ -0,0 +1,149 @@
# Downloads Module
This module handles all download functionality for the Streamyfin app, including video downloads, subtitles, trickplay images, and cover images.
## Architecture
The downloads module is structured with a clean separation of concerns:
### Core Files
- **`database.ts`** - Pure functions for MMKV database operations
- **`fileOperations.ts`** - Pure functions for file system operations
- **`utils.ts`** - Pure utility functions (filename generation, URI conversion)
- **`additionalDownloads.ts`** - Pure functions for downloading additional assets
- **`notifications.ts`** - Pure functions for notification handling
- **`types.ts`** - TypeScript type definitions
### Hooks
- **`useDownloadOperations.ts`** - Hook providing download operations (start, cancel, delete)
- **`useDownloadEventHandlers.ts`** - Hook setting up native download event listeners
### Main Provider
- **`DownloadProvider.tsx`** - React context provider that orchestrates all download functionality
## Features
### Video Downloads
- Background download support using native module
- Progress tracking and reporting
- Pause/resume capability (future enhancement)
- Download queue management
### Additional Assets (Automatic)
When a video download completes, the following are automatically downloaded:
1. **Trickplay Images** - Preview thumbnail sheets for video scrubbing
2. **Subtitles** - External subtitle files (for non-transcoded content)
3. **Cover Images** - Primary item images and series images
4. **Segments** - Intro and credit skip timestamps
### File Management
- Automatic cleanup of all associated files (video, subtitles, trickplay)
- Size calculation including all assets
- Batch delete operations
## Implementation Details
### Pure Functions
All core logic is implemented as pure functions that:
- Take explicit parameters
- Return explicit values
- Have no side effects
- Are easily testable
### Imperative Design
The module uses imperative function calls rather than reactive patterns:
- Direct function invocation
- Explicit error handling
- Clear control flow
- Minimal side effects
### Storage
- **MMKV** - Used for persistent database storage
- **expo-file-system** - Used for file operations
- **Native module** - Used for background downloads
## Usage
```typescript
import { useDownload } from '@/providers/DownloadProvider';
function MyComponent() {
const {
startBackgroundDownload,
cancelDownload,
deleteFile,
getDownloadedItems,
processes,
} = useDownload();
// Start a download
await startBackgroundDownload(url, item, mediaSource, bitrate);
// Cancel a download
await cancelDownload(itemId);
// Delete a download
await deleteFile(itemId);
// Get all downloads
const items = getDownloadedItems();
}
```
## Event Flow
1. **Start Download**
- Pre-download cover images
- Start video download via native module
- Track progress via event listeners
2. **Download Progress**
- Native module emits progress events
- React state updated with progress percentage
- UI reflects current download state
3. **Download Complete**
- Video file saved to disk
- Additional assets downloaded in parallel:
- Trickplay images
- Subtitles (if applicable)
- Segments data
- Item saved to database
- Notification sent
- Process removed from queue
4. **Delete**
- Item removed from database
- All associated files deleted:
- Video file
- Subtitle files
- Trickplay directory
## File Structure
```
providers/Downloads/
├── additionalDownloads.ts # Trickplay, subtitles, cover images
├── database.ts # MMKV operations
├── fileOperations.ts # File system operations
├── notifications.ts # Notification helpers
├── types.ts # TypeScript types
├── utils.ts # Utility functions
├── index.ts # Module exports
├── hooks/
│ ├── useDownloadEventHandlers.ts
│ └── useDownloadOperations.ts
└── README.md # This file
```
## Future Enhancements
- Background download scheduling
- Network condition awareness
- Download priority management
- Automatic cleanup of old downloads
- Series season download management

View File

@@ -0,0 +1,272 @@
import type { Api } from "@jellyfin/sdk";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Directory, File, Paths } from "expo-file-system";
import { getItemImage } from "@/utils/getItemImage";
import { fetchAndParseSegments } from "@/utils/segments";
import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay";
import type { MediaTimeSegment, TrickPlayData } from "./types";
import { generateFilename } from "./utils";
/**
* Downloads trickplay images for an item
* @returns TrickPlayData with path and size, or undefined if not available
*/
export async function downloadTrickplayImages(
item: BaseItemDto,
): Promise<TrickPlayData | undefined> {
const trickplayInfo = getTrickplayInfo(item);
if (!trickplayInfo || !item.Id) {
console.log(`[TRICKPLAY] No trickplay info available for ${item.Name}`);
return undefined;
}
const filename = generateFilename(item);
const trickplayDir = new Directory(Paths.document, `${filename}_trickplay`);
// Create directory if it doesn't exist
if (!trickplayDir.exists) {
trickplayDir.create({ intermediates: true });
}
let totalSize = 0;
const downloadPromises: Promise<void>[] = [];
console.log(
`[TRICKPLAY] Downloading ${trickplayInfo.totalImageSheets} sheets for ${item.Name}`,
);
for (let index = 0; index < trickplayInfo.totalImageSheets; index++) {
const url = generateTrickplayUrl(item, index);
if (!url) continue;
const destination = new File(trickplayDir, `${index}.jpg`);
// Skip if already exists
if (destination.exists) {
totalSize += destination.size;
continue;
}
downloadPromises.push(
File.downloadFileAsync(url, destination)
.then(() => {
totalSize += destination.size;
console.log(
`[TRICKPLAY] Downloaded sheet ${index + 1}/${trickplayInfo.totalImageSheets}`,
);
})
.catch((error) => {
console.error(
`[TRICKPLAY] Failed to download sheet ${index}:`,
error,
);
}),
);
}
await Promise.all(downloadPromises);
console.log(
`[TRICKPLAY] Completed download for ${item.Name}, total size: ${totalSize} bytes`,
);
return {
path: trickplayDir.uri,
size: totalSize,
};
}
/**
* Downloads external subtitle files and updates their delivery URLs to local paths
* @returns Updated media source with local subtitle paths
*/
export async function downloadSubtitles(
mediaSource: MediaSourceInfo,
item: BaseItemDto,
apiBasePath: string,
): Promise<MediaSourceInfo> {
const externalSubtitles = mediaSource.MediaStreams?.filter(
(stream) =>
stream.Type === "Subtitle" && stream.DeliveryMethod === "External",
);
if (!externalSubtitles || externalSubtitles.length === 0) {
console.log(`[SUBTITLES] No external subtitles for ${item.Name}`);
return mediaSource;
}
console.log(
`[SUBTITLES] Downloading ${externalSubtitles.length} subtitle files for ${item.Name}`,
);
const filename = generateFilename(item);
const downloadPromises = externalSubtitles.map(async (subtitle) => {
if (!subtitle.DeliveryUrl) return;
const url = apiBasePath + subtitle.DeliveryUrl;
const extension = subtitle.Codec || "srt";
const destination = new File(
Paths.document,
`${filename}_subtitle_${subtitle.Index}.${extension}`,
);
// Skip if already exists
if (destination.exists) {
subtitle.DeliveryUrl = destination.uri;
return;
}
try {
await File.downloadFileAsync(url, destination);
subtitle.DeliveryUrl = destination.uri;
console.log(
`[SUBTITLES] Downloaded subtitle ${subtitle.DisplayTitle || subtitle.Language}`,
);
} catch (error) {
console.error(
`[SUBTITLES] Failed to download subtitle ${subtitle.Index}:`,
error,
);
}
});
await Promise.all(downloadPromises);
console.log(`[SUBTITLES] Completed subtitle downloads for ${item.Name}`);
return mediaSource;
}
/**
* Downloads and saves the cover image for an item
* @returns Path to the saved image, or undefined if failed
*/
export async function downloadCoverImage(
item: BaseItemDto,
api: Api,
saveImageFn: (itemId: string, url?: string) => Promise<void>,
): Promise<string | undefined> {
if (!item.Id) {
console.log(`[COVER] No item ID for cover image`);
return undefined;
}
try {
const itemImage = getItemImage({
item,
api,
variant: "Primary",
quality: 90,
width: 500,
});
if (!itemImage?.uri) {
console.log(`[COVER] No cover image available for ${item.Name}`);
return undefined;
}
await saveImageFn(item.Id, itemImage.uri);
console.log(`[COVER] Saved cover image for ${item.Name}`);
return itemImage.uri;
} catch (error) {
console.error(`[COVER] Failed to download cover image:`, error);
return undefined;
}
}
/**
* Downloads and saves the series primary image for an episode
* @returns Path to the saved image, or undefined if failed
*/
export async function downloadSeriesImage(
item: BaseItemDto,
saveSeriesImageFn: (item: BaseItemDto) => Promise<void>,
): Promise<void> {
if (item.Type !== "Episode" || !item.SeriesId) {
return;
}
try {
await saveSeriesImageFn(item);
console.log(`[COVER] Saved series image for ${item.SeriesName}`);
} catch (error) {
console.error(`[COVER] Failed to download series image:`, error);
}
}
/**
* Fetches intro and credit segments for an item
*/
export async function fetchSegments(
itemId: string,
api: Api,
): Promise<{
introSegments?: MediaTimeSegment[];
creditSegments?: MediaTimeSegment[];
}> {
try {
const segments = await fetchAndParseSegments(itemId, api);
console.log(`[SEGMENTS] Fetched segments for item ${itemId}`);
return segments;
} catch (error) {
console.error(`[SEGMENTS] Failed to fetch segments:`, error);
return {};
}
}
/**
* Orchestrates all additional downloads for a completed item
* Called after main video download completes
*/
export async function downloadAdditionalAssets(params: {
item: BaseItemDto;
mediaSource: MediaSourceInfo;
api: Api;
saveImageFn: (itemId: string, url?: string) => Promise<void>;
saveSeriesImageFn: (item: BaseItemDto) => Promise<void>;
}): Promise<{
trickPlayData?: TrickPlayData;
updatedMediaSource: MediaSourceInfo;
introSegments?: MediaTimeSegment[];
creditSegments?: MediaTimeSegment[];
}> {
const { item, mediaSource, api, saveImageFn, saveSeriesImageFn } = params;
console.log(`[ADDITIONAL] Starting additional downloads for ${item.Name}`);
// Run all downloads in parallel for speed
const [
trickPlayData,
updatedMediaSource,
segments,
// Cover images (fire and forget, errors are logged)
] = await Promise.all([
downloadTrickplayImages(item),
// Only download subtitles for non-transcoded streams
mediaSource.TranscodingUrl
? Promise.resolve(mediaSource)
: downloadSubtitles(mediaSource, item, api.basePath || ""),
item.Id ? fetchSegments(item.Id, api) : Promise.resolve({}),
// Cover image downloads (run but don't wait for results)
downloadCoverImage(item, api, saveImageFn).catch((err) => {
console.error("[COVER] Error downloading cover:", err);
return undefined;
}),
downloadSeriesImage(item, saveSeriesImageFn).catch((err) => {
console.error("[COVER] Error downloading series image:", err);
return undefined;
}),
]);
console.log(`[ADDITIONAL] Completed additional downloads for ${item.Name}`);
return {
trickPlayData,
updatedMediaSource,
introSegments: segments.introSegments,
creditSegments: segments.creditSegments,
};
}

View File

@@ -0,0 +1,189 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { storage } from "@/utils/mmkv";
import type { DownloadedItem, DownloadsDatabase } from "./types";
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
/**
* Get the downloads database from storage
*/
export function getDownloadsDatabase(): DownloadsDatabase {
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
if (file) {
return JSON.parse(file) as DownloadsDatabase;
}
return { movies: {}, series: {}, other: {} };
}
/**
* Save the downloads database to storage
*/
export function saveDownloadsDatabase(db: DownloadsDatabase): void {
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
}
/**
* Get all downloaded items as a flat array
*/
export function getAllDownloadedItems(): DownloadedItem[] {
const db = getDownloadsDatabase();
const items: DownloadedItem[] = [];
for (const movie of Object.values(db.movies)) {
items.push(movie);
}
for (const series of Object.values(db.series)) {
for (const season of Object.values(series.seasons)) {
for (const episode of Object.values(season.episodes)) {
items.push(episode);
}
}
}
if (db.other) {
for (const item of Object.values(db.other)) {
items.push(item);
}
}
return items;
}
/**
* Get a downloaded item by its ID
*/
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
const db = getDownloadsDatabase();
if (db.movies[id]) {
return db.movies[id];
}
for (const series of Object.values(db.series)) {
for (const season of Object.values(series.seasons)) {
for (const episode of Object.values(season.episodes)) {
if (episode.item.Id === id) {
return episode;
}
}
}
}
if (db.other?.[id]) {
return db.other[id];
}
return undefined;
}
/**
* Add a downloaded item to the database
*/
export function addDownloadedItem(item: DownloadedItem): void {
const db = getDownloadsDatabase();
const baseItem = item.item;
if (baseItem.Type === "Movie" && baseItem.Id) {
db.movies[baseItem.Id] = item;
} else if (
baseItem.Type === "Episode" &&
baseItem.SeriesId &&
baseItem.ParentIndexNumber !== undefined &&
baseItem.ParentIndexNumber !== null &&
baseItem.IndexNumber !== undefined &&
baseItem.IndexNumber !== null
) {
// Ensure series exists
if (!db.series[baseItem.SeriesId]) {
const seriesInfo: Partial<BaseItemDto> = {
Id: baseItem.SeriesId,
Name: baseItem.SeriesName,
Type: "Series",
};
db.series[baseItem.SeriesId] = {
seriesInfo: seriesInfo as BaseItemDto,
seasons: {},
};
}
// Ensure season exists
const seasonNumber = baseItem.ParentIndexNumber;
if (!db.series[baseItem.SeriesId].seasons[seasonNumber]) {
db.series[baseItem.SeriesId].seasons[seasonNumber] = {
episodes: {},
};
}
// Add episode
const episodeNumber = baseItem.IndexNumber;
db.series[baseItem.SeriesId].seasons[seasonNumber].episodes[episodeNumber] =
item;
} else if (baseItem.Id) {
if (!db.other) db.other = {};
db.other[baseItem.Id] = item;
}
saveDownloadsDatabase(db);
}
/**
* Remove a downloaded item from the database
* Returns the removed item if found, undefined otherwise
*/
export function removeDownloadedItem(id: string): DownloadedItem | undefined {
const db = getDownloadsDatabase();
let itemToDelete: DownloadedItem | undefined;
// Check movies
if (db.movies[id]) {
itemToDelete = db.movies[id];
delete db.movies[id];
} else {
// Check series episodes
for (const seriesId in db.series) {
const series = db.series[seriesId];
for (const seasonNum in series.seasons) {
const season = series.seasons[seasonNum];
for (const episodeNum in season.episodes) {
const episode = season.episodes[episodeNum];
if (episode.item.Id === id) {
itemToDelete = episode;
delete season.episodes[episodeNum];
// Clean up empty season
if (Object.keys(season.episodes).length === 0) {
delete series.seasons[seasonNum];
}
// Clean up empty series
if (Object.keys(series.seasons).length === 0) {
delete db.series[seriesId];
}
break;
}
}
}
}
// Check other items
if (!itemToDelete && db.other?.[id]) {
itemToDelete = db.other[id];
delete db.other[id];
}
}
if (itemToDelete) {
saveDownloadsDatabase(db);
}
return itemToDelete;
}
/**
* Clear all downloaded items from the database
*/
export function clearAllDownloadedItems(): void {
saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
}

View File

@@ -0,0 +1,99 @@
import { Directory, File, Paths } from "expo-file-system";
import { getAllDownloadedItems, getDownloadedItemById } from "./database";
import type { DownloadedItem } from "./types";
/**
* Delete a video file and all associated files (subtitles, trickplay, etc.)
*/
export function deleteVideoFile(filePath: string): void {
try {
const videoFile = new File("", filePath);
if (videoFile.exists) {
videoFile.delete();
console.log(`[DELETE] Video file deleted: ${filePath}`);
}
} catch (error) {
console.error("Failed to delete video file:", error);
throw error;
}
}
/**
* Delete all associated files for a downloaded item
* Includes: video, subtitles, trickplay images
*/
export function deleteAllAssociatedFiles(item: DownloadedItem): void {
try {
// Delete video file
if (item.videoFilePath) {
deleteVideoFile(item.videoFilePath);
}
// Delete subtitle files
if (item.mediaSource?.MediaStreams) {
for (const stream of item.mediaSource.MediaStreams) {
if (
stream.Type === "Subtitle" &&
stream.DeliveryMethod === "External" &&
stream.DeliveryUrl
) {
try {
const subtitleFilename = stream.DeliveryUrl.split("/").pop();
if (subtitleFilename) {
const subtitleFile = new File(Paths.document, subtitleFilename);
if (subtitleFile.exists) {
subtitleFile.delete();
console.log(`[DELETE] Subtitle deleted: ${subtitleFilename}`);
}
}
} catch (error) {
console.error("[DELETE] Failed to delete subtitle:", error);
}
}
}
}
// Delete trickplay directory
if (item.trickPlayData?.path) {
try {
const trickplayDirName = item.trickPlayData.path.split("/").pop();
if (trickplayDirName) {
const trickplayDir = new Directory(Paths.document, trickplayDirName);
if (trickplayDir.exists) {
trickplayDir.delete();
console.log(
`[DELETE] Trickplay directory deleted: ${trickplayDirName}`,
);
}
}
} catch (error) {
console.error("[DELETE] Failed to delete trickplay directory:", error);
}
}
} catch (error) {
console.error("[DELETE] Error deleting associated files:", error);
throw error;
}
}
/**
* Get the size of a downloaded item by ID
* Includes video file size and trickplay data size
*/
export function getDownloadedItemSize(id: string): number {
const item = getDownloadedItemById(id);
if (!item) return 0;
const videoSize = item.videoFileSize || 0;
const trickplaySize = item.trickPlayData?.size || 0;
return videoSize + trickplaySize;
}
/**
* Calculate total size of all downloaded items
*/
export function calculateTotalDownloadedSize(): number {
const items = getAllDownloadedItems();
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
}

View File

@@ -0,0 +1,318 @@
import type { Api } from "@jellyfin/sdk";
import { File } from "expo-file-system";
import type { MutableRefObject } from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner-native";
import useImageStorage from "@/hooks/useImageStorage";
import type {
DownloadCompleteEvent,
DownloadErrorEvent,
DownloadProgressEvent,
DownloadStartedEvent,
} from "@/modules";
import { BackgroundDownloader } from "@/modules";
import useDownloadHelper from "@/utils/download";
import { downloadAdditionalAssets } from "../additionalDownloads";
import { addDownloadedItem } from "../database";
import {
getNotificationContent,
sendDownloadNotification,
} from "../notifications";
import type {
DownloadedItem,
JobStatus,
MediaTimeSegment,
TrickPlayData,
} from "../types";
import { generateFilename } from "../utils";
import {
addSpeedDataPoint,
calculateWeightedSpeed,
clearSpeedData,
} from "./useDownloadSpeedCalculator";
interface UseDownloadEventHandlersProps {
taskMapRef: MutableRefObject<Map<number, string>>;
processes: JobStatus[];
updateProcess: (
processId: string,
updater: Partial<JobStatus> | ((current: JobStatus) => Partial<JobStatus>),
) => void;
removeProcess: (id: string) => void;
onSuccess?: () => void;
onDataChange?: () => void;
api?: Api;
}
/**
* Hook to set up download event listeners (progress, complete, error, started)
*/
export function useDownloadEventHandlers({
taskMapRef,
processes,
updateProcess,
removeProcess,
onSuccess,
onDataChange,
api,
}: UseDownloadEventHandlersProps) {
const { t } = useTranslation();
const { saveSeriesPrimaryImage } = useDownloadHelper();
const { saveImage } = useImageStorage();
// Handle download started events
useEffect(() => {
console.log("[DPL] Setting up started listener");
const startedSub = BackgroundDownloader.addStartedListener(
(event: DownloadStartedEvent) => {
console.log("[DPL] Download started event received:", event);
},
);
return () => {
console.log("[DPL] Removing started listener");
startedSub.remove();
};
}, []);
// Handle download progress events
useEffect(() => {
console.log("[DPL] Setting up progress listener");
const progressSub = BackgroundDownloader.addProgressListener(
(event: DownloadProgressEvent) => {
console.log("[DPL] Progress event received:", {
taskId: event.taskId,
progress: event.progress,
bytesWritten: event.bytesWritten,
totalBytes: event.totalBytes,
taskMapSize: taskMapRef.current.size,
taskMapKeys: Array.from(taskMapRef.current.keys()),
});
const processId = taskMapRef.current.get(event.taskId);
if (!processId) {
console.log(
`[DPL] Progress event for unknown taskId: ${event.taskId}`,
event,
);
return;
}
// Validate event data before processing
if (
typeof event.bytesWritten !== "number" ||
event.bytesWritten < 0 ||
!Number.isFinite(event.bytesWritten)
) {
console.warn(
`[DPL] Invalid bytesWritten for taskId ${event.taskId}: ${event.bytesWritten}`,
);
return;
}
if (
typeof event.progress !== "number" ||
event.progress < 0 ||
event.progress > 1 ||
!Number.isFinite(event.progress)
) {
console.warn(
`[DPL] Invalid progress for taskId ${event.taskId}: ${event.progress}`,
);
return;
}
const progress = Math.min(
Math.floor(event.progress * 100),
99, // Cap at 99% until completion
);
// Add data point and calculate speed (validation happens inside)
addSpeedDataPoint(processId, event.bytesWritten);
const speed = calculateWeightedSpeed(processId);
console.log(
`[DPL] Progress update for processId: ${processId}, taskId: ${event.taskId}, progress: ${progress}%, bytesWritten: ${event.bytesWritten}, speed: ${speed ? (speed / 1024 / 1024).toFixed(2) : "N/A"} MB/s`,
);
updateProcess(processId, {
progress,
bytesDownloaded: event.bytesWritten,
lastProgressUpdateTime: new Date(),
speed,
estimatedTotalSizeBytes:
event.totalBytes > 0 && Number.isFinite(event.totalBytes)
? event.totalBytes
: undefined,
});
},
);
return () => {
console.log("[DPL] Removing progress listener");
progressSub.remove();
};
}, [taskMapRef, updateProcess]);
// Handle download completion events
useEffect(() => {
const completeSub = BackgroundDownloader.addCompleteListener(
async (event: DownloadCompleteEvent) => {
const processId = taskMapRef.current.get(event.taskId);
if (!processId) return;
const process = processes.find((p) => p.id === processId);
if (!process) return;
try {
const { item, mediaSource } = process;
const videoFile = new File("", event.filePath);
const fileInfo = videoFile.info();
const videoFileSize = fileInfo.size || 0;
const filename = generateFilename(item);
console.log(
`[COMPLETE] Video download complete (${videoFileSize} bytes), starting additional downloads for ${item.Name}`,
);
// Download additional assets (trickplay, subtitles, cover images, segments)
let trickPlayData: TrickPlayData | undefined;
let updatedMediaSource = mediaSource;
let introSegments: MediaTimeSegment[] | undefined;
let creditSegments: MediaTimeSegment[] | undefined;
if (api) {
const additionalAssets = await downloadAdditionalAssets({
item,
mediaSource,
api,
saveImageFn: saveImage,
saveSeriesImageFn: saveSeriesPrimaryImage,
});
trickPlayData = additionalAssets.trickPlayData;
updatedMediaSource = additionalAssets.updatedMediaSource;
introSegments = additionalAssets.introSegments;
creditSegments = additionalAssets.creditSegments;
} else {
console.warn(
"[COMPLETE] API not available, skipping additional downloads",
);
}
const downloadedItem: DownloadedItem = {
item,
mediaSource: updatedMediaSource,
videoFilePath: event.filePath,
videoFileSize,
videoFileName: `${filename}.mp4`,
trickPlayData,
introSegments,
creditSegments,
userData: {
audioStreamIndex: 0,
subtitleStreamIndex: 0,
},
};
addDownloadedItem(downloadedItem);
updateProcess(processId, {
status: "completed",
progress: 100,
});
const notificationContent = getNotificationContent(item, true, t);
await sendDownloadNotification(
notificationContent.title,
notificationContent.body,
);
toast.success(
t("home.downloads.toasts.download_completed_for_item", {
item: item.Name,
}),
);
onSuccess?.();
onDataChange?.();
// Clean up speed data when download completes
clearSpeedData(processId);
// Remove process after short delay
setTimeout(() => {
removeProcess(processId);
}, 2000);
} catch (error) {
console.error("Error handling download completion:", error);
updateProcess(processId, { status: "error" });
clearSpeedData(processId);
removeProcess(processId);
}
},
);
return () => completeSub.remove();
}, [
taskMapRef,
processes,
updateProcess,
removeProcess,
onSuccess,
onDataChange,
api,
saveImage,
saveSeriesPrimaryImage,
t,
]);
// Handle download error events
useEffect(() => {
const errorSub = BackgroundDownloader.addErrorListener(
async (event: DownloadErrorEvent) => {
const processId = taskMapRef.current.get(event.taskId);
if (!processId) return;
const process = processes.find((p) => p.id === processId);
if (!process) return;
console.error(`Download error for ${processId}:`, event.error);
updateProcess(processId, { status: "error" });
// Clean up speed data
clearSpeedData(processId);
const notificationContent = getNotificationContent(
process.item,
false,
t,
);
await sendDownloadNotification(
notificationContent.title,
notificationContent.body,
);
toast.error(
t("home.downloads.toasts.download_failed_for_item", {
item: process.item.Name,
}),
{
description: event.error,
},
);
// Remove process after short delay
setTimeout(() => {
removeProcess(processId);
}, 3000);
},
);
return () => errorSub.remove();
}, [taskMapRef, processes, updateProcess, removeProcess, t]);
}

View File

@@ -0,0 +1,288 @@
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { File, Paths } from "expo-file-system";
import type { MutableRefObject } from "react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner-native";
import type { Bitrate } from "@/components/BitrateSelector";
import useImageStorage from "@/hooks/useImageStorage";
import { BackgroundDownloader } from "@/modules";
import { getOrSetDeviceId } from "@/utils/device";
import useDownloadHelper from "@/utils/download";
import { getItemImage } from "@/utils/getItemImage";
import {
clearAllDownloadedItems,
getAllDownloadedItems,
removeDownloadedItem,
} from "../database";
import {
calculateTotalDownloadedSize,
deleteAllAssociatedFiles,
} from "../fileOperations";
import type { JobStatus } from "../types";
import { generateFilename, uriToFilePath } from "../utils";
interface UseDownloadOperationsProps {
taskMapRef: MutableRefObject<Map<number, string>>;
processes: JobStatus[];
setProcesses: (updater: (prev: JobStatus[]) => JobStatus[]) => void;
removeProcess: (id: string) => void;
api: any;
authHeader?: string;
onDataChange?: () => void;
}
/**
* Hook providing download operation functions (start, cancel, delete)
*/
export function useDownloadOperations({
taskMapRef,
processes,
setProcesses,
removeProcess,
api,
authHeader,
onDataChange,
}: UseDownloadOperationsProps) {
const { t } = useTranslation();
const { saveSeriesPrimaryImage } = useDownloadHelper();
const { saveImage } = useImageStorage();
const startBackgroundDownload = useCallback(
async (
url: string,
item: BaseItemDto,
mediaSource: MediaSourceInfo,
maxBitrate: Bitrate,
) => {
if (!api || !item.Id || !authHeader) {
console.warn("startBackgroundDownload ~ Missing required params");
throw new Error("startBackgroundDownload ~ Missing required params");
}
try {
const deviceId = getOrSetDeviceId();
const processId = item.Id;
// Check if already downloading
const existingProcess = processes.find((p) => p.id === processId);
if (existingProcess) {
toast.info(
t("home.downloads.toasts.item_already_downloading", {
item: item.Name,
}),
);
return;
}
// Pre-download cover images before starting the video download
console.log(`[DOWNLOAD] Pre-downloading cover images for ${item.Name}`);
await saveSeriesPrimaryImage(item);
const itemImage = getItemImage({
item,
api,
variant: "Primary",
quality: 90,
width: 500,
});
await saveImage(item.Id, itemImage?.uri);
// Create job status
const jobStatus: JobStatus = {
id: processId,
inputUrl: url,
item,
itemId: item.Id,
deviceId,
progress: 0,
status: "downloading",
timestamp: new Date(),
mediaSource,
maxBitrate,
bytesDownloaded: 0,
};
// Add to processes
setProcesses((prev) => [...prev, jobStatus]);
// Generate destination path
const filename = generateFilename(item);
const videoFile = new File(Paths.document, `${filename}.mp4`);
const destinationPath = uriToFilePath(videoFile.uri);
console.log(`[DOWNLOAD] Starting download for ${filename}`);
console.log(`[DOWNLOAD] URL: ${url}`);
console.log(`[DOWNLOAD] Destination: ${destinationPath}`);
// Start the download (URL already contains api_key)
const taskId = await BackgroundDownloader.startDownload(
url,
destinationPath,
);
console.log(
`[DOWNLOAD] Got taskId: ${taskId} for processId: ${processId}`,
);
// Map task ID to process ID
taskMapRef.current.set(taskId, processId);
console.log(`[DOWNLOAD] TaskMap now contains:`, {
size: taskMapRef.current.size,
entries: Array.from(taskMapRef.current.entries()),
});
toast.success(
t("home.downloads.toasts.download_started_for_item", {
item: item.Name,
}),
);
} catch (error) {
console.error("Failed to start download:", error);
toast.error(t("home.downloads.toasts.failed_to_start_download"), {
description: error instanceof Error ? error.message : "Unknown error",
});
throw error;
}
},
[api, authHeader, processes, setProcesses, taskMapRef, t],
);
const cancelDownload = useCallback(
async (id: string) => {
// Find the task ID for this process
let taskId: number | undefined;
for (const [tId, pId] of taskMapRef.current.entries()) {
if (pId === id) {
taskId = tId;
break;
}
}
if (taskId !== undefined) {
BackgroundDownloader.cancelDownload(taskId);
}
removeProcess(id);
toast.info(t("home.downloads.toasts.download_cancelled"));
},
[taskMapRef, removeProcess, t],
);
const deleteFile = useCallback(
async (id: string) => {
const itemToDelete = removeDownloadedItem(id);
if (itemToDelete) {
try {
deleteAllAssociatedFiles(itemToDelete);
toast.success(
t("home.downloads.toasts.file_deleted", {
item: itemToDelete.item.Name,
}),
);
onDataChange?.();
} catch (error) {
console.error("Failed to delete files:", error);
}
}
},
[t, onDataChange],
);
const deleteItems = useCallback(
async (ids: string[]) => {
for (const id of ids) {
await deleteFile(id);
}
},
[deleteFile],
);
const deleteAllFiles = useCallback(async () => {
const allItems = getAllDownloadedItems();
for (const item of allItems) {
try {
deleteAllAssociatedFiles(item);
} catch (error) {
console.error("Failed to delete file:", error);
}
}
clearAllDownloadedItems();
toast.success(t("home.downloads.toasts.all_files_deleted"));
onDataChange?.();
}, [t, onDataChange]);
const deleteFileByType = useCallback(
async (itemType: string) => {
const allItems = getAllDownloadedItems();
const itemsToDelete = allItems.filter(
(item) => item.item.Type === itemType,
);
if (itemsToDelete.length === 0) {
console.log(`[DELETE] No items found with type: ${itemType}`);
return;
}
console.log(
`[DELETE] Deleting ${itemsToDelete.length} items of type: ${itemType}`,
);
for (const item of itemsToDelete) {
try {
deleteAllAssociatedFiles(item);
removeDownloadedItem(item.item.Id || "");
} catch (error) {
console.error(
`Failed to delete ${itemType} file ${item.item.Name}:`,
error,
);
}
}
const itemLabel =
itemType === "Movie"
? t("common.movies")
: itemType === "Episode"
? t("common.episodes")
: itemType;
toast.success(
t("home.downloads.toasts.files_deleted_by_type", {
count: itemsToDelete.length,
type: itemLabel,
defaultValue: `${itemsToDelete.length} ${itemLabel} deleted`,
}),
);
onDataChange?.();
},
[t, onDataChange],
);
const appSizeUsage = useCallback(async () => {
const totalSize = calculateTotalDownloadedSize();
return {
total: 0,
remaining: 0,
appSize: totalSize,
};
}, []);
return {
startBackgroundDownload,
cancelDownload,
deleteFile,
deleteItems,
deleteAllFiles,
deleteFileByType,
appSizeUsage,
};
}

View File

@@ -0,0 +1,261 @@
interface SpeedDataPoint {
timestamp: number;
bytesDownloaded: number;
}
const WINDOW_DURATION = 60000; // 1 minute in ms
const MIN_DATA_POINTS = 5; // Need at least 5 points for accurate speed
const MAX_REASONABLE_SPEED = 1024 * 1024 * 1024; // 1 GB/s sanity check
const EMA_ALPHA = 0.2; // Smoothing factor for EMA (lower = smoother, 0-1 range)
// Private state
const dataPoints = new Map<string, SpeedDataPoint[]>();
const emaSpeed = new Map<string, number>(); // Store EMA speed for each process
function isValidBytes(bytes: number): boolean {
return typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0;
}
function isValidTimestamp(timestamp: number): boolean {
return (
typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0
);
}
export function addSpeedDataPoint(
processId: string,
bytesDownloaded: number,
): void {
// Validate input
if (!isValidBytes(bytesDownloaded)) {
console.warn(
`[SpeedCalc] Invalid bytes value for ${processId}: ${bytesDownloaded}`,
);
return;
}
const now = Date.now();
if (!isValidTimestamp(now)) {
console.warn(`[SpeedCalc] Invalid timestamp: ${now}`);
return;
}
if (!dataPoints.has(processId)) {
dataPoints.set(processId, []);
}
const points = dataPoints.get(processId)!;
// Validate that bytes are increasing (or at least not decreasing)
if (points.length > 0) {
const lastPoint = points[points.length - 1];
if (bytesDownloaded < lastPoint.bytesDownloaded) {
console.warn(
`[SpeedCalc] Bytes decreased for ${processId}: ${lastPoint.bytesDownloaded} -> ${bytesDownloaded}. Resetting.`,
);
// Reset the data for this process
dataPoints.set(processId, []);
}
}
// Add new data point
points.push({
timestamp: now,
bytesDownloaded,
});
// Remove data points older than 1 minute
const cutoffTime = now - WINDOW_DURATION;
while (points.length > 0 && points[0].timestamp < cutoffTime) {
points.shift();
}
}
export function calculateSpeed(processId: string): number | undefined {
const points = dataPoints.get(processId);
if (!points || points.length < MIN_DATA_POINTS) {
return undefined;
}
const oldest = points[0];
const newest = points[points.length - 1];
// Validate data points
if (
!isValidBytes(oldest.bytesDownloaded) ||
!isValidBytes(newest.bytesDownloaded) ||
!isValidTimestamp(oldest.timestamp) ||
!isValidTimestamp(newest.timestamp)
) {
console.warn(`[SpeedCalc] Invalid data points for ${processId}`);
return undefined;
}
const timeDelta = (newest.timestamp - oldest.timestamp) / 1000; // seconds
const bytesDelta = newest.bytesDownloaded - oldest.bytesDownloaded;
// Validate calculations
if (timeDelta < 0.5) {
// Not enough time has passed
return undefined;
}
if (bytesDelta < 0) {
console.warn(
`[SpeedCalc] Negative bytes delta for ${processId}: ${bytesDelta}`,
);
return undefined;
}
const speed = bytesDelta / timeDelta; // bytes per second
// Sanity check: if speed is unrealistically high, something is wrong
if (!Number.isFinite(speed) || speed < 0 || speed > MAX_REASONABLE_SPEED) {
console.warn(`[SpeedCalc] Unrealistic speed for ${processId}: ${speed}`);
return undefined;
}
return speed;
}
// Calculate weighted average speed (more recent data has higher weight)
export function calculateWeightedSpeed(processId: string): number | undefined {
const points = dataPoints.get(processId);
if (!points || points.length < MIN_DATA_POINTS) {
return undefined;
}
let totalWeightedSpeed = 0;
let totalWeight = 0;
// Calculate speed between consecutive points with exponential weighting
for (let i = 1; i < points.length; i++) {
const prevPoint = points[i - 1];
const currPoint = points[i];
// Validate both points
if (
!isValidBytes(prevPoint.bytesDownloaded) ||
!isValidBytes(currPoint.bytesDownloaded) ||
!isValidTimestamp(prevPoint.timestamp) ||
!isValidTimestamp(currPoint.timestamp)
) {
continue;
}
const timeDelta = (currPoint.timestamp - prevPoint.timestamp) / 1000;
const bytesDelta = currPoint.bytesDownloaded - prevPoint.bytesDownloaded;
// Skip invalid deltas
if (timeDelta < 0.1 || bytesDelta < 0) {
continue;
}
const speed = bytesDelta / timeDelta;
// Sanity check
if (!Number.isFinite(speed) || speed < 0 || speed > MAX_REASONABLE_SPEED) {
console.warn(`[SpeedCalc] Skipping unrealistic speed point: ${speed}`);
continue;
}
// More recent points get exponentially higher weight
// Using 1.3 instead of 2 for gentler weighting (less sensitive to recent changes)
const weight = 1.3 ** i;
totalWeightedSpeed += speed * weight;
totalWeight += weight;
}
if (totalWeight === 0) {
return undefined;
}
const weightedSpeed = totalWeightedSpeed / totalWeight;
// Final sanity check
if (!Number.isFinite(weightedSpeed) || weightedSpeed < 0) {
return undefined;
}
return weightedSpeed;
}
// Calculate ETA in seconds
export function calculateETA(
processId: string,
bytesDownloaded: number,
totalBytes: number,
): number | undefined {
const speed = calculateWeightedSpeed(processId);
if (!speed || speed <= 0 || !totalBytes || totalBytes <= 0) {
return undefined;
}
const bytesRemaining = totalBytes - bytesDownloaded;
if (bytesRemaining <= 0) {
return 0;
}
const secondsRemaining = bytesRemaining / speed;
// Sanity check
if (!Number.isFinite(secondsRemaining) || secondsRemaining < 0) {
return undefined;
}
return secondsRemaining;
}
// Calculate smoothed ETA using Exponential Moving Average (EMA)
// This provides much smoother ETA estimates, reducing jumpy time estimates
const emaETA = new Map<string, number>();
export function calculateSmoothedETA(
processId: string,
bytesDownloaded: number,
totalBytes: number,
): number | undefined {
const currentETA = calculateETA(processId, bytesDownloaded, totalBytes);
if (currentETA === undefined) {
return undefined;
}
const previousEma = emaETA.get(processId);
if (previousEma === undefined) {
// First calculation, initialize with current ETA
emaETA.set(processId, currentETA);
return currentETA;
}
// EMA formula: EMA(t) = α * current + (1 - α) * EMA(t-1)
// Lower alpha = smoother but slower to respond
const smoothed = EMA_ALPHA * currentETA + (1 - EMA_ALPHA) * previousEma;
emaETA.set(processId, smoothed);
return smoothed;
}
export function clearSpeedData(processId: string): void {
dataPoints.delete(processId);
emaSpeed.delete(processId);
emaETA.delete(processId);
}
export function resetAllSpeedData(): void {
dataPoints.clear();
emaSpeed.clear();
emaETA.clear();
}
// Debug function to inspect current state
export function getSpeedDataDebug(
processId: string,
): SpeedDataPoint[] | undefined {
return dataPoints.get(processId);
}

View File

@@ -0,0 +1,47 @@
// Database operations
// Additional downloads (trickplay, subtitles, cover images)
export {
downloadAdditionalAssets,
downloadCoverImage,
downloadSeriesImage,
downloadSubtitles,
downloadTrickplayImages,
fetchSegments,
} from "./additionalDownloads";
export {
addDownloadedItem,
clearAllDownloadedItems,
getAllDownloadedItems,
getDownloadedItemById,
getDownloadsDatabase,
removeDownloadedItem,
saveDownloadsDatabase,
} from "./database";
// File operations
export {
calculateTotalDownloadedSize,
deleteAllAssociatedFiles,
deleteVideoFile,
getDownloadedItemSize,
} from "./fileOperations";
// Hooks
export { useDownloadEventHandlers } from "./hooks/useDownloadEventHandlers";
export { useDownloadOperations } from "./hooks/useDownloadOperations";
// Notification helpers
export {
getNotificationContent,
sendDownloadNotification,
} from "./notifications";
// Types (re-export from existing types.ts)
export type {
DownloadedItem,
DownloadedSeason,
DownloadedSeries,
DownloadsDatabase,
JobStatus,
MediaTimeSegment,
TrickPlayData,
} from "./types";
// Utility functions
export { generateFilename, uriToFilePath } from "./utils";

View File

@@ -0,0 +1,74 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as Notifications from "expo-notifications";
import type { TFunction } from "i18next";
import { Platform } from "react-native";
/**
* Generate notification content based on item type
*/
export function getNotificationContent(
item: BaseItemDto,
isSuccess: boolean,
t: TFunction,
): { title: string; body: string } {
if (item.Type === "Episode") {
const season = item.ParentIndexNumber
? String(item.ParentIndexNumber).padStart(2, "0")
: "??";
const episode = item.IndexNumber
? String(item.IndexNumber).padStart(2, "0")
: "??";
const subtitle = `${item.Name} - [S${season}E${episode}] (${item.SeriesName})`;
return {
title: isSuccess ? "Download complete" : "Download failed",
body: subtitle,
};
}
if (item.Type === "Movie") {
const year = item.ProductionYear ? ` (${item.ProductionYear})` : "";
const subtitle = `${item.Name}${year}`;
return {
title: isSuccess ? "Download complete" : "Download failed",
body: subtitle,
};
}
return {
title: isSuccess
? t("home.downloads.toasts.download_completed_for_item", {
item: item.Name,
})
: t("home.downloads.toasts.download_failed_for_item", {
item: item.Name,
}),
body: item.Name || "Unknown item",
};
}
/**
* Send a local notification for download events
*/
export async function sendDownloadNotification(
title: string,
body: string,
data?: Record<string, any>,
): Promise<void> {
if (Platform.isTV) return;
try {
await Notifications.scheduleNotificationAsync({
content: {
title,
body,
data: data || {}, // iOS requires data to be an object, not undefined
...(Platform.OS === "android" && { channelId: "downloads" }),
},
trigger: null,
});
} catch (error) {
console.error("Failed to send notification:", error);
}
}

View File

@@ -113,7 +113,6 @@ export type JobStatus = {
/** Current status of the download job */
status:
| "downloading" // The job is actively downloading
| "paused" // The job is paused
| "error" // The job encountered an error
| "pending" // The job is waiting to start
| "completed" // The job has finished downloading
@@ -133,14 +132,4 @@ export type JobStatus = {
/** Estimated total size of the download in bytes (optional) this is used when we
* download transcoded content because we don't know the size of the file until it's downloaded */
estimatedTotalSizeBytes?: number;
/** Timestamp when the download was paused (optional) */
pausedAt?: Date;
/** Progress percentage when download was paused (optional) */
pausedProgress?: number;
/** Bytes downloaded when download was paused (optional) */
pausedBytes?: number;
/** Bytes downloaded in the current session (since last resume). Used for session-only speed calculation. */
lastSessionBytes?: number;
/** Timestamp when the session-only bytes were last updated. */
lastSessionUpdateTime?: Date;
};

View File

@@ -0,0 +1,33 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
/**
* Generate a safe filename from item metadata
*/
export function generateFilename(item: BaseItemDto): string {
if (item.Type === "Episode") {
const season = String(item.ParentIndexNumber || 0).padStart(2, "0");
const episode = String(item.IndexNumber || 0).padStart(2, "0");
const seriesName = (item.SeriesName || "Unknown")
.replace(/[^a-z0-9]/gi, "_")
.toLowerCase();
return `${seriesName}_s${season}e${episode}`;
}
if (item.Type === "Movie") {
const movieName = (item.Name || "Unknown")
.replace(/[^a-z0-9]/gi, "_")
.toLowerCase();
const year = item.ProductionYear || "";
return `${movieName}_${year}`;
}
return `${item.Id}`;
}
/**
* Strip file:// prefix from URI to get plain file path
* Required for native modules that expect plain paths
*/
export function uriToFilePath(uri: string): string {
return uri.replace(/^file:\/\//, "");
}

View File

@@ -0,0 +1,41 @@
const fs = require("node:fs");
const path = require("node:path");
const filePath = path.join(
__dirname,
"..",
"node_modules",
"react-native-bottom-tabs",
"lib",
"module",
"TabView.js",
);
try {
let content = fs.readFileSync(filePath, "utf8");
if (content.includes("// Polyfill for Image.resolveAssetSource")) {
console.log("✓ react-native-bottom-tabs already patched");
process.exit(0);
}
const patchCode = `
// Polyfill for Image.resolveAssetSource if not available
if (!Image.resolveAssetSource) {
const resolveAssetSourceModule = require('react-native/Libraries/Image/resolveAssetSource');
Image.resolveAssetSource = resolveAssetSourceModule.default || resolveAssetSourceModule;
}
`;
content = content.replace(
`import { Image, Platform, StyleSheet, View, processColor } from 'react-native';
import { BottomTabBarHeightContext }`,
`import { Image, Platform, StyleSheet, View, processColor } from 'react-native';
${patchCode}import { BottomTabBarHeightContext }`,
);
fs.writeFileSync(filePath, content, "utf8");
console.log("✓ Successfully patched react-native-bottom-tabs");
} catch (error) {
console.error("Failed to patch react-native-bottom-tabs:", error.message);
}

View File

@@ -269,7 +269,7 @@
"movies": "Movies",
"queue": "Queue",
"other_media": "Other media",
"queue_hint": "Queue and downloads will be lost on app restart",
"queue_hint": "Queue will be lost on app restart",
"no_items_in_queue": "No Items in Queue",
"no_downloaded_items": "No Downloaded Items",
"delete_all_movies_button": "Delete All Movies",
@@ -308,7 +308,8 @@
"all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
"failed_to_clean_cache_directory": "Failed to clean cache directory",
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
"go_to_downloads": "Go to Downloads"
"go_to_downloads": "Go to Downloads",
"file_deleted": "{{item}} deleted"
}
}
},

View File

@@ -23,7 +23,8 @@
"*.ts",
"*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
"expo-env.d.ts",
"nativewind-env.d.ts"
],
"exclude": [
"node_modules",

51
utils/downloadStats.ts Normal file
View File

@@ -0,0 +1,51 @@
export function formatSpeed(bytesPerSecond: number | undefined): string {
if (!bytesPerSecond || bytesPerSecond <= 0) return "N/A";
const mbps = bytesPerSecond / (1024 * 1024);
if (mbps >= 1) {
return `${mbps.toFixed(2)} MB/s`;
}
const kbps = bytesPerSecond / 1024;
return `${kbps.toFixed(0)} KB/s`;
}
export function formatETA(
bytesDownloaded: number | undefined,
totalBytes: number | undefined,
speed: number | undefined,
): string {
if (!bytesDownloaded || !totalBytes || !speed || speed <= 0) {
return "Calculating...";
}
const remainingBytes = totalBytes - bytesDownloaded;
if (remainingBytes <= 0) return "0s";
const secondsRemaining = remainingBytes / speed;
if (secondsRemaining < 60) {
return `${Math.ceil(secondsRemaining)}s`;
}
if (secondsRemaining < 3600) {
const minutes = Math.floor(secondsRemaining / 60);
const seconds = Math.ceil(secondsRemaining % 60);
return `${minutes}m ${seconds}s`;
}
const hours = Math.floor(secondsRemaining / 3600);
const minutes = Math.floor((secondsRemaining % 3600) / 60);
return `${hours}h ${minutes}m`;
}
export function calculateETA(
bytesDownloaded: number | undefined,
totalBytes: number | undefined,
speed: number | undefined,
): number | undefined {
if (!bytesDownloaded || !totalBytes || !speed || speed <= 0) {
return undefined;
}
const remainingBytes = totalBytes - bytesDownloaded;
return remainingBytes / speed; // seconds
}