mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
Compare commits
23 Commits
feature/ne
...
feat/new-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
744c35d71c | ||
|
|
f28d6ca56d | ||
|
|
23406b957d | ||
|
|
ec7954036e | ||
|
|
87716aff92 | ||
|
|
380f5cbf70 | ||
|
|
23c1c817a0 | ||
|
|
3a8fb0a5e5 | ||
|
|
06e19bd7e6 | ||
|
|
ac0f088ee3 | ||
|
|
930c98caec | ||
|
|
5894272149 | ||
|
|
0b39ab0212 | ||
|
|
e905737d5b | ||
|
|
4517fe354b | ||
|
|
d764e5f9d2 | ||
|
|
7fef2ed5e2 | ||
|
|
c36cd66e36 | ||
|
|
1363c3137e | ||
|
|
e55f2462e5 | ||
|
|
c88de0250f | ||
|
|
8d59065c49 | ||
|
|
ec622aba55 |
5
.cursor/rules/external-terminal.mdc
Normal file
5
.cursor/rules/external-terminal.mdc
Normal 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
3
.gitignore
vendored
@@ -46,4 +46,5 @@ streamyfin-4fec1-firebase-adminsdk.json
|
||||
.env.local
|
||||
*.aab
|
||||
/version-backup-*
|
||||
bun.lockb
|
||||
bun.lockb
|
||||
modules/background-downloader/android/build/*
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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={() =>
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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
251
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 "...";
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
5
global.css
Normal file
@@ -0,0 +1,5 @@
|
||||
@import "tailwindcss/theme.css" layer(theme);
|
||||
@import "tailwindcss/preflight.css" layer(base);
|
||||
@import "tailwindcss/utilities.css";
|
||||
|
||||
@import "nativewind/theme";
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
258
modules/background-downloader/README.md
Normal file
258
modules/background-downloader/README.md
Normal 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
|
||||
46
modules/background-downloader/android/build.gradle
Normal file
46
modules/background-downloader/android/build.gradle
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -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?
|
||||
)
|
||||
|
||||
98
modules/background-downloader/example.ts
Normal file
98
modules/background-downloader/example.ts
Normal 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();
|
||||
}
|
||||
12
modules/background-downloader/expo-module.config.json
Normal file
12
modules/background-downloader/expo-module.config.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
91
modules/background-downloader/index.ts
Normal file
91
modules/background-downloader/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
import type { BackgroundDownloaderModuleType } from "./BackgroundDownloader.types";
|
||||
|
||||
const BackgroundDownloaderModule: BackgroundDownloaderModuleType =
|
||||
requireNativeModule("BackgroundDownloader");
|
||||
|
||||
export default BackgroundDownloaderModule;
|
||||
@@ -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
3
nativewind-env.d.ts
vendored
@@ -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.
|
||||
|
||||
18
package.json
18
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 xcode‑js 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
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
188
providers/Downloads/MIGRATION.md
Normal file
188
providers/Downloads/MIGRATION.md
Normal 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
|
||||
|
||||
149
providers/Downloads/README.md
Normal file
149
providers/Downloads/README.md
Normal 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
|
||||
272
providers/Downloads/additionalDownloads.ts
Normal file
272
providers/Downloads/additionalDownloads.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
189
providers/Downloads/database.ts
Normal file
189
providers/Downloads/database.ts
Normal 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: {} });
|
||||
}
|
||||
99
providers/Downloads/fileOperations.ts
Normal file
99
providers/Downloads/fileOperations.ts
Normal 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);
|
||||
}
|
||||
318
providers/Downloads/hooks/useDownloadEventHandlers.ts
Normal file
318
providers/Downloads/hooks/useDownloadEventHandlers.ts
Normal 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]);
|
||||
}
|
||||
288
providers/Downloads/hooks/useDownloadOperations.ts
Normal file
288
providers/Downloads/hooks/useDownloadOperations.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
261
providers/Downloads/hooks/useDownloadSpeedCalculator.ts
Normal file
261
providers/Downloads/hooks/useDownloadSpeedCalculator.ts
Normal 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);
|
||||
}
|
||||
47
providers/Downloads/index.ts
Normal file
47
providers/Downloads/index.ts
Normal 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";
|
||||
74
providers/Downloads/notifications.ts
Normal file
74
providers/Downloads/notifications.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
33
providers/Downloads/utils.ts
Normal file
33
providers/Downloads/utils.ts
Normal 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:\/\//, "");
|
||||
}
|
||||
41
scripts/patch-bottom-tabs.js
Normal file
41
scripts/patch-bottom-tabs.js
Normal 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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
51
utils/downloadStats.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user