Compare commits

..

1 Commits

Author SHA1 Message Date
Fredrik Burmester
e6dd433821 wip 2025-11-14 20:05:39 +01:00
96 changed files with 706 additions and 2197 deletions

View File

@@ -156,7 +156,7 @@ jobs:
build-ios-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
runs-on: macos-26
runs-on: macos-15
name: 🍎 Build iOS IPA (Phone)
permissions:
contents: read
@@ -191,11 +191,6 @@ jobs:
- name: 🛠️ Generate project files
run: bun run prebuild
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
with:
xcode-version: "26.0.1"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@main
with:
@@ -224,7 +219,7 @@ jobs:
# Disabled for now - uncomment when ready to build iOS TV
# build-ios-tv:
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
# runs-on: macos-26
# runs-on: macos-15
# name: 🍎 Build iOS IPA (TV)
# permissions:
# contents: read
@@ -259,11 +254,6 @@ jobs:
# - name: 🛠️ Generate project files
# run: bun run prebuild:tv
#
# - name: 🔧 Setup Xcode
# uses: maxim-lobanov/setup-xcode@v1
# with:
# xcode-version: '26.0.1'
#
# - name: 🏗️ Setup EAS
# uses: expo/expo-github-action@main
# with:

View File

@@ -31,13 +31,13 @@ jobs:
fetch-depth: 0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/autobuild@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2

View File

@@ -57,7 +57,7 @@ jobs:
fetch-depth: 0
- name: Dependency Review
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
with:
fail-on-severity: high
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}

177
.vscode/settings.json vendored
View File

@@ -1,25 +1,178 @@
{
// ==========================================
// FORMATTING & LINTING
// ==========================================
// Biome as default formatter
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit"
"editor.formatOnPaste": true,
"editor.formatOnType": false,
// Language-specific formatters
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"editor.formatOnSaveMode": "file"
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[swift]": {
"editor.insertSpaces": true,
"editor.tabSize": 2
},
// ==========================================
// TYPESCRIPT & JAVASCRIPT
// ==========================================
// TypeScript performance optimizations
"typescript.preferences.includePackageJsonAutoImports": "auto",
"typescript.suggest.autoImports": true,
"typescript.updateImportsOnFileMove.enabled": "always",
"typescript.preferences.preferTypeOnlyAutoImports": true,
"typescript.preferences.importModuleSpecifier": "relative",
"typescript.preferences.includeCompletionsForImportStatements": true,
"typescript.preferences.includeCompletionsWithSnippetText": true,
// JavaScript settings
"javascript.preferences.importModuleSpecifier": "relative",
"javascript.suggest.autoImports": true,
"javascript.updateImportsOnFileMove.enabled": "always",
// ==========================================
// REACT NATIVE & EXPO
// ==========================================
// File associations for React Native
"files.associations": {
"*.expo.ts": "typescript",
"*.expo.tsx": "typescriptreact",
"*.expo.js": "javascript",
"*.expo.jsx": "javascriptreact",
"metro.config.js": "javascript",
"babel.config.js": "javascript",
"app.config.js": "javascript",
"eas.json": "jsonc"
},
// React Native specific settings
"emmet.includeLanguages": {
"typescriptreact": "html",
"javascriptreact": "html"
},
"emmet.triggerExpansionOnTab": true,
// Exclude build directories from search
"search.exclude": {
"**/node_modules": true
},
// ==========================================
// EDITOR PERFORMANCE & UX
// ==========================================
// Performance optimizations
"editor.largeFileOptimizations": true,
"files.watcherExclude": {
"**/.git/objects/**": true,
"**/.git/subtree-cache/**": true,
"**/node_modules/**": true,
"**/.expo/**": true,
"**/ios/**": true,
"**/android/**": true,
"**/build/**": true,
"**/dist/**": true
},
// Better editor behavior
"editor.suggestSelection": "first",
"editor.quickSuggestions": {
"strings": true,
"comments": true,
"other": true
},
"editor.snippetSuggestions": "top",
"editor.tabCompletion": "on",
"editor.wordBasedSuggestions": "off",
// ==========================================
// TERMINAL & DEVELOPMENT
// ==========================================
// Terminal settings for Bun (Windows-specific)
"terminal.integrated.profiles.windows": {
"Command Prompt": {
"path": "C:\\Windows\\System32\\cmd.exe",
"env": {
"PATH": "${env:PATH};./node_modules/.bin"
}
}
},
// ==========================================
// WORKSPACE & NAVIGATION
// ==========================================
// Better workspace navigation
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.expand": false,
"explorer.fileNesting.patterns": {
"*.ts": "${capture}.js",
"*.tsx": "${capture}.js",
"*.js": "${capture}.js,${capture}.js.map,${capture}.min.js,${capture}.d.ts",
"*.jsx": "${capture}.js",
"package.json": "package-lock.json,yarn.lock,bun.lock,bun.lockb,.yarnrc,.yarnrc.yml",
"tsconfig.json": "tsconfig.*.json",
".env": ".env.*",
"app.json": "app.config.js,eas.json,expo-env.d.ts",
"README.md": "LICENSE.txt,SECURITY.md,CODE_OF_CONDUCT.md,CONTRIBUTING.md"
},
// Better breadcrumbs and navigation
"breadcrumbs.enabled": true,
"outline.showVariables": true,
"outline.showConstants": true,
// ==========================================
// GIT & VERSION CONTROL
// ==========================================
// Git integration
"git.autofetch": true,
"git.enableSmartCommit": true,
"git.confirmSync": false,
"git.ignoreLimitWarning": true,
// ==========================================
// CODE QUALITY & ERRORS
// ==========================================
// Better error detection
"typescript.validate.enable": true,
"javascript.validate.enable": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
},
// Problem matcher for better error display
"typescript.tsc.autoDetect": "on"
}

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.48.0",
"version": "0.47.1",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -29,12 +29,16 @@
},
"supportsTablet": true,
"bundleIdentifier": "com.fredrikburmester.streamyfin",
"icon": "./assets/images/icon-ios-liquid-glass.icon",
"icon": {
"dark": "./assets/images/icon-ios-plain.png",
"light": "./assets/images/icon-ios-light.png",
"tinted": "./assets/images/icon-ios-tinted.png"
},
"appleTeamId": "MWD5K362T8"
},
"android": {
"jsEngine": "hermes",
"versionCode": 85,
"versionCode": 84,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",

View File

@@ -171,7 +171,7 @@ export default function page() {
contentInsetAdjustmentBehavior='automatic'
>
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
<View className='mb-4 flex flex-col space-y-4 px-4'>
<View className='mb-4 flex flex-col gap-y-4 px-4'>
{/* Queue card - hidden */}
{/* <View className='bg-neutral-900 p-4 rounded-2xl'>
<Text className='text-lg font-bold'>
@@ -180,7 +180,7 @@ export default function page() {
<Text className='text-xs opacity-70 text-red-600'>
{t("home.downloads.queue_hint")}
</Text>
<View className='flex flex-col space-y-2 mt-2'>
<View className='flex flex-col gap-y-2 mt-2'>
{queue.map((q, index) => (
<TouchableOpacity
onPress={() =>

View File

@@ -20,7 +20,7 @@ export default function page() {
return (
<View
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 space-y-4" : "py-16 space-y-8"} px-4`}
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 gap-y-4" : "py-16 gap-y-8"} px-4`}
>
<View>
<Text className='text-3xl font-bold text-center mb-2'>

View File

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

View File

@@ -1,9 +1,9 @@
import { File, Paths } from "expo-file-system";
import { useNavigation } from "expo-router";
import type * as SharingType from "expo-sharing";
import * as Sharing from "expo-sharing";
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { ScrollView, TouchableOpacity, View } from "react-native";
import Collapsible from "react-native-collapsible";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
@@ -11,11 +11,6 @@ import { FilterButton } from "@/components/filters/FilterButton";
import { Loader } from "@/components/Loader";
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
// Conditionally import expo-sharing only on non-TV platforms
const Sharing = Platform.isTV
? null
: (require("expo-sharing") as typeof SharingType);
export default function Page() {
const navigation = useNavigation();
const { logs } = useLog();
@@ -54,8 +49,6 @@ export default function Page() {
// Sharing it as txt while its formatted allows us to share it with many more applications
const share = useCallback(async () => {
if (!Sharing) return;
const logsFile = new File(Paths.document, "logs.txt");
setLoading(true);
@@ -67,11 +60,9 @@ export default function Page() {
} finally {
setLoading(false);
}
}, [filteredLogs, Sharing]);
}, [filteredLogs]);
useEffect(() => {
if (Platform.isTV) return;
navigation.setOptions({
headerRight: () =>
loading ? (
@@ -91,7 +82,7 @@ export default function Page() {
paddingTop: insets.top + 48,
}}
>
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
<View className='flex flex-row justify-end py-2 px-4 gap-x-2'>
<FilterButton
id={orderFilterId}
queryKey='log'
@@ -115,7 +106,7 @@ export default function Page() {
/>
</View>
<ScrollView className='pb-4 px-4'>
<View className='flex flex-col space-y-2'>
<View className='flex flex-col gap-y-2'>
{filteredLogs?.map((log, index) => (
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
<TouchableOpacity
@@ -155,7 +146,7 @@ export default function Page() {
</Text>
)}
<Collapsible collapsed={!state[log.timestamp]}>
<View className='mt-2 flex flex-col space-y-2'>
<View className='mt-2 flex flex-col gap-y-2'>
<ScrollView className='rounded-xl' style={codeBlockStyle}>
<Text>{JSON.stringify(log.data, null, 2)}</Text>
</ScrollView>

View File

@@ -1,4 +1,3 @@
import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
import { useLocalSearchParams } from "expo-router";
import type React from "react";
import { useEffect } from "react";
@@ -21,11 +20,7 @@ const Page: React.FC = () => {
const { offline } = useLocalSearchParams() as { offline?: string };
const isOffline = offline === "true";
const { data: item, isError } = useItemQuery(id, false, undefined, [
ItemFields.MediaSources,
ItemFields.MediaSourceCount,
ItemFields.MediaStreams,
]);
const { data: item, isError } = useItemQuery(id, isOffline);
const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => {
@@ -85,7 +80,7 @@ const Page: React.FC = () => {
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
<View className='flex flex-row space-x-1 mb-8'>
<View className='flex flex-row gap-x-1 mb-8'>
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />

View File

@@ -131,11 +131,9 @@ const Page: React.FC = () => {
mediaId: Number(result.id!),
mediaType: mediaType!,
tvdbId: details?.externalIds?.tvdbId,
...(mediaType === MediaType.TV && {
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
?.map?.((s) => s.seasonNumber),
}),
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
?.map?.((s) => s.seasonNumber),
};
if (hasAdvancedRequestPermission) {
@@ -241,7 +239,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'>
@@ -284,7 +282,7 @@ const Page: React.FC = () => {
</Button>
) : (
details?.mediaInfo?.jellyfinMediaId && (
<View className='flex flex-row space-x-2 mt-4'>
<View className='flex flex-row gap-x-2 mt-4'>
{!Platform.isTV && (
<Button
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
@@ -384,13 +382,13 @@ const Page: React.FC = () => {
onDismiss={handleIssueModalDismiss}
>
<BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View className='flex flex-col gap-y-4 px-4 pb-8 pt-2'>
<View>
<Text className='font-bold text-2xl text-neutral-100'>
{t("jellyseerr.whats_wrong")}
</Text>
</View>
<View className='flex flex-col space-y-2 items-start'>
<View className='flex flex-col gap-y-2 items-start'>
<View className='flex flex-col w-full'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.issue_type")}

View File

@@ -26,7 +26,7 @@ export default function page() {
paddingTop: 8,
}}
>
<View className='flex flex-col space-y-2'>
<View className='flex flex-col gap-y-2'>
<ScrollingCollectionList
queryKey={["livetv", "recommended"]}
title={t("live_tv.on_now")}

View File

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

View File

@@ -94,7 +94,7 @@ const page: React.FC = () => {
item &&
allEpisodes &&
allEpisodes.length > 0 && (
<View className='flex flex-row items-center space-x-2'>
<View className='flex flex-row items-center gap-x-2'>
<AddToFavorites item={item} />
{!Platform.isTV && (
<DownloadItems

View File

@@ -418,7 +418,7 @@ export default function search() {
</Text>
</View>
) : debouncedSearch.length === 0 ? (
<View className='mt-4 flex flex-col items-center space-y-2'>
<View className='mt-4 flex flex-col items-center gap-y-2'>
{exampleSearches.map((e) => (
<TouchableOpacity
onPress={() => {

View File

@@ -43,7 +43,6 @@ import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log";
import { generateDeviceProfile } from "@/utils/profiles/native";
@@ -674,30 +673,7 @@ export default function page() {
);
}, []);
// Prepare metadata for iOS native media controls
const nowPlayingMetadata = useMemo(() => {
if (!item || !api) return undefined;
const artworkUri = getPrimaryImageUrl({
api,
item,
quality: 90,
width: 500,
});
return {
title: item.Name || "",
artist:
item.Type === "Episode"
? item.SeriesName || ""
: item.AlbumArtist || "",
albumTitle:
item.Type === "Episode" && item.SeasonName
? item.SeasonName
: undefined,
artworkUri: artworkUri || undefined,
};
}, [item, api]);
console.log("Debug: component render"); // Uncomment to debug re-renders
// Show error UI first, before checking loading/missingdata
if (itemStatus.isError || streamStatus.isError) {
@@ -755,7 +731,6 @@ export default function page() {
initOptions,
}}
style={{ width: "100%", height: "100%" }}
nowPlayingMetadata={nowPlayingMetadata}
onVideoProgress={onProgress}
progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged}

View File

@@ -1,19 +1,17 @@
import { Link, Stack } from "expo-router";
import { StyleSheet } from "react-native";
import { ThemedText } from "@/components/ThemedText";
import { ThemedView } from "@/components/ThemedView";
import { StyleSheet, View } from "react-native";
import { Text } from "../components/common/Text";
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: "Oops!" }} />
<ThemedView style={styles.container}>
<ThemedText type='title'>This screen doesn't exist.</ThemedText>
<View style={styles.container}>
<Text>This screen doesn't exist.</Text>
<Link href={"/home"} style={styles.link}>
<ThemedText type='link'>Go to home screen!</ThemedText>
<Text>Go to home screen!</Text>
</Link>
</ThemedView>
</View>
</>
);
}

View File

@@ -34,6 +34,7 @@ import { storage } from "@/utils/mmkv";
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
import "@/global.css";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { getLocales } from "expo-localization";
import type { EventSubscription } from "expo-modules-core";

View File

@@ -42,14 +42,14 @@ const Login: React.FC = () => {
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [serverURL, setServerURL] = useState<string>(_apiUrl || "");
const [serverURL, setServerURL] = useState<string>(_apiUrl);
const [serverName, setServerName] = useState<string>("");
const [credentials, setCredentials] = useState<{
username: string;
password: string;
}>({
username: _username || "",
password: _password || "",
username: _username,
password: _password,
});
/**
@@ -264,12 +264,6 @@ const Login: React.FC = () => {
onChangeText={(text: string) =>
setCredentials({ ...credentials, username: text })
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.username) {
setCredentials({ ...credentials, username: newValue });
}
}}
value={credentials.username}
keyboardType='default'
returnKeyType='done'
@@ -278,8 +272,6 @@ const Login: React.FC = () => {
clearButtonMode='while-editing'
maxLength={500}
extraClassName='mb-4'
autoFocus={false}
blurOnSubmit={true}
/>
{/* Password */}
@@ -288,12 +280,6 @@ const Login: React.FC = () => {
onChangeText={(text: string) =>
setCredentials({ ...credentials, password: text })
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.password) {
setCredentials({ ...credentials, password: newValue });
}
}}
value={credentials.password}
secureTextEntry
keyboardType='default'
@@ -303,17 +289,10 @@ const Login: React.FC = () => {
clearButtonMode='while-editing'
maxLength={500}
extraClassName='mb-4'
autoFocus={false}
blurOnSubmit={true}
/>
<View className='mt-4'>
<Button
onPress={handleLogin}
disabled={!credentials.username.trim()}
>
{t("login.login_button")}
</Button>
<Button onPress={handleLogin}>{t("login.login_button")}</Button>
</View>
<View className='mt-3'>
<Button
@@ -355,8 +334,6 @@ const Login: React.FC = () => {
autoCapitalize='none'
textContentType='URL'
maxLength={500}
autoFocus={false}
blurOnSubmit={true}
/>
{/* Full-width primary button */}
@@ -400,7 +377,7 @@ const Login: React.FC = () => {
{api?.basePath ? (
<View className='flex flex-col flex-1 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 ? (
<>
@@ -417,12 +394,6 @@ const Login: React.FC = () => {
onChangeText={(text) =>
setCredentials({ ...credentials, username: text })
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.username) {
setCredentials({ ...credentials, username: newValue });
}
}}
value={credentials.username}
keyboardType='default'
returnKeyType='done'
@@ -439,12 +410,6 @@ const Login: React.FC = () => {
onChangeText={(text) =>
setCredentials({ ...credentials, password: text })
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.password) {
setCredentials({ ...credentials, password: newValue });
}
}}
value={credentials.password}
secureTextEntry
keyboardType='default'
@@ -458,7 +423,6 @@ const Login: React.FC = () => {
<Button
onPress={handleLogin}
loading={loading}
disabled={!credentials.username.trim()}
className='flex-1 mr-2'
>
{t("login.login_button")}

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
<g id="g10">
<path id="path88" d="M3547.01,1831.49C3493.38,1822.66 3262.53,1779.28 2992.01,1820.24C2424.16,1906.21 2154.85,2275.8 1882,2420.24C1473.31,2636.6 1060.97,2644.95 832,2592.03L832,1445.92C832,1321.76 863.078,1198.06 925.307,1090.27C1057.09,862.011 1323.38,718.405 1586.6,736.145C1695.48,743.482 1801.3,777.735 1895.64,832.199L3357.51,1676.21C3424.47,1714.87 3482.92,1761.76 3532.01,1815.41L3547.01,1831.49Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(149,41,235);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(98,22,247);stop-opacity:1"/></linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
<g id="g10">
<path id="path66" d="M3357.51,2903.64L1895.64,3747.65C1670.29,3877.76 1412.33,3877.76 1186.98,3747.65C961.629,3617.55 832.648,3394.14 832.648,3133.93L832.648,1445.92C832.648,1185.71 961.629,962.305 1186.98,832.199C1412.33,702.094 1670.29,702.094 1895.64,832.199L3357.51,1676.21C3582.86,1806.31 3711.84,2029.71 3711.84,2289.93C3711.84,2550.14 3582.86,2773.54 3357.51,2903.64ZM1721.48,3213.68L3098.31,2454.7C3163.9,2418.55 3193.45,2364.85 3193.45,2289.93C3193.45,2215 3163.93,2161.32 3098.31,2125.15L1721.48,1366.18C1655.87,1330.01 1596.09,1328.72 1531.21,1366.18C1466.34,1403.63 1436.08,1456.03 1436.08,1530.96L1436.08,3048.89C1436.08,3123.77 1466.35,3176.23 1531.21,3213.68C1596.08,3251.11 1655.89,3249.83 1721.48,3213.68" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(188,74,241);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(227,105,219);stop-opacity:1"/></linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g>
<g id="g10">
<path id="path88" d="M0,319.909L0,234C17.667,234.844 138.649,236.708 195,190C220.441,168.912 271.21,169.515 294.001,178.788C332.576,194.487 378.643,259.549 360,270.644C353.455,277.797 345.662,284.049 336.734,289.204L141.818,401.738C129.24,409 115.13,413.567 100.613,414.546C65.517,416.911 30.012,397.763 12.441,367.329C4.144,352.957 0,336.464 0,319.909Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(225,102,222);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(204,88,233);stop-opacity:1"/></linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
<g id="g10">
<path id="path28" d="M1427.29,1523.37C1427.29,1447.7 1457.85,1394.77 1523.38,1356.94C1588.91,1319.11 1649.28,1320.41 1715.55,1356.94L3106.14,2123.5C3172.42,2160.03 3202.24,2214.25 3202.24,2289.93C3202.24,2365.6 3172.39,2419.83 3106.14,2456.35L1715.55,3222.91C1649.31,3259.43 1588.89,3260.73 1523.38,3222.91C1457.87,3185.1 1427.29,3132.11 1427.29,3056.48L1427.29,1523.37" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.17673e-13,-1921.74,1921.74,1.17673e-13,2314.76,3250.79)"><stop offset="0" style="stop-color:rgb(93,17,250);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(143,40,236);stop-opacity:1"/></linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,184 +0,0 @@
{
"fill": {
"solid": "display-p3:0.18039,0.18039,0.18039,1.00000"
},
"groups": [
{
"blur-material": 0.3,
"layers": [
{
"fill-specializations": [
{
"value": "none"
},
{
"appearance": "tinted",
"value": {
"automatic-gradient": "display-p3:0.76482,0.76482,0.76482,0.84903"
}
}
],
"glass": true,
"hidden": false,
"image-name": "streamyfin_logo_layer1.svg",
"name": "streamyfin_logo_layer1"
}
],
"opacity": 1,
"position": {
"scale": 1.7,
"translation-in-points": [30, 0]
},
"shadow": {
"kind": "none",
"opacity": 1
},
"specular": true,
"translucency": {
"enabled": true,
"value": 0.6
}
},
{
"blend-mode": "normal",
"blur-material": 0.8,
"hidden": false,
"layers": [
{
"blend-mode": "normal",
"fill-specializations": [
{
"value": "none"
},
{
"appearance": "tinted",
"value": {
"automatic-gradient": "gray:0.75000,1.00000"
}
}
],
"hidden": false,
"image-name": "streamyfin_logo_layer2.svg",
"name": "streamyfin_logo_layer2",
"opacity": 1,
"position": {
"scale": 1,
"translation-in-points": [0, 0]
}
}
],
"lighting": "individual",
"name": "Group",
"opacity": 1,
"position": {
"scale": 1.7,
"translation-in-points": [30, -0.01613253252572302]
},
"shadow": {
"kind": "layer-color",
"opacity": 0.35
},
"specular": true,
"translucency-specializations": [
{
"value": {
"enabled": true,
"value": 0.5
}
},
{
"appearance": "tinted",
"value": {
"enabled": true,
"value": 0.8
}
}
]
},
{
"blend-mode": "normal",
"blur-material": 0.5,
"layers": [
{
"fill-specializations": [
{
"appearance": "tinted",
"value": {
"automatic-gradient": "gray:0.29000,1.00000"
}
}
],
"glass": true,
"hidden": false,
"image-name": "streamyfin_logo_layer3.svg",
"name": "streamyfin_logo_layer3",
"opacity": 0.9
}
],
"name": "Group",
"opacity": 0.8,
"position": {
"scale": 1.7,
"translation-in-points": [30, 0]
},
"shadow": {
"kind": "none",
"opacity": 0.5
},
"specular": true,
"translucency": {
"enabled": true,
"value": 0.7
}
},
{
"blur-material": 0.5,
"hidden": false,
"layers": [
{
"glass": true,
"hidden-specializations": [
{
"value": false
},
{
"appearance": "tinted",
"value": true
}
],
"image-name": "streamyfin_logo_layer4.svg",
"name": "streamyfin_logo_layer4",
"opacity-specializations": [
{
"value": 1
},
{
"appearance": "tinted",
"value": 0
}
]
}
],
"lighting": "combined",
"name": "Group",
"opacity": 0.9,
"position": {
"scale": 1.7,
"translation-in-points": [30, 0]
},
"shadow": {
"kind": "neutral",
"opacity": 0.5
},
"specular": false,
"translucency": {
"enabled": true,
"value": 0.5
}
}
],
"supported-platforms": {
"circles": ["watchOS"],
"squares": "shared"
}
}

View File

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

217
bun.lock
View File

@@ -46,18 +46,19 @@
"i18next": "^25.0.0",
"jotai": "^2.12.5",
"lodash": "^4.17.21",
"nativewind": "^2.0.11",
"nativewind": "^5.0.0-preview.2",
"patch-package": "^8.0.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "16.0.0",
"react-i18next": "^15.4.0",
"react-native": "npm:react-native-tvos@0.81.5-1",
"react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "^1.0.2",
"react-native-circular-progress": "^1.4.1",
"react-native-collapsible": "^1.6.2",
"react-native-country-flag": "^2.0.2",
"react-native-device-info": "^15.0.0",
"react-native-css": "^3.0.1",
"react-native-device-info": "^14.0.4",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-google-cast": "^4.9.1",
@@ -80,31 +81,33 @@
"react-native-web": "^0.21.0",
"react-native-worklets": "0.5.1",
"sonner-native": "^0.21.0",
"tailwindcss": "3.3.2",
"tailwindcss": "^4.1.17",
"use-debounce": "^10.0.4",
"zod": "^4.1.3",
},
"devDependencies": {
"@babel/core": "7.28.5",
"@biomejs/biome": "2.3.5",
"@react-native-community/cli": "20.0.2",
"@react-native-tvos/config-tv": "0.1.4",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.20",
"@babel/core": "^7.20.0",
"@biomejs/biome": "^2.3.5",
"@react-native-community/cli": "^20.0.0",
"@react-native-tvos/config-tv": "^0.1.1",
"@tailwindcss/postcss": "^4.1.17",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.15",
"@types/react": "~19.1.10",
"@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0",
"expo-doctor": "1.17.11",
"husky": "9.1.7",
"lint-staged": "16.2.6",
"@types/react-test-renderer": "^19.0.0",
"cross-env": "^10.0.0",
"expo-doctor": "^1.17.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.5",
"postcss": "^8.5.6",
"postinstall-postinstall": "^2.1.0",
"react-test-renderer": "19.1.1",
"typescript": "5.9.3",
"typescript": "~5.9.2",
},
},
},
"overrides": {
"expo-constants": "~18.0.10",
"expo-task-manager": "~14.0.8",
"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=="],
@@ -133,7 +136,7 @@
"@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="],
"@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=="],
@@ -577,6 +580,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.17", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.17" } }, "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.17", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.17", "@tailwindcss/oxide-darwin-arm64": "4.1.17", "@tailwindcss/oxide-darwin-x64": "4.1.17", "@tailwindcss/oxide-freebsd-x64": "4.1.17", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", "@tailwindcss/oxide-linux-x64-musl": "4.1.17", "@tailwindcss/oxide-wasm32-wasi": "4.1.17", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" } }, "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.17", "", { "os": "android", "cpu": "arm64" }, "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17", "", { "os": "linux", "cpu": "arm" }, "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.17", "", { "dependencies": { "@emnapi/core": "^1.6.0", "@emnapi/runtime": "^1.6.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.7", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.17", "", { "os": "win32", "cpu": "x64" }, "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw=="],
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.17", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.17", "@tailwindcss/oxide": "4.1.17", "postcss": "^8.4.41", "tailwindcss": "4.1.17" } }, "sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.7", "", {}, "sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.7", "", { "dependencies": { "@tanstack/query-core": "5.90.7" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ=="],
@@ -687,6 +720,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=="],
@@ -713,7 +748,7 @@
"babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.5", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg=="],
"babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
"babel-plugin-react-compiler": ["babel-plugin-react-compiler@19.1.0-rc.1-rc-af1b7da-20250421", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-E3kaokBhWDLf7ZD8fuYjYn0ZJHYZ+3EHtAWCdX2hl4lpu1z9S/Xr99sxhx2bTCVB41oIesz9FtM8f4INsrZaOw=="],
"babel-plugin-react-native-web": ["babel-plugin-react-native-web@0.21.2", "", {}, "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA=="],
@@ -739,8 +774,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=="],
@@ -777,16 +810,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.30001754", "", {}, "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg=="],
"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=="],
@@ -817,12 +844,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=="],
@@ -837,6 +868,8 @@
"core-js-compat": ["core-js-compat@3.46.0", "", { "dependencies": { "browserslist": "^4.26.3" } }, "sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law=="],
"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=="],
@@ -847,22 +880,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.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="],
@@ -895,12 +920,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=="],
@@ -925,6 +946,8 @@
"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=="],
@@ -1051,8 +1074,6 @@
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
"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=="],
@@ -1131,7 +1152,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=="],
@@ -1199,8 +1220,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=="],
@@ -1267,7 +1286,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=="],
@@ -1307,31 +1326,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=="],
@@ -1357,6 +1372,8 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="],
"marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="],
@@ -1431,7 +1448,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.2", "", { "dependencies": { "tailwindcss-safe-area": "^1.1.0" }, "peerDependencies": { "react-native-css": "^3.0.1", "tailwindcss": ">4.1.11" } }, "sha512-rTNrwFIwl/n2VH7KPvsZj/NdvKf+uGHF4NYtPamr5qG2eTYGT8B8VeyCPfYf/xUskpWOLJVqVEXaFO/vuIDEdw=="],
"negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="],
@@ -1465,8 +1482,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=="],
@@ -1527,8 +1542,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=="],
@@ -1541,24 +1554,10 @@
"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=="],
"pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="],
"pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
@@ -1605,7 +1604,7 @@
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
"react-i18next": ["react-i18next@16.0.0", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 25.5.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-JQ+dFfLnFSKJQt7W01lJHWRC0SX7eDPobI+MSTJ3/gP39xH2g33AuTE7iddAfXYHamJdAeMGM0VFboPaD3G68Q=="],
"react-i18next": ["react-i18next@15.7.4", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.4.0", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw=="],
"react-is": ["react-is@19.2.0", "", {}, "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA=="],
@@ -1621,7 +1620,9 @@
"react-native-country-flag": ["react-native-country-flag@2.0.2", "", {}, "sha512-5LMWxS79ZQ0Q9ntYgDYzWp794+HcQGXQmzzZNBR1AT7z5HcJHtX7rlk8RHi7RVzfp5gW6plWSZ4dKjRpu/OafQ=="],
"react-native-device-info": ["react-native-device-info@15.0.1", "", { "peerDependencies": { "react-native": "*" } }, "sha512-U5waZRXtT3l1SgZpZMlIvMKPTkFZPH8W7Ks6GrJhdH723aUIPxjVer7cRSij1mvQdOAAYFJV/9BDzlC8apG89A=="],
"react-native-css": ["react-native-css@3.0.1", "", { "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", "lightningcss": ">=1.27.0", "react": ">=19", "react-native": ">=0.81" } }, "sha512-sE/Qp2p+UaV9+W6T+GwsRH5sIynaTfhM2Khn+tN1q1YKYq1STVyAWyC+0i6ac4mFAO/FxQ/a03BDmrrt48qiuQ=="],
"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=="],
@@ -1679,14 +1680,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=="],
@@ -1845,7 +1842,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.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="],
"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.2", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg=="],
@@ -1871,8 +1872,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=="],
@@ -2001,16 +2000,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=="],
@@ -2119,6 +2110,18 @@
"@react-navigation/native-stack/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.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
"@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.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],
"@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=="],
@@ -2131,14 +2134,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=="],
"babel-preset-expo/babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
"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=="],
"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=="],
@@ -2157,8 +2158,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=="],
@@ -2215,10 +2214,6 @@
"metro-transform-worker/metro-source-map": ["metro-source-map@0.83.2", "", { "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.2", "nullthrows": "^1.1.1", "ob1": "0.83.2", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-5FL/6BSQvshIKjXOennt9upFngq2lFvDakZn5LfauIVq8+L4sxXewIlSTcxAtzbtjAIaXeOSVMtCJ5DdfCt9AA=="],
"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.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"npm-package-arg/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
@@ -2229,10 +2224,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=="],
@@ -2277,8 +2268,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=="],

View File

@@ -132,15 +132,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
return itemsNotDownloaded.length === 0;
}, [items, itemsNotDownloaded]);
const itemsProcesses = useMemo(
() =>
processes?.filter((p) => p?.item?.Id && itemIds.includes(p.item.Id)) ||
[],
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
[processes, itemIds],
);
const progress = useMemo(() => {
if (itemIds.length === 1)
return itemsProcesses.reduce((acc, p) => acc + (p.progress || 0), 0);
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
return (
((itemIds.length -
queue.filter((q) => itemIds.includes(q.item.Id)).length) /
@@ -264,9 +262,9 @@ export const DownloadItems: React.FC<DownloadProps> = ({
closeModal();
// Wait for modal dismiss animation to complete
setTimeout(() => {
requestAnimationFrame(() => {
initiateDownload(...itemsToDownload);
}, 300);
});
} else {
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
@@ -355,7 +353,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
keyboardBlurBehavior='restore'
>
<BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View className='flex flex-col gap-y-4 px-4 pb-8 pt-2'>
<View>
<Text className='font-bold text-2xl text-neutral-100'>
{title}
@@ -367,7 +365,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
})}
</Text>
</View>
<View className='flex flex-col space-y-2 w-full'>
<View className='flex flex-col gap-y-2 w-full'>
<View className='items-start'>
<BitrateSelector
inverted
@@ -406,7 +404,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
/>
</View>
{selectedOptions?.mediaSource && (
<View className='flex flex-col space-y-2 items-start'>
<View className='flex flex-col gap-y-2 items-start'>
<AudioTrackSelector
source={selectedOptions.mediaSource}
onChange={(val) => {

View File

@@ -12,7 +12,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { type Bitrate } from "@/components/BitrateSelector";
import { ItemImage } from "@/components/common/ItemImage";
import { DownloadSingleItem } from "@/components/DownloadItem";
import { MediaSourceButton } from "@/components/MediaSourceButton";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
@@ -24,16 +23,19 @@ import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useItemQuery } from "@/hooks/useItemQuery";
import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { AddToFavorites } from "./AddToFavorites";
import { BitrateSheet } from "./BitRateSheet";
import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { MediaSourceSheet } from "./MediaSourceSheet";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
import { TrackSheet } from "./TrackSheet";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
@@ -68,9 +70,6 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
SelectedOptions | undefined
>(undefined);
// preload media sources
useItemQuery(item.Id, false, undefined, []);
const {
defaultAudioIndex,
defaultBitrate,
@@ -125,10 +124,10 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
)}
</View>
) : (
<View className='flex flex-row items-center space-x-2'>
<View className='flex flex-row items-center gap-x-2'>
<Chromecast.Chromecast width={22} height={22} />
{item.Type !== "Program" && (
<View className='flex flex-row items-center space-x-2'>
<View className='flex flex-row items-center gap-x-2'>
{!Platform.isTV && (
<DownloadSingleItem item={item} size='large' />
)}
@@ -202,27 +201,76 @@ 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 pt-2 mb-2 shrink'>
<View className='flex flex-col px-4 w-full gap-y-2 pt-2 mb-2 shrink'>
<ItemHeader item={item} className='mb-2' />
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
<PlayButton
selectedOptions={selectedOptions}
item={item}
isOffline={isOffline}
colors={itemColors}
/>
<View className='w-1' />
{!isOffline && (
<MediaSourceButton
selectedOptions={selectedOptions}
setSelectedOptions={setSelectedOptions}
item={item}
colors={itemColors}
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
<View className='flex flex-row items-center justify-start w-full h-16 mb-2'>
<BitrateSheet
className='mr-1'
onChange={(val) =>
setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val },
)
}
selected={selectedOptions.bitrate}
/>
)}
</View>
<MediaSourceSheet
className='mr-1'
item={item}
onChange={(val) =>
setSelectedOptions(
(prev) =>
prev && {
...prev,
mediaSource: val,
},
)
}
selected={selectedOptions.mediaSource}
/>
<TrackSheet
className='mr-1'
streamType='Audio'
title={t("item_card.audio")}
source={selectedOptions.mediaSource}
onChange={(val) => {
setSelectedOptions(
(prev) =>
prev && {
...prev,
audioIndex: val,
},
);
}}
selected={selectedOptions.audioIndex}
/>
<TrackSheet
source={selectedOptions.mediaSource}
streamType='Subtitle'
title={t("item_card.subtitles")}
onChange={(val) =>
setSelectedOptions(
(prev) =>
prev && {
...prev,
subtitleIndex: val,
},
)
}
selected={selectedOptions.subtitleIndex}
/>
</View>
)}
<PlayButton
className='grow'
selectedOptions={selectedOptions}
item={item}
isOffline={isOffline}
colors={itemColors}
/>
</View>
{item.Type === "Episode" && (
<SeasonEpisodesCarousel
item={item}
@@ -231,6 +279,9 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
/>
)}
{!isOffline && (
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
)}
<OverviewText text={item.Overview} className='px-4 mb-4' />
{item.Type !== "Program" && (

View File

@@ -15,7 +15,7 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
if (!item)
return (
<View
className='flex flex-col space-y-1.5 w-full items-start h-32'
className='flex flex-col gap-y-1.5 w-full items-start h-32'
{...props}
>
<View className='w-1/3 h-6 bg-neutral-900 rounded' />

View File

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

View File

@@ -1,203 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { useItemQuery } from "@/hooks/useItemQuery";
import { BITRATES } from "./BitRateSheet";
import type { SelectedOptions } from "./ItemContent";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
item: BaseItemDto;
selectedOptions: SelectedOptions;
setSelectedOptions: React.Dispatch<
React.SetStateAction<SelectedOptions | undefined>
>;
colors?: ThemeColors;
}
export const MediaSourceButton: React.FC<Props> = ({
item,
selectedOptions,
setSelectedOptions,
colors,
}: Props) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { data: itemWithSources, isLoading } = useItemQuery(
item.Id,
false,
undefined,
[],
);
const effectiveColors = colors || {
primary: "#7c3aed",
text: "#000000",
};
useEffect(() => {
const firstMediaSource = itemWithSources?.MediaSources?.[0];
if (!firstMediaSource) return;
setSelectedOptions((prev) => {
if (!prev) return prev;
return {
...prev,
mediaSource: firstMediaSource,
};
});
}, [itemWithSources, setSelectedOptions]);
const getMediaSourceDisplayName = useCallback((source: MediaSourceInfo) => {
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
if (source.Name) return source.Name;
if (videoStream?.DisplayTitle) return videoStream.DisplayTitle;
return `Source ${source.Id}`;
}, []);
const audioStreams = useMemo(
() =>
selectedOptions.mediaSource?.MediaStreams?.filter(
(x) => x.Type === "Audio",
) || [],
[selectedOptions.mediaSource],
);
const subtitleStreams = useMemo(
() =>
selectedOptions.mediaSource?.MediaStreams?.filter(
(x) => x.Type === "Subtitle",
) || [],
[selectedOptions.mediaSource],
);
const optionGroups: OptionGroup[] = useMemo(() => {
const groups: OptionGroup[] = [];
// Bitrate group
groups.push({
title: t("item_card.quality"),
options: BITRATES.map((bitrate) => ({
type: "radio" as const,
label: bitrate.key,
value: bitrate,
selected: bitrate.value === selectedOptions.bitrate?.value,
onPress: () =>
setSelectedOptions((prev) => prev && { ...prev, bitrate }),
})),
});
// Media Source group (only if multiple sources)
if (
itemWithSources?.MediaSources &&
itemWithSources.MediaSources.length > 1
) {
groups.push({
title: t("item_card.video"),
options: itemWithSources.MediaSources.map((source) => ({
type: "radio" as const,
label: getMediaSourceDisplayName(source),
value: source,
selected: source.Id === selectedOptions.mediaSource?.Id,
onPress: () =>
setSelectedOptions(
(prev) => prev && { ...prev, mediaSource: source },
),
})),
});
}
// Audio track group
if (audioStreams.length > 0) {
groups.push({
title: t("item_card.audio"),
options: audioStreams.map((stream) => ({
type: "radio" as const,
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
value: stream.Index,
selected: stream.Index === selectedOptions.audioIndex,
onPress: () =>
setSelectedOptions(
(prev) => prev && { ...prev, audioIndex: stream.Index ?? 0 },
),
})),
});
}
// Subtitle track group (with None option)
if (subtitleStreams.length > 0) {
const noneOption = {
type: "radio" as const,
label: t("common.none"),
value: -1,
selected: selectedOptions.subtitleIndex === -1,
onPress: () =>
setSelectedOptions((prev) => prev && { ...prev, subtitleIndex: -1 }),
};
const subtitleOptions = subtitleStreams.map((stream) => ({
type: "radio" as const,
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
value: stream.Index,
selected: stream.Index === selectedOptions.subtitleIndex,
onPress: () =>
setSelectedOptions(
(prev) => prev && { ...prev, subtitleIndex: stream.Index ?? -1 },
),
}));
groups.push({
title: t("item_card.subtitles"),
options: [noneOption, ...subtitleOptions],
});
}
return groups;
}, [
itemWithSources,
selectedOptions,
audioStreams,
subtitleStreams,
getMediaSourceDisplayName,
t,
setSelectedOptions,
]);
const trigger = (
<TouchableOpacity
disabled={!item || isLoading}
onPress={() => setOpen(true)}
className='relative'
>
<View
style={{ backgroundColor: effectiveColors.primary, opacity: 0.7 }}
className='absolute w-12 h-12 rounded-full'
/>
<View className='w-12 h-12 rounded-full z-10 items-center justify-center'>
{isLoading ? (
<ActivityIndicator size='small' color={effectiveColors.text} />
) : (
<Ionicons name='list' size={24} color={effectiveColors.text} />
)}
</View>
</TouchableOpacity>
);
return (
<PlatformDropdown
groups={optionGroups}
trigger={trigger}
title={t("item_card.media_options")}
open={open}
onOpenChange={setOpen}
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
);
};

View File

@@ -184,7 +184,7 @@ const PlatformDropdownComponent = ({
expoUIConfig,
bottomSheetConfig,
}: PlatformDropdownProps) => {
const { showModal, hideModal, isVisible } = useGlobalModal();
const { showModal, hideModal } = useGlobalModal();
// Handle controlled open state for Android
useEffect(() => {
@@ -207,14 +207,6 @@ const PlatformDropdownComponent = ({
}
}, [controlledOpen]);
// Watch for modal dismissal on Android (e.g., swipe down, backdrop tap)
// and sync the controlled open state
useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true && !isVisible) {
controlledOnOpenChange?.(false);
}
}, [isVisible, controlledOpen, controlledOnOpenChange]);
if (Platform.OS === "ios") {
return (
<Host style={expoUIConfig?.hostStyle}>

View File

@@ -358,6 +358,9 @@ export const PlayButton: React.FC<Props> = ({
[startColor.value.text, endColor.value.text],
),
}));
/**
* *********************
*/
// if (Platform.OS === "ios")
// return (
@@ -374,7 +377,7 @@ export const PlayButton: React.FC<Props> = ({
// color={effectiveColors.primary}
// modifiers={[fixedSize()]}
// >
// <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 || 0) -
@@ -411,7 +414,7 @@ export const PlayButton: React.FC<Props> = ({
accessibilityLabel='Play button'
accessibilityHint='Tap to play the media'
onPress={onPress}
className={"relative flex-1"}
className={"relative"}
>
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
<Animated.View
@@ -437,7 +440,7 @@ export const PlayButton: React.FC<Props> = ({
}}
className='flex flex-row items-center justify-center bg-transparent rounded-full z-20 h-12 w-full '
>
<View className='flex flex-row items-center space-x-2'>
<View className='flex flex-row items-center gap-x-2'>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(
(item?.RunTimeTicks || 0) -

View File

@@ -200,7 +200,7 @@ export const PlayButton: React.FC<Props> = ({
}}
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
>
<View className='flex flex-row items-center space-x-2'>
<View className='flex flex-row items-center gap-x-2'>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text>

View File

@@ -21,7 +21,7 @@ interface Props extends ViewProps {
export const Ratings: React.FC<Props> = ({ item, ...props }) => {
if (!item) return null;
return (
<View className='flex flex-row items-center mt-2 space-x-2' {...props}>
<View className='flex flex-row items-center mt-2 gap-x-2' {...props}>
{item.OfficialRating && (
<Badge text={item.OfficialRating} variant='gray' />
)}
@@ -79,7 +79,7 @@ export const JellyserrRatings: React.FC<{
!!result.voteCount ||
(data?.criticsRating && !!data?.criticsScore) ||
(data?.audienceRating && !!data?.audienceScore)) && (
<View className='flex flex-row flex-wrap space-x-1'>
<View className='flex flex-row flex-wrap gap-x-1'>
{data?.criticsRating && !!data?.criticsScore && (
<Badge
text={`${data.criticsScore}%`}

View File

@@ -1,54 +0,0 @@
import { StyleSheet, Text, type TextProps } from "react-native";
export type ThemedTextProps = TextProps & {
lightColor?: string;
darkColor?: string;
type?: "default" | "title" | "defaultSemiBold" | "subtitle" | "link";
};
export function ThemedText({
style,
type = "default",
...rest
}: ThemedTextProps) {
return (
<Text
style={[
{ color: "white" },
type === "default" ? styles.default : undefined,
type === "title" ? styles.title : undefined,
type === "defaultSemiBold" ? styles.defaultSemiBold : undefined,
type === "subtitle" ? styles.subtitle : undefined,
type === "link" ? styles.link : undefined,
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: "600",
},
title: {
fontSize: 32,
fontWeight: "bold",
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: "bold",
},
link: {
lineHeight: 30,
fontSize: 16,
color: "#0a7ea4",
},
});

View File

@@ -1,15 +0,0 @@
import { View, type ViewProps } from "react-native";
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
};
export function ThemedView({
style,
lightColor,
darkColor,
...otherProps
}: ThemedViewProps) {
return <View style={[{ backgroundColor: "black" }, style]} {...otherProps} />;
}

View File

@@ -16,10 +16,7 @@ export function Input(props: InputProps) {
const [isFocused, setIsFocused] = useState(false);
return Platform.isTV ? (
<TouchableOpacity
onPress={() => inputRef?.current?.focus?.()}
activeOpacity={1}
>
<TouchableOpacity onFocus={() => inputRef?.current?.focus?.()}>
<TextInput
ref={inputRef}
className={`

View File

@@ -1,20 +1,16 @@
import { Platform, Text as RNText, type TextProps } from "react-native";
export function Text(props: TextProps) {
const { style, ...otherProps } = props;
export function Text({ className, ...props }: TextProps) {
if (Platform.isTV)
return (
<RNText
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}
/>
<RNText allowFontScaling={false} style={{ color: "white" }} {...props} />
);
return (
<RNText
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}
className={`text-white ${className}`}
{...props}
/>
);
}

View File

@@ -9,11 +9,7 @@ interface ActiveDownloadsProps extends ViewProps {}
export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
const { processes } = useDownload();
// Filter out any invalid processes before rendering
const validProcesses = processes?.filter((p) => p?.item?.Id) || [];
if (validProcesses.length === 0)
if (processes?.length === 0)
return (
<View {...props} className='bg-neutral-900 p-4 rounded-2xl'>
<Text className='text-lg font-bold'>
@@ -31,8 +27,8 @@ export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
{t("home.downloads.active_downloads")}
</Text>
<View className='gap-y-2'>
{validProcesses.map((p: JobStatus) => (
<DownloadCard key={p.id} process={p} />
{processes?.map((p: JobStatus) => (
<DownloadCard key={p.item.Id} process={p} />
))}
</View>
</View>

View File

@@ -51,7 +51,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
};
const eta = useMemo(() => {
if (!process?.estimatedTotalSizeBytes || !process?.bytesDownloaded) {
if (!process.estimatedTotalSizeBytes || !process.bytesDownloaded) {
return null;
}
@@ -66,14 +66,13 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
}
return formatTimeString(secondsRemaining, "s");
}, [process?.id, process?.bytesDownloaded, process?.estimatedTotalSizeBytes]);
}, [process.id, process.bytesDownloaded, process.estimatedTotalSizeBytes]);
const estimatedSize = useMemo(() => {
if (process?.estimatedTotalSizeBytes)
return process.estimatedTotalSizeBytes;
if (process.estimatedTotalSizeBytes) return process.estimatedTotalSizeBytes;
// Calculate from bitrate + duration (only if bitrate value is defined)
if (process?.maxBitrate?.value && process?.item?.RunTimeTicks) {
if (process.maxBitrate.value) {
return estimateDownloadSize(
process.maxBitrate.value,
process.item.RunTimeTicks,
@@ -82,43 +81,32 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
return undefined;
}, [
process?.maxBitrate?.value,
process?.item?.RunTimeTicks,
process?.estimatedTotalSizeBytes,
process.maxBitrate.value,
process.item.RunTimeTicks,
process.estimatedTotalSizeBytes,
]);
const isTranscoding = process?.isTranscoding || false;
const isTranscoding = process.isTranscoding || false;
const downloadedAmount = useMemo(() => {
if (!process?.bytesDownloaded) return null;
if (!process.bytesDownloaded) return null;
return formatBytes(process.bytesDownloaded);
}, [process?.bytesDownloaded]);
}, [process.bytesDownloaded]);
const base64Image = useMemo(() => {
try {
const itemId = process?.item?.Id;
if (!itemId) return undefined;
return storage.getString(itemId);
} catch {
return undefined;
}
}, [process?.item?.Id]);
return storage.getString(process.item.Id!);
}, []);
// Sanitize progress to ensure it's within valid bounds
const sanitizedProgress = useMemo(() => {
if (
typeof process?.progress !== "number" ||
typeof process.progress !== "number" ||
Number.isNaN(process.progress)
) {
return 0;
}
return Math.max(0, Math.min(100, process.progress));
}, [process?.progress]);
// Return null after all hooks have been called
if (!process || !process.item || !process.item.Id) {
return null;
}
}, [process.progress]);
return (
<TouchableOpacity

View File

@@ -51,7 +51,7 @@ export const FilterButton = <T,>({
>
<View
className={`
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
px-3 py-1.5 rounded-full flex flex-row items-center gap-x-1
${
values.length > 0
? "bg-purple-600 border border-purple-700"

View File

@@ -97,19 +97,20 @@ export const Home = () => {
}
navigation.setOptions({
headerLeft: () => (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className='ml-1.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather
name='download'
color={hasDownloads ? Colors.primary : "white"}
size={24}
/>
</TouchableOpacity>
<View className='flex flex-row items-center ml-1.5'>
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather
name='download'
color={hasDownloads ? Colors.primary : "white"}
size={24}
/>
</TouchableOpacity>
</View>
),
});
}, [navigation, router, hasDownloads]);
@@ -470,8 +471,7 @@ export const Home = () => {
}}
>
<View
className='flex flex-col space-y-4'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
className={`flex flex-col gap-y-4 ${Platform.OS === "android" ? "pt-4" : ""}`}
>
{sections.map((section, index) => {
if (section.type === "InfiniteScrollingCollectionList") {

View File

@@ -475,7 +475,7 @@ export const HomeWithCarousel = () => {
paddingTop: 0,
}}
>
<View className='flex flex-col space-y-4'>
<View className='flex flex-col gap-y-4'>
{sections.map((section, index) => {
if (section.type === "InfiniteScrollingCollectionList") {
return (

View File

@@ -84,9 +84,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
return (
<View {...props}>
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
{title}
</Text>
<View className='px-4 mb-2'>
<Text className='text-lg font-bold text-neutral-100'>{title}</Text>
</View>
{isLoading === false && allItems.length === 0 && (
<View className='px-4'>
<Text className='text-neutral-500'>{t("home.no_items")}</Text>

View File

@@ -154,7 +154,7 @@ const DetailFacts: React.FC<
<Facts
title={t("jellyseerr.release_dates")}
facts={filteredReleases?.map?.((r: Release, idx) => (
<View key={idx} className='flex flex-row space-x-2 items-center'>
<View key={idx} className='flex flex-row gap-x-2 items-center'>
{r.type === 3 ? (
// Theatrical
<Ionicons name='ticket' size={16} color='white' />
@@ -189,7 +189,7 @@ const DetailFacts: React.FC<
<Facts
title={t("jellyseerr.production_country")}
facts={details?.productionCountries?.map((n, idx) => (
<View key={idx} className='flex flex-row items-center space-x-2'>
<View key={idx} className='flex flex-row items-center gap-x-2'>
<CountryFlag isoCode={n.iso_3166_1} size={10} />
<Text>{n.name}</Text>
</View>

View File

@@ -118,7 +118,7 @@ const ParallaxSlideShow = <T,>({
}
logo={logo}
>
<View className='flex flex-col space-y-4 px-4'>
<View className='flex flex-col gap-y-4 px-4'>
<View className='flex flex-row justify-between w-full'>
<View className='flex flex-col w-full'>{HeaderContent?.()}</View>
</View>

View File

@@ -144,14 +144,11 @@ const RequestModal = forwardRef<
}, [defaultServiceDetails]);
const seasonTitle = useMemo(() => {
if (!requestBody?.seasons || requestBody.seasons.length === 0) {
return undefined;
}
if (requestBody.seasons.length > 1) {
if (requestBody?.seasons && requestBody?.seasons?.length > 1) {
return t("jellyseerr.season_all");
}
return t("jellyseerr.season_number", {
season_number: requestBody.seasons[0],
season_number: requestBody?.seasons,
});
}, [requestBody?.seasons]);
@@ -305,7 +302,7 @@ const RequestModal = forwardRef<
stackBehavior='push'
>
<BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View className='flex flex-col gap-y-4 px-4 pb-8 pt-2'>
<View>
<Text className='font-bold text-2xl text-neutral-100'>
{t("jellyseerr.advanced")}
@@ -314,7 +311,7 @@ const RequestModal = forwardRef<
<Text className='text-neutral-300'>{seasonTitle}</Text>
)}
</View>
<View className='flex flex-col space-y-2'>
<View className='flex flex-col gap-y-2'>
{defaultService && defaultServiceDetails && users && (
<>
<View className='flex flex-col'>

View File

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

View File

@@ -83,7 +83,7 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
// Android UI
return (
<View className='flex flex-row justify-end items-center space-x-1'>
<View className='flex flex-row justify-end items-center gap-x-1'>
<FilterButton
id={searchFilterId}
queryKey='jellyseerr_search'

View File

@@ -32,7 +32,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
onPress={() =>
item?.SeriesId && router.push(`/series/${item.SeriesId}`)
}
className='flex flex-col space-y-2 w-28'
className='flex flex-col gap-y-2 w-28'
>
<Poster
id={item?.Id}

View File

@@ -128,7 +128,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
}}
/>
{episodes?.length ? (
<View className='flex flex-row items-center space-x-2'>
<View className='flex flex-row items-center gap-x-2'>
<DownloadItems
title={t("item_card.download.download_season")}
className='ml-2'

View File

@@ -105,14 +105,14 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
android_keyboardInputMode='adjustResize'
>
<BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View className='flex flex-col gap-y-4 px-4 pb-8 pt-2'>
<View>
<Text className='font-bold text-2xl text-neutral-100'>
{t("home.settings.quick_connect.quick_connect_title")}
</Text>
</View>
<View className='flex flex-col space-y-2'>
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full space-y-4'>
<View className='flex flex-col gap-y-2'>
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full gap-y-4'>
<Text className='text-neutral-400 text-center'>
{t(
"home.settings.quick_connect.enter_the_quick_connect_code",

View File

@@ -1,212 +0,0 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { forwardRef, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Platform,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import type { StorageLocation } from "@/modules";
import { useSettings } from "@/utils/atoms/settings";
import {
clearStorageLocationsCache,
getAvailableStorageLocations,
} from "@/utils/storage";
interface StorageLocationPickerProps {
onClose: () => void;
}
export const StorageLocationPicker = forwardRef<
BottomSheetModal,
StorageLocationPickerProps
>(({ onClose }, ref) => {
const { t } = useTranslation();
const { settings, updateSettings } = useSettings();
const insets = useSafeAreaInsets();
const [selectedId, setSelectedId] = useState<string | undefined>(
settings.downloadStorageLocation || "internal",
);
const { data: locations, isLoading } = useQuery({
queryKey: ["storageLocations"],
queryFn: getAvailableStorageLocations,
enabled: Platform.OS === "android",
});
const handleSelect = (location: StorageLocation) => {
setSelectedId(location.id);
};
const handleConfirm = () => {
updateSettings({ downloadStorageLocation: selectedId });
clearStorageLocationsCache(); // Clear cache so next download uses new location
toast.success(
t("settings.storage.storage_location_updated", {
defaultValue: "Storage location updated",
}),
);
onClose();
};
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
if (Platform.OS !== "android") {
return null;
}
return (
<BottomSheetModal
ref={ref}
enableDynamicSizing
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
enablePanDownToClose
enableDismissOnClose
>
<BottomSheetScrollView
style={{
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
}}
>
<View className='px-4 pt-2'>
<Text className='text-lg font-semibold mb-1'>
{t("settings.storage.select_storage_location", {
defaultValue: "Select Storage Location",
})}
</Text>
<Text className='text-sm text-neutral-500 mb-4'>
{t("settings.storage.existing_downloads_note", {
defaultValue:
"Existing downloads will remain in their current location",
})}
</Text>
{isLoading ? (
<View className='items-center justify-center py-8'>
<ActivityIndicator size='large' />
<Text className='mt-4 text-neutral-500'>
{t("settings.storage.loading_storage", {
defaultValue: "Loading storage options...",
})}
</Text>
</View>
) : !locations || locations.length === 0 ? (
<View className='items-center justify-center py-8'>
<Text className='text-neutral-500'>
{t("settings.storage.no_storage_found", {
defaultValue: "No storage locations found",
})}
</Text>
</View>
) : (
<>
{locations.map((location) => {
const isSelected = selectedId === location.id;
const freeSpaceGB = (location.freeSpace / 1024 ** 3).toFixed(2);
const totalSpaceGB = (location.totalSpace / 1024 ** 3).toFixed(
2,
);
const usedPercent = (
((location.totalSpace - location.freeSpace) /
location.totalSpace) *
100
).toFixed(0);
return (
<TouchableOpacity
key={location.id}
onPress={() => handleSelect(location)}
className={`p-4 mb-2 rounded-lg ${
isSelected
? "bg-purple-600/20 border border-purple-600"
: "bg-neutral-800"
}`}
>
<View className='flex-row items-center justify-between'>
<View className='flex-1'>
<View className='flex-row items-center'>
<Text className='text-base font-semibold'>
{location.label}
</Text>
{location.type === "external" && (
<View className='ml-2 px-2 py-0.5 bg-blue-600/30 rounded'>
<Text className='text-xs text-blue-400'>
{t("settings.storage.removable", {
defaultValue: "Removable",
})}
</Text>
</View>
)}
</View>
<Text className='text-sm text-neutral-500 mt-1'>
{t("settings.storage.space_info", {
defaultValue:
"{{free}} GB free of {{total}} GB ({{used}}% used)",
free: freeSpaceGB,
total: totalSpaceGB,
used: usedPercent,
})}
</Text>
</View>
{isSelected && (
<View className='w-6 h-6 rounded-full bg-purple-600 items-center justify-center ml-2'>
<Text className='text-white text-xs'></Text>
</View>
)}
</View>
</TouchableOpacity>
);
})}
<View className='flex-row gap-x-2 py-4'>
<TouchableOpacity
onPress={onClose}
className='flex-1 py-3 rounded-lg bg-neutral-800 items-center'
>
<Text className='text-white font-semibold'>
{t("common.cancel", { defaultValue: "Cancel" })}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleConfirm}
className='flex-1 py-3 rounded-lg bg-purple-600 items-center'
disabled={!selectedId}
>
<Text className='text-white font-semibold'>
{t("common.confirm", { defaultValue: "Confirm" })}
</Text>
</TouchableOpacity>
</View>
</>
)}
</View>
</BottomSheetScrollView>
</BottomSheetModal>
);
});

View File

@@ -1,6 +1,4 @@
import { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { toast } from "sonner-native";
@@ -8,19 +6,14 @@ import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
import { useHaptic } from "@/hooks/useHaptic";
import { useDownload } from "@/providers/DownloadProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getStorageLabel } from "@/utils/storage";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { StorageLocationPicker } from "./StorageLocationPicker";
export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload();
const { settings } = useSettings();
const { t } = useTranslation();
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { data: size } = useQuery({
queryKey: ["appSize"],
@@ -36,12 +29,6 @@ export const StorageSettings = () => {
},
});
const { data: storageLabel } = useQuery({
queryKey: ["storageLabel", settings.downloadStorageLocation],
queryFn: () => getStorageLabel(settings.downloadStorageLocation),
enabled: Platform.OS === "android",
});
const onDeleteClicked = async () => {
try {
await deleteAllFiles();
@@ -115,32 +102,14 @@ export const StorageSettings = () => {
</View>
</View>
{!Platform.isTV && (
<>
{Platform.OS === "android" && (
<ListGroup>
<ListItem
title={t("settings.storage.download_location", {
defaultValue: "Download Location",
})}
value={storageLabel || "Internal Storage"}
onPress={() => bottomSheetModalRef.current?.present()}
/>
</ListGroup>
)}
<ListGroup>
<ListItem
textColor='red'
onPress={onDeleteClicked}
title={t("home.settings.storage.delete_all_downloaded_files")}
/>
</ListGroup>
</>
<ListGroup>
<ListItem
textColor='red'
onPress={onDeleteClicked}
title={t("home.settings.storage.delete_all_downloaded_files")}
/>
</ListGroup>
)}
<StorageLocationPicker
ref={bottomSheetModalRef}
onClose={() => bottomSheetModalRef.current?.dismiss()}
/>
</View>
);
};

View File

@@ -130,7 +130,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
<Text className='text-xs opacity-50'>{item?.Album}</Text>
)}
</View>
<View className='flex flex-row space-x-2 shrink-0'>
<View className='flex flex-row gap-x-2 shrink-0'>
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}

View File

@@ -127,7 +127,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
)}
</View>
<View className='flex flex-row items-center space-x-2'>
<View className='flex flex-row items-center gap-x-2'>
{!Platform.isTV &&
(settings.defaultPlayer === VideoPlayer.VLC_4 ||
Platform.OS === "android") && (

View File

@@ -13,6 +13,12 @@ const SkipButton: React.FC<SkipButtonProps> = ({
buttonText,
...props
}) => {
console.log(`[SKIP_BUTTON] Render:`, {
buttonText,
showButton,
className: showButton ? "flex" : "hidden",
});
return (
<View className={showButton ? "flex" : "hidden"} {...props}>
<TouchableOpacity

View File

@@ -130,13 +130,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
useEffect(() => {
const fetchTracks = async () => {
if (getSubtitleTracks) {
let subtitleData: TrackInfo[] | null = null;
try {
subtitleData = await getSubtitleTracks();
} catch (error) {
console.log("[VideoContext] Failed to get subtitle tracks:", error);
return;
}
let subtitleData = await getSubtitleTracks();
// Only FOR VLC 3, If we're transcoding, we need to reverse the subtitle data, because VLC reverses the HLS subtitles.
if (
mediaSource?.TranscodingUrl &&
@@ -185,13 +179,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
setSubtitleTracks(subtitles);
}
if (getAudioTracks) {
let audioData: TrackInfo[] | null = null;
try {
audioData = await getAudioTracks();
} catch (error) {
console.log("[VideoContext] Failed to get audio tracks:", error);
return;
}
const audioData = await getAudioTracks();
const allAudio =
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
const audioTracks: Track[] = allAudio?.map((audio, idx) => {

View File

@@ -28,7 +28,6 @@ export const useGestureDetection = ({
onVerticalDragEnd,
onTap,
screenWidth = 400,
screenHeight = 800,
}: SwipeGestureOptions = {}) => {
const touchStartTime = useRef(0);
const touchStartPosition = useRef({ x: 0, y: 0 });
@@ -37,47 +36,25 @@ export const useGestureDetection = ({
const dragSide = useRef<"left" | "right" | null>(null);
const hasMovedEnough = useRef(false);
const gestureType = useRef<"none" | "horizontal" | "vertical">("none");
const shouldIgnoreTouch = useRef(false);
const handleTouchStart = useCallback(
(event: GestureResponderEvent) => {
const startY = event.nativeEvent.pageY;
// Define exclusion zones (15% from top and bottom)
const topExclusionZone = screenHeight * 0.15;
const bottomExclusionZone = screenHeight * 0.85;
// Check if touch started in exclusion zones
if (startY < topExclusionZone || startY > bottomExclusionZone) {
shouldIgnoreTouch.current = true;
return;
}
shouldIgnoreTouch.current = false;
touchStartTime.current = Date.now();
touchStartPosition.current = {
x: event.nativeEvent.pageX,
y: startY,
};
lastTouchPosition.current = {
x: event.nativeEvent.pageX,
y: startY,
};
isDragging.current = false;
dragSide.current = null;
hasMovedEnough.current = false;
gestureType.current = "none";
},
[screenHeight],
);
const handleTouchStart = useCallback((event: GestureResponderEvent) => {
touchStartTime.current = Date.now();
touchStartPosition.current = {
x: event.nativeEvent.pageX,
y: event.nativeEvent.pageY,
};
lastTouchPosition.current = {
x: event.nativeEvent.pageX,
y: event.nativeEvent.pageY,
};
isDragging.current = false;
dragSide.current = null;
hasMovedEnough.current = false;
gestureType.current = "none";
}, []);
const handleTouchMove = useCallback(
(event: GestureResponderEvent) => {
// Ignore touch if it started in exclusion zone
if (shouldIgnoreTouch.current) {
return;
}
const currentPosition = {
x: event.nativeEvent.pageX,
y: event.nativeEvent.pageY,
@@ -129,12 +106,6 @@ export const useGestureDetection = ({
const handleTouchEnd = useCallback(
(event: GestureResponderEvent) => {
// Ignore touch if it started in exclusion zone
if (shouldIgnoreTouch.current) {
shouldIgnoreTouch.current = false;
return;
}
const touchEndTime = Date.now();
const touchEndPosition = {
x: event.nativeEvent.pageX,

View File

@@ -19,14 +19,10 @@ export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
useEffect(() => {
const fetchTracks = async () => {
if (playerRef.current) {
try {
const audio = await playerRef.current.getAudioTracks();
const subtitles = await playerRef.current.getSubtitleTracks();
setAudioTracks(audio);
setSubtitleTracks(subtitles);
} catch (error) {
console.log("[VideoDebugInfo] Failed to fetch tracks:", error);
}
const audio = await playerRef.current.getAudioTracks();
const subtitles = await playerRef.current.getSubtitleTracks();
setAudioTracks(audio);
setSubtitleTracks(subtitles);
}
};
@@ -64,24 +60,8 @@ export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
className='mt-2.5 bg-blue-500 p-2 rounded'
onPress={() => {
if (playerRef.current) {
playerRef.current
.getAudioTracks()
.then(setAudioTracks)
.catch((err) => {
console.log(
"[VideoDebugInfo] Failed to get audio tracks:",
err,
);
});
playerRef.current
.getSubtitleTracks()
.then(setSubtitleTracks)
.catch((err) => {
console.log(
"[VideoDebugInfo] Failed to get subtitle tracks:",
err,
);
});
playerRef.current.getAudioTracks().then(setAudioTracks);
playerRef.current.getSubtitleTracks().then(setSubtitleTracks);
}
}}
>

View File

@@ -45,14 +45,14 @@
},
"production": {
"environment": "production",
"channel": "0.48.0",
"channel": "0.47.1",
"android": {
"image": "latest"
}
},
"production-apk": {
"environment": "production",
"channel": "0.48.0",
"channel": "0.47.1",
"android": {
"buildType": "apk",
"image": "latest"
@@ -60,7 +60,7 @@
},
"production-apk-tv": {
"environment": "production",
"channel": "0.48.0",
"channel": "0.47.1",
"android": {
"buildType": "apk",
"image": "latest"

5
global.css Normal file
View File

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

View File

@@ -2,14 +2,8 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom, useAtomValue } from "jotai";
import { useEffect, useMemo } from "react";
import { Platform } from "react-native";
import type * as ImageColorsType from "react-native-image-colors";
import { getColors, ImageColorsResult } from "react-native-image-colors";
import { apiAtom } from "@/providers/JellyfinProvider";
// Conditionally import react-native-image-colors only on non-TV platforms
const ImageColors = Platform.isTV
? null
: (require("react-native-image-colors") as typeof ImageColorsType);
import {
adjustToNearBlack,
calculateTextColor,
@@ -70,13 +64,11 @@ export const useImageColors = ({
}
// Extract colors from the image
if (!ImageColors?.getColors) return;
ImageColors.getColors(source.uri, {
getColors(source.uri, {
fallback: "#fff",
cache: false,
})
.then((colors: ImageColorsType.ImageColorsResult) => {
.then((colors: ImageColorsResult) => {
let primary = "#fff";
let text = "#000";
let backup = "#fff";

View File

@@ -2,14 +2,8 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtomValue } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { Platform } from "react-native";
import type * as ImageColorsType from "react-native-image-colors";
import { getColors, ImageColorsResult } from "react-native-image-colors";
import { apiAtom } from "@/providers/JellyfinProvider";
// Conditionally import react-native-image-colors only on non-TV platforms
const ImageColors = Platform.isTV
? null
: (require("react-native-image-colors") as typeof ImageColorsType);
import {
adjustToNearBlack,
calculateTextColor,
@@ -86,13 +80,11 @@ export const useImageColorsReturn = ({
}
// Extract colors from the image
if (!ImageColors?.getColors) return;
ImageColors.getColors(source.uri, {
getColors(source.uri, {
fallback: "#fff",
cache: false,
})
.then((colors: ImageColorsType.ImageColorsResult) => {
.then((colors: ImageColorsResult) => {
let primary = "#fff";
let text = "#000";
let backup = "#fff";

View File

@@ -43,14 +43,37 @@ export const useIntroSkipper = (
const introTimestamps = segments?.introSegments?.[0];
useEffect(() => {
console.log(`[INTRO_SKIPPER] Hook state:`, {
itemId,
currentTime,
hasSegments: !!segments,
segments: segments,
introSegmentsCount: segments?.introSegments?.length || 0,
introSegments: segments?.introSegments,
hasIntroTimestamps: !!introTimestamps,
introTimestamps,
isVlc,
isOffline,
});
if (introTimestamps) {
const shouldShow =
currentTime > introTimestamps.startTime &&
currentTime < introTimestamps.endTime;
console.log(`[INTRO_SKIPPER] Button visibility check:`, {
currentTime,
introStart: introTimestamps.startTime,
introEnd: introTimestamps.endTime,
afterStart: currentTime > introTimestamps.startTime,
beforeEnd: currentTime < introTimestamps.endTime,
shouldShow,
});
setShowSkipButton(shouldShow);
} else {
if (showSkipButton) {
console.log(`[INTRO_SKIPPER] No intro timestamps, hiding button`);
setShowSkipButton(false);
}
}
@@ -59,6 +82,10 @@ export const useIntroSkipper = (
const skipIntro = useCallback(() => {
if (!introTimestamps) return;
try {
console.log(
`[INTRO_SKIPPER] Skipping intro to:`,
introTimestamps.endTime,
);
lightHapticFeedback();
wrappedSeek(introTimestamps.endTime);
setTimeout(() => {
@@ -69,5 +96,7 @@ export const useIntroSkipper = (
}
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
console.log(`[INTRO_SKIPPER] Returning state:`, { showSkipButton });
return { showSkipButton, skipIntro };
};

View File

@@ -1,54 +1,28 @@
import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
// Helper to exclude specific fields
export const excludeFields = (fieldsToExclude: ItemFields[]) => {
return Object.values(ItemFields).filter(
(field) => !fieldsToExclude.includes(field),
);
};
export const useItemQuery = (
itemId: string | undefined,
isOffline?: boolean,
fields?: ItemFields[],
excludeFields?: ItemFields[],
) => {
export const useItemQuery = (itemId: string, isOffline: boolean) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { getDownloadedItemById } = useDownload();
// Calculate final fields: use excludeFields if provided, otherwise use fields
const finalFields = excludeFields
? Object.values(ItemFields).filter(
(field) => !excludeFields.includes(field),
)
: fields;
return useQuery({
queryKey: ["item", itemId, finalFields],
queryKey: ["item", itemId],
queryFn: async () => {
if (!itemId) throw new Error("Item ID is required");
if (isOffline) {
return getDownloadedItemById(itemId)?.item;
}
if (!api || !user) return null;
const response = await getItemsApi(api).getItems({
ids: [itemId],
userId: user.Id,
...(finalFields && { fields: finalFields }),
if (!api || !user || !itemId) return null;
const res = await getUserLibraryApi(api).getItem({
itemId: itemId,
userId: user?.Id,
});
return response.data.Items?.[0];
return res.data;
},
enabled: !!itemId,
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,

View File

@@ -512,7 +512,7 @@ export const useJellyseerr = () => {
};
const jellyseerrRegion = useMemo(
() => jellyseerrUser?.settings?.region || "US",
() => jellyseerrUser?.settings?.discoverRegion || "US",
[jellyseerrUser],
);

View File

@@ -1,19 +1,12 @@
import type { OrientationChangeEvent } from "expo-screen-orientation";
import { useEffect, useState } from "react";
import { Platform } from "react-native";
import {
addOrientationChangeListener,
getOrientationAsync,
lockAsync,
Orientation as OrientationEnum,
OrientationLock,
unlockAsync,
} from "@/packages/expo-screen-orientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { OrientationLock } from "@/packages/expo-screen-orientation";
import { Orientation } from "../packages/expo-screen-orientation.tv";
const orientationToOrientationLock = (
orientation: (typeof OrientationEnum)[keyof typeof OrientationEnum],
): (typeof OrientationLock)[keyof typeof OrientationLock] => {
orientation: Orientation,
): OrientationLock => {
switch (orientation) {
case Orientation.LANDSCAPE_LEFT:
return OrientationLock.LANDSCAPE_LEFT;
@@ -28,52 +21,44 @@ const orientationToOrientationLock = (
export const useOrientation = () => {
const [orientation, setOrientation] = useState(
Platform.isTV ? OrientationLock.LANDSCAPE : OrientationLock.UNKNOWN,
Platform.isTV
? ScreenOrientation.OrientationLock.LANDSCAPE
: ScreenOrientation.OrientationLock.UNKNOWN,
);
useEffect(() => {
if (Platform.isTV) return;
const orientationSubscription = addOrientationChangeListener(
(event: OrientationChangeEvent) => {
const orientationSubscription =
ScreenOrientation.addOrientationChangeListener((event) => {
setOrientation(
orientationToOrientationLock(event.orientationInfo.orientation),
);
},
);
});
getOrientationAsync().then(
(orientation: (typeof OrientationEnum)[keyof typeof OrientationEnum]) => {
setOrientation(orientationToOrientationLock(orientation));
},
);
ScreenOrientation.getOrientationAsync().then((orientation) => {
setOrientation(orientationToOrientationLock(orientation));
});
return () => {
orientationSubscription.remove();
};
}, []);
const lockOrientation = async (
lock: (typeof OrientationLock)[keyof typeof OrientationLock],
) => {
const lockOrientation = async (lock: OrientationLock) => {
if (Platform.isTV) return;
if (lock === OrientationLock.DEFAULT) {
await unlockAsync();
if (lock === ScreenOrientation.OrientationLock.DEFAULT) {
await ScreenOrientation.unlockAsync();
} else {
await lockAsync(lock);
await ScreenOrientation.lockAsync(lock);
}
};
const unlockOrientationFn = async () => {
const unlockOrientation = async () => {
if (Platform.isTV) return;
await unlockAsync();
await ScreenOrientation.unlockAsync();
};
return {
orientation,
setOrientation,
lockOrientation,
unlockOrientation: unlockOrientationFn,
};
return { orientation, setOrientation, lockOrientation, unlockOrientation };
};

View File

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

View File

@@ -59,13 +59,6 @@ export type ChapterInfo = {
duration: number;
};
export type NowPlayingMetadata = {
title?: string;
artist?: string;
albumTitle?: string;
artworkUri?: string;
};
export type VlcPlayerViewProps = {
source: VlcPlayerSource;
style?: ViewStyle | ViewStyle[];
@@ -74,7 +67,6 @@ export type VlcPlayerViewProps = {
muted?: boolean;
volume?: number;
videoAspectRatio?: string;
nowPlayingMetadata?: NowPlayingMetadata;
onVideoProgress?: (event: ProgressUpdatePayload) => void;
onVideoStateChange?: (event: PlaybackStatePayload) => void;
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;

View File

@@ -102,7 +102,6 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
muted,
volume,
videoAspectRatio,
nowPlayingMetadata,
onVideoLoadStart,
onVideoStateChange,
onVideoProgress,
@@ -132,7 +131,6 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
muted={muted}
volume={volume}
videoAspectRatio={videoAspectRatio}
nowPlayingMetadata={nowPlayingMetadata}
onVideoLoadStart={onVideoLoadStart}
onVideoLoadEnd={onVideoLoadEnd}
onVideoStateChange={onVideoStateChange}

View File

@@ -35,7 +35,7 @@ android {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation "com.squareup.okhttp3:okhttp:5.3.0"
implementation "com.squareup.okhttp3:okhttp:4.12.0"
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {

View File

@@ -4,16 +4,11 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Build
import android.os.Environment
import android.os.IBinder
import android.os.storage.StorageManager
import android.os.storage.StorageVolume
import android.util.Log
import expo.modules.kotlin.Promise
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.io.File
data class DownloadTaskInfo(
val url: String,
@@ -147,105 +142,6 @@ class BackgroundDownloaderModule : Module() {
promise.reject("ERROR", "Failed to get active downloads: ${e.message}", e)
}
}
AsyncFunction("getAvailableStorageLocations") { promise: Promise ->
try {
val storageLocations = mutableListOf<Map<String, Any>>()
// Use getExternalFilesDirs which works reliably across all Android versions
// This returns app-specific directories on both internal and external storage
val externalDirs = context.getExternalFilesDirs(null)
Log.d(TAG, "getExternalFilesDirs returned ${externalDirs.size} locations")
// Also check with StorageManager for additional info
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
val volumes = storageManager.storageVolumes
Log.d(TAG, "StorageManager reports ${volumes.size} volumes")
for ((i, vol) in volumes.withIndex()) {
Log.d(TAG, " Volume $i: isPrimary=${vol.isPrimary}, isRemovable=${vol.isRemovable}, state=${vol.state}, uuid=${vol.uuid}")
}
}
for ((index, dir) in externalDirs.withIndex()) {
try {
if (dir == null) {
Log.w(TAG, "Directory at index $index is null - SD card may not be mounted")
continue
}
if (!dir.exists()) {
Log.w(TAG, "Directory at index $index does not exist: ${dir.absolutePath}")
continue
}
val isPrimary = index == 0
val isRemovable = !isPrimary && Environment.isExternalStorageRemovable(dir)
// Get volume UUID for better identification
val volumeId = if (isPrimary) {
"internal"
} else {
// Try to get a stable UUID for the SD card
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
try {
val storageVolume = storageManager.getStorageVolume(dir)
storageVolume?.uuid ?: "sdcard_$index"
} catch (e: Exception) {
"sdcard_$index"
}
} else {
"sdcard_$index"
}
}
// Get human-readable label
val label = if (isPrimary) {
"Internal Storage"
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
try {
val storageVolume = storageManager.getStorageVolume(dir)
storageVolume?.getDescription(context) ?: "SD Card"
} catch (e: Exception) {
"SD Card"
}
} else {
"SD Card"
}
}
val totalSpace = dir.totalSpace
val freeSpace = dir.freeSpace
Log.d(TAG, "Storage location: $label (id: $volumeId, path: ${dir.absolutePath}, removable: $isRemovable)")
storageLocations.add(
mapOf(
"id" to volumeId,
"path" to dir.absolutePath,
"type" to (if (isRemovable || !isPrimary) "external" else "internal"),
"label" to label,
"totalSpace" to totalSpace,
"freeSpace" to freeSpace
)
)
} catch (e: Exception) {
Log.e(TAG, "Error processing storage at index $index: ${e.message}", e)
continue
}
}
Log.d(TAG, "Returning ${storageLocations.size} storage locations")
promise.resolve(storageLocations)
} catch (e: Exception) {
Log.e(TAG, "Error getting storage locations: ${e.message}", e)
promise.reject("ERROR", "Failed to get storage locations: ${e.message}", e)
}
}
}
private fun startDownloadInternal(urlString: String, destinationPath: String?): Int {

View File

@@ -5,7 +5,6 @@ import type {
DownloadErrorEvent,
DownloadProgressEvent,
DownloadStartedEvent,
StorageLocation,
} from "./src/BackgroundDownloader.types";
import BackgroundDownloaderModule from "./src/BackgroundDownloaderModule";
@@ -16,7 +15,6 @@ export interface BackgroundDownloader {
cancelQueuedDownload(url: string): void;
cancelAllDownloads(): void;
getActiveDownloads(): Promise<ActiveDownload[]>;
getAvailableStorageLocations(): Promise<StorageLocation[]>;
addProgressListener(
listener: (event: DownloadProgressEvent) => void,
@@ -66,10 +64,6 @@ const BackgroundDownloader: BackgroundDownloader = {
return await BackgroundDownloaderModule.getActiveDownloads();
},
async getAvailableStorageLocations(): Promise<StorageLocation[]> {
return await BackgroundDownloaderModule.getAvailableStorageLocations();
},
addProgressListener(
listener: (event: DownloadProgressEvent) => void,
): EventSubscription {
@@ -112,5 +106,4 @@ export type {
DownloadErrorEvent,
DownloadProgressEvent,
DownloadStartedEvent,
StorageLocation,
};

View File

@@ -29,15 +29,6 @@ export interface ActiveDownload {
state: "running" | "suspended" | "canceling" | "completed" | "unknown";
}
export interface StorageLocation {
id: string;
path: string;
type: "internal" | "external";
label: string;
totalSpace: number;
freeSpace: number;
}
export interface BackgroundDownloaderModuleType {
startDownload(url: string, destinationPath?: string): Promise<number>;
enqueueDownload(url: string, destinationPath?: string): Promise<number>;
@@ -45,7 +36,6 @@ export interface BackgroundDownloaderModuleType {
cancelQueuedDownload(url: string): void;
cancelAllDownloads(): void;
getActiveDownloads(): Promise<ActiveDownload[]>;
getAvailableStorageLocations(): Promise<StorageLocation[]>;
addListener(
eventName: string,
listener: (event: any) => void,

View File

@@ -18,7 +18,6 @@ export type {
DownloadErrorEvent,
DownloadProgressEvent,
DownloadStartedEvent,
StorageLocation,
} from "./background-downloader";
// Background Downloader
export { default as BackgroundDownloader } from "./background-downloader";

View File

@@ -16,12 +16,6 @@ public class VlcPlayerModule: Module {
}
}
Prop("nowPlayingMetadata") { (view: VlcPlayerView, metadata: [String: String]?) in
if let metadata = metadata {
view.setNowPlayingMetadata(metadata)
}
}
Events(
"onPlaybackStateChanged",
"onVideoStateChange",

View File

@@ -1,6 +1,4 @@
import ExpoModulesCore
import MediaPlayer
import AVFoundation
#if os(tvOS)
import TVVLCKit
@@ -26,9 +24,6 @@ class VlcPlayerView: ExpoView {
var hasSource = false
var isTranscoding = false
private var initialSeekPerformed: Bool = false
private var nowPlayingMetadata: [String: String]?
private var artworkImage: UIImage?
private var artworkDownloadTask: URLSessionDataTask?
// MARK: - Initialization
@@ -36,8 +31,6 @@ class VlcPlayerView: ExpoView {
super.init(appContext: appContext)
setupView()
setupNotifications()
setupRemoteCommandCenter()
setupAudioSession()
}
// MARK: - Setup
@@ -67,205 +60,42 @@ class VlcPlayerView: ExpoView {
NotificationCenter.default.addObserver(
self, selector: #selector(applicationDidBecomeActive),
name: UIApplication.didBecomeActiveNotification, object: nil)
#if !os(tvOS)
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
NotificationCenter.default.addObserver(
self, selector: #selector(handleAudioSessionInterruption),
name: AVAudioSession.interruptionNotification, object: nil)
#endif
}
private func setupAudioSession() {
#if !os(tvOS)
do {
let audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playback, mode: .moviePlayback, options: [])
try audioSession.setActive(true)
print("Audio session configured for media controls")
} catch {
print("Failed to setup audio session: \(error)")
}
#endif
}
private func setupRemoteCommandCenter() {
#if !os(tvOS)
let commandCenter = MPRemoteCommandCenter.shared()
// Play command
commandCenter.playCommand.isEnabled = true
commandCenter.playCommand.addTarget { [weak self] _ in
self?.play()
return .success
}
// Pause command
commandCenter.pauseCommand.isEnabled = true
commandCenter.pauseCommand.addTarget { [weak self] _ in
self?.pause()
return .success
}
// Toggle play/pause command
commandCenter.togglePlayPauseCommand.isEnabled = true
commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
guard let self = self, let player = self.mediaPlayer else {
return .commandFailed
}
if player.isPlaying {
self.pause()
} else {
self.play()
}
return .success
}
// Seek forward command
commandCenter.skipForwardCommand.isEnabled = true
commandCenter.skipForwardCommand.preferredIntervals = [15]
commandCenter.skipForwardCommand.addTarget { [weak self] event in
guard let self = self, let player = self.mediaPlayer else {
return .commandFailed
}
let skipInterval = (event as? MPSkipIntervalCommandEvent)?.interval ?? 15
let currentTime = player.time.intValue
self.seekTo(currentTime + Int32(skipInterval * 1000))
return .success
}
// Seek backward command
commandCenter.skipBackwardCommand.isEnabled = true
commandCenter.skipBackwardCommand.preferredIntervals = [15]
commandCenter.skipBackwardCommand.addTarget { [weak self] event in
guard let self = self, let player = self.mediaPlayer else {
return .commandFailed
}
let skipInterval = (event as? MPSkipIntervalCommandEvent)?.interval ?? 15
let currentTime = player.time.intValue
self.seekTo(max(0, currentTime - Int32(skipInterval * 1000)))
return .success
}
// Change playback position command (scrubbing)
commandCenter.changePlaybackPositionCommand.isEnabled = true
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let self = self,
let event = event as? MPChangePlaybackPositionCommandEvent else {
return .commandFailed
}
let positionTime = event.positionTime
self.seekTo(Int32(positionTime * 1000))
return .success
}
print("Remote command center configured")
#endif
}
private func cleanupRemoteCommandCenter() {
#if !os(tvOS)
let commandCenter = MPRemoteCommandCenter.shared()
// Remove all command targets to prevent memory leaks
commandCenter.playCommand.removeTarget(nil)
commandCenter.pauseCommand.removeTarget(nil)
commandCenter.togglePlayPauseCommand.removeTarget(nil)
commandCenter.skipForwardCommand.removeTarget(nil)
commandCenter.skipBackwardCommand.removeTarget(nil)
commandCenter.changePlaybackPositionCommand.removeTarget(nil)
// Disable commands
commandCenter.playCommand.isEnabled = false
commandCenter.pauseCommand.isEnabled = false
commandCenter.togglePlayPauseCommand.isEnabled = false
commandCenter.skipForwardCommand.isEnabled = false
commandCenter.skipBackwardCommand.isEnabled = false
commandCenter.changePlaybackPositionCommand.isEnabled = false
print("Remote command center cleaned up")
#endif
}
// MARK: - Public Methods
func startPictureInPicture() {}
@objc func play() {
DispatchQueue.main.async {
self.mediaPlayer?.play()
self.isPaused = false
self.updateNowPlayingInfo()
print("Play")
}
self.mediaPlayer?.play()
self.isPaused = false
print("Play")
}
@objc func pause() {
DispatchQueue.main.async {
self.mediaPlayer?.pause()
self.isPaused = true
self.updateNowPlayingInfo()
}
}
@objc func handleAudioSessionInterruption(_ notification: Notification) {
#if !os(tvOS)
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
return
}
switch type {
case .began:
// Interruption began - pause the video
print("Audio session interrupted - pausing video")
self.pause()
case .ended:
// Interruption ended - check if we should resume
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
print("Audio session interruption ended - can resume")
// Don't auto-resume - let user manually resume playback
} else {
print("Audio session interruption ended - should not resume")
}
}
@unknown default:
break
}
#endif
self.mediaPlayer?.pause()
self.isPaused = true
}
@objc func seekTo(_ time: Int32) {
DispatchQueue.main.async {
guard let player = self.mediaPlayer else { return }
guard let player = self.mediaPlayer else { return }
let wasPlaying = player.isPlaying
let wasPlaying = player.isPlaying
if wasPlaying {
self.pause()
}
if let duration = player.media?.length.intValue {
print("Seeking to time: \(time) Video Duration \(duration)")
// If the specified time is greater than the duration, seek to the end
let seekTime = time > duration ? duration - 1000 : time
player.time = VLCTime(int: seekTime)
if wasPlaying {
player.pause()
}
if let duration = player.media?.length.intValue {
print("Seeking to time: \(time) Video Duration \(duration)")
// If the specified time is greater than the duration, seek to the end
let seekTime = time > duration ? duration - 1000 : time
player.time = VLCTime(int: seekTime)
if wasPlaying {
player.play()
}
self.updatePlayerState()
self.updateNowPlayingInfo()
} else {
print("Error: Unable to retrieve video duration")
self.play()
}
self.updatePlayerState()
} else {
print("Error: Unable to retrieve video duration")
}
}
@@ -433,55 +263,6 @@ class VlcPlayerView: ExpoView {
}
}
@objc func setNowPlayingMetadata(_ metadata: [String: String]) {
// Cancel any existing artwork download to prevent race conditions
artworkDownloadTask?.cancel()
artworkDownloadTask = nil
self.nowPlayingMetadata = metadata
print("[NowPlaying] Metadata received: \(metadata)")
// Load artwork asynchronously if provided
if let artworkUri = metadata["artworkUri"], let url = URL(string: artworkUri) {
print("[NowPlaying] Loading artwork from: \(artworkUri)")
artworkDownloadTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
guard let self = self else { return }
if let error = error as NSError?, error.code == NSURLErrorCancelled {
print("[NowPlaying] Artwork download cancelled")
return
}
if let error = error {
print("[NowPlaying] Artwork loading error: \(error)")
DispatchQueue.main.async {
self.updateNowPlayingInfo()
}
} else if let data = data, let image = UIImage(data: data) {
print("[NowPlaying] Artwork loaded successfully, size: \(image.size)")
self.artworkImage = image
DispatchQueue.main.async {
self.updateNowPlayingInfo()
}
} else {
print("[NowPlaying] Failed to create image from data")
// Update Now Playing info without artwork on failure
DispatchQueue.main.async {
self.updateNowPlayingInfo()
}
}
}
artworkDownloadTask?.resume()
} else {
// No artwork URI provided - update immediately
print("[NowPlaying] No artwork URI provided")
artworkImage = nil
DispatchQueue.main.async {
self.updateNowPlayingInfo()
}
}
}
@objc func stop(completion: (() -> Void)? = nil) {
guard !isStopping else {
completion?()
@@ -513,27 +294,6 @@ class VlcPlayerView: ExpoView {
// Stop the media player
mediaPlayer?.stop()
// Cancel any in-flight artwork downloads
artworkDownloadTask?.cancel()
artworkDownloadTask = nil
artworkImage = nil
// Cleanup remote command center targets
cleanupRemoteCommandCenter()
#if !os(tvOS)
// Deactivate audio session to allow other apps to use audio
do {
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
print("Audio session deactivated")
} catch {
print("Failed to deactivate audio session: \(error)")
}
// Clear Now Playing info
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
#endif
// Remove observer
NotificationCenter.default.removeObserver(self)
@@ -567,60 +327,6 @@ class VlcPlayerView: ExpoView {
"duration": durationMs,
])
}
// Update Now Playing info to sync elapsed playback time
// iOS needs periodic updates to keep progress indicator in sync
DispatchQueue.main.async {
self.updateNowPlayingInfo()
}
}
private func updateNowPlayingInfo() {
#if !os(tvOS)
guard let player = self.mediaPlayer else { return }
var nowPlayingInfo = [String: Any]()
// Playback rate (0.0 = paused, 1.0 = playing at normal speed)
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.isPlaying ? player.rate : 0.0
// Current playback time in seconds
let currentTimeSeconds = Double(player.time.intValue) / 1000.0
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTimeSeconds
// Total duration in seconds
if let duration = player.media?.length.intValue {
let durationSeconds = Double(duration) / 1000.0
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = durationSeconds
}
// Add metadata if available
if let metadata = self.nowPlayingMetadata {
if let title = metadata["title"] {
nowPlayingInfo[MPMediaItemPropertyTitle] = title
print("[NowPlaying] Setting title: \(title)")
}
if let artist = metadata["artist"] {
nowPlayingInfo[MPMediaItemPropertyArtist] = artist
print("[NowPlaying] Setting artist: \(artist)")
}
if let albumTitle = metadata["albumTitle"] {
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = albumTitle
print("[NowPlaying] Setting album: \(albumTitle)")
}
}
// Add artwork if available
if let artwork = self.artworkImage {
print("[NowPlaying] Setting artwork with size: \(artwork.size)")
let artworkItem = MPMediaItemArtwork(boundsSize: artwork.size) { _ in
return artwork
}
nowPlayingInfo[MPMediaItemPropertyArtwork] = artworkItem
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
#endif
}
// MARK: - Expo Events

3
nativewind-env.d.ts vendored
View File

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

View File

@@ -64,18 +64,19 @@
"i18next": "^25.0.0",
"jotai": "^2.12.5",
"lodash": "^4.17.21",
"nativewind": "^2.0.11",
"nativewind": "^5.0.0-preview.2",
"patch-package": "^8.0.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "16.0.0",
"react-i18next": "^15.4.0",
"react-native": "npm:react-native-tvos@0.81.5-1",
"react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "^1.0.2",
"react-native-circular-progress": "^1.4.1",
"react-native-collapsible": "^1.6.2",
"react-native-country-flag": "^2.0.2",
"react-native-device-info": "^15.0.0",
"react-native-css": "^3.0.1",
"react-native-device-info": "^14.0.4",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-google-cast": "^4.9.1",
@@ -98,25 +99,28 @@
"react-native-web": "^0.21.0",
"react-native-worklets": "0.5.1",
"sonner-native": "^0.21.0",
"tailwindcss": "3.3.2",
"tailwindcss": "^4.1.17",
"use-debounce": "^10.0.4",
"zod": "^4.1.3"
},
"devDependencies": {
"@babel/core": "7.28.5",
"@biomejs/biome": "2.3.5",
"@react-native-community/cli": "20.0.2",
"@react-native-tvos/config-tv": "0.1.4",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.20",
"@babel/core": "^7.20.0",
"@biomejs/biome": "^2.3.5",
"@react-native-community/cli": "^20.0.0",
"@react-native-tvos/config-tv": "^0.1.1",
"@tailwindcss/postcss": "^4.1.17",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.15",
"@types/react": "~19.1.10",
"@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0",
"expo-doctor": "1.17.11",
"husky": "9.1.7",
"lint-staged": "16.2.6",
"@types/react-test-renderer": "^19.0.0",
"cross-env": "^10.0.0",
"expo-doctor": "^1.17.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.5",
"postcss": "^8.5.6",
"postinstall-postinstall": "^2.1.0",
"react-test-renderer": "19.1.1",
"typescript": "5.9.3"
"typescript": "~5.9.2"
},
"expo": {
"doctor": {
@@ -150,5 +154,8 @@
"resolutions": {
"expo-constants": "~18.0.10",
"expo-task-manager": "~14.0.8"
},
"overrides": {
"lightningcss": "1.30.1"
}
}

View File

@@ -1,76 +1 @@
import { Platform } from "react-native";
// Dummy exports for TV
enum DummyOrientationLock {
DEFAULT = 0,
ALL = 1,
PORTRAIT = 2,
PORTRAIT_UP = 3,
PORTRAIT_DOWN = 4,
LANDSCAPE = 5,
LANDSCAPE_LEFT = 6,
LANDSCAPE_RIGHT = 7,
OTHER = 8,
UNKNOWN = 9,
}
enum DummyOrientation {
UNKNOWN = 0,
PORTRAIT_UP = 1,
PORTRAIT_DOWN = 2,
LANDSCAPE_LEFT = 3,
LANDSCAPE_RIGHT = 4,
}
const dummyLockAsync = async () => {};
const dummyUnlockAsync = async () => {};
const dummyGetOrientationAsync = async () => DummyOrientation.UNKNOWN;
const dummyGetOrientationLockAsync = async () => DummyOrientationLock.DEFAULT;
const dummySupportsOrientationLockAsync = async () => false;
// Conditionally export based on platform
let ScreenOrientation: any;
if (!Platform.isTV) {
ScreenOrientation = require("expo-screen-orientation");
}
export const OrientationLock = Platform.isTV
? DummyOrientationLock
: ScreenOrientation?.OrientationLock;
export const Orientation = Platform.isTV
? DummyOrientation
: ScreenOrientation?.Orientation;
// Export types
export type OrientationLockType = typeof OrientationLock;
export type OrientationType = typeof Orientation;
export const lockAsync = Platform.isTV
? dummyLockAsync
: ScreenOrientation?.lockAsync;
export const unlockAsync = Platform.isTV
? dummyUnlockAsync
: ScreenOrientation?.unlockAsync;
export const getOrientationAsync = Platform.isTV
? dummyGetOrientationAsync
: ScreenOrientation?.getOrientationAsync;
export const getOrientationLockAsync = Platform.isTV
? dummyGetOrientationLockAsync
: ScreenOrientation?.getOrientationLockAsync;
export const supportsOrientationLockAsync = Platform.isTV
? dummySupportsOrientationLockAsync
: ScreenOrientation?.supportsOrientationLockAsync;
export const lockPlatformAsync = Platform.isTV
? dummyLockAsync
: ScreenOrientation?.lockPlatformAsync;
export const getPlatformLockAsync = Platform.isTV
? dummyGetOrientationLockAsync
: ScreenOrientation?.getPlatformLockAsync;
export const addOrientationChangeListener = Platform.isTV
? () => ({ remove: () => {} })
: ScreenOrientation?.addOrientationChangeListener;
export const removeOrientationChangeListener = Platform.isTV
? () => {}
: ScreenOrientation?.removeOrientationChangeListener;
export const removeOrientationChangeListeners = Platform.isTV
? () => {}
: ScreenOrientation?.removeOrientationChangeListeners;
export * from "expo-screen-orientation";

5
postcss.config.mjs Normal file
View File

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

View File

@@ -58,42 +58,31 @@ function useDownloadProvider() {
| Partial<JobStatus>
| ((current: JobStatus) => Partial<JobStatus>),
) => {
setProcesses((prev) => {
const processIndex = prev.findIndex((p) => p.id === processId);
if (processIndex === -1) return prev;
const currentProcess = prev[processIndex];
if (!currentProcess) return prev;
const newStatus =
typeof updater === "function" ? updater(currentProcess) : updater;
// Create new array with updated process
const newProcesses = [...prev];
newProcesses[processIndex] = {
...currentProcess,
...newStatus,
};
return newProcesses;
});
setProcesses((prev) =>
prev.map((p) => {
if (p.id !== processId) return p;
const newStatus =
typeof updater === "function" ? updater(p) : updater;
return {
...p,
...newStatus,
};
}),
);
},
[setProcesses],
);
const removeProcess = useCallback(
(id: string) => {
// Use setTimeout to defer removal and avoid race conditions during rendering
setTimeout(() => {
setProcesses((prev) => prev.filter((process) => process.id !== id));
setProcesses((prev) => prev.filter((process) => process.id !== id));
// Find and remove from task map
taskMapRef.current.forEach((processId, taskId) => {
if (processId === id) {
taskMapRef.current.delete(taskId);
}
});
}, 0);
// Find and remove from task map
taskMapRef.current.forEach((processId, taskId) => {
if (processId === id) {
taskMapRef.current.delete(taskId);
}
});
},
[setProcesses],
);

View File

@@ -6,20 +6,16 @@ import type {
import { Directory, File, Paths } from "expo-file-system";
import { getItemImage } from "@/utils/getItemImage";
import { fetchAndParseSegments } from "@/utils/segments";
import { filePathToUri } from "@/utils/storage";
import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay";
import type { MediaTimeSegment, TrickPlayData } from "./types";
import { generateFilename } from "./utils";
/**
* Downloads trickplay images for an item
* @param item - The item to download trickplay images for
* @param storagePath - Optional custom storage path (for Android SD card support)
* @returns TrickPlayData with path and size, or undefined if not available
*/
export async function downloadTrickplayImages(
item: BaseItemDto,
storagePath?: string,
): Promise<TrickPlayData | undefined> {
const trickplayInfo = getTrickplayInfo(item);
if (!trickplayInfo || !item.Id) {
@@ -27,11 +23,7 @@ export async function downloadTrickplayImages(
}
const filename = generateFilename(item);
// Use custom storage path if provided (Android SD card), otherwise use Paths.document
const trickplayDir = storagePath
? new Directory(filePathToUri(storagePath), `${filename}_trickplay`)
: new Directory(Paths.document, `${filename}_trickplay`);
const trickplayDir = new Directory(Paths.document, `${filename}_trickplay`);
// Create directory if it doesn't exist
if (!trickplayDir.exists) {
@@ -77,17 +69,12 @@ export async function downloadTrickplayImages(
/**
* Downloads external subtitle files and updates their delivery URLs to local paths
* @param mediaSource - The media source containing subtitle information
* @param item - The item to download subtitles for
* @param apiBasePath - The base path for the API
* @param storagePath - Optional custom storage path (for Android SD card support)
* @returns Updated media source with local subtitle paths
*/
export async function downloadSubtitles(
mediaSource: MediaSourceInfo,
item: BaseItemDto,
apiBasePath: string,
storagePath?: string,
): Promise<MediaSourceInfo> {
const externalSubtitles = mediaSource.MediaStreams?.filter(
(stream) =>
@@ -104,17 +91,10 @@ export async function downloadSubtitles(
const url = apiBasePath + subtitle.DeliveryUrl;
const extension = subtitle.Codec || "srt";
// Use custom storage path if provided (Android SD card), otherwise use Paths.document
const destination = storagePath
? new File(
filePathToUri(storagePath),
`${filename}_subtitle_${subtitle.Index}.${extension}`,
)
: new File(
Paths.document,
`${filename}_subtitle_${subtitle.Index}.${extension}`,
);
const destination = new File(
Paths.document,
`${filename}_subtitle_${subtitle.Index}.${extension}`,
);
// Skip if already exists
if (destination.exists) {
@@ -228,21 +208,13 @@ export async function downloadAdditionalAssets(params: {
api: Api;
saveImageFn: (itemId: string, url?: string) => Promise<void>;
saveSeriesImageFn: (item: BaseItemDto) => Promise<void>;
storagePath?: string;
}): Promise<{
trickPlayData?: TrickPlayData;
updatedMediaSource: MediaSourceInfo;
introSegments?: MediaTimeSegment[];
creditSegments?: MediaTimeSegment[];
}> {
const {
item,
mediaSource,
api,
saveImageFn,
saveSeriesImageFn,
storagePath,
} = params;
const { item, mediaSource, api, saveImageFn, saveSeriesImageFn } = params;
// Run all downloads in parallel for speed
const [
@@ -251,11 +223,11 @@ export async function downloadAdditionalAssets(params: {
segments,
// Cover images (fire and forget, errors are logged)
] = await Promise.all([
downloadTrickplayImages(item, storagePath),
downloadTrickplayImages(item),
// Only download subtitles for non-transcoded streams
mediaSource.TranscodingUrl
? Promise.resolve(mediaSource)
: downloadSubtitles(mediaSource, item, api.basePath || "", storagePath),
: downloadSubtitles(mediaSource, item, api.basePath || ""),
item.Id
? fetchSegments(item.Id, api)
: Promise.resolve({

View File

@@ -1,4 +1,4 @@
import { Directory, File } from "expo-file-system";
import { Directory, File, Paths } from "expo-file-system";
import { getAllDownloadedItems, getDownloadedItemById } from "./database";
import type { DownloadedItem } from "./types";
import { filePathToUri } from "./utils";
@@ -39,11 +39,13 @@ export function deleteAllAssociatedFiles(item: DownloadedItem): void {
stream.DeliveryUrl
) {
try {
// Use the full path from DeliveryUrl (it's already a full file:// URI)
const subtitleFile = new File(stream.DeliveryUrl);
if (subtitleFile.exists) {
subtitleFile.delete();
console.log(`[DELETE] Subtitle deleted: ${stream.DeliveryUrl}`);
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);
@@ -55,13 +57,15 @@ export function deleteAllAssociatedFiles(item: DownloadedItem): void {
// Delete trickplay directory
if (item.trickPlayData?.path) {
try {
// Use the full path from trickPlayData (it's already a full file:// URI)
const trickplayDir = new Directory(item.trickPlayData.path);
if (trickplayDir.exists) {
trickplayDir.delete();
console.log(
`[DELETE] Trickplay directory deleted: ${item.trickPlayData.path}`,
);
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);

View File

@@ -6,16 +6,13 @@ import { File, Paths } from "expo-file-system";
import type { MutableRefObject } from "react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import DeviceInfo from "react-native-device-info";
import { toast } from "sonner-native";
import type { Bitrate } from "@/components/BitrateSelector";
import useImageStorage from "@/hooks/useImageStorage";
import { BackgroundDownloader } from "@/modules";
import { useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device";
import useDownloadHelper from "@/utils/download";
import { getStoragePath } from "@/utils/storage";
import { downloadAdditionalAssets } from "../additionalDownloads";
import {
clearAllDownloadedItems,
@@ -52,7 +49,6 @@ export function useDownloadOperations({
onDataChange,
}: UseDownloadOperationsProps) {
const { t } = useTranslation();
const { settings } = useSettings();
const { saveSeriesPrimaryImage } = useDownloadHelper();
const { saveImage } = useImageStorage();
@@ -83,12 +79,6 @@ export function useDownloadOperations({
return;
}
// Get storage path if custom location is set
let storagePath: string | undefined;
if (Platform.OS === "android" && settings.downloadStorageLocation) {
storagePath = await getStoragePath(settings.downloadStorageLocation);
}
// Download all additional assets BEFORE starting native video download
const additionalAssets = await downloadAdditionalAssets({
item,
@@ -96,7 +86,6 @@ export function useDownloadOperations({
api,
saveImageFn: saveImage,
saveSeriesImageFn: saveSeriesPrimaryImage,
storagePath,
});
// Ensure URL is absolute (not relative) before storing
@@ -130,19 +119,10 @@ export function useDownloadOperations({
// Add to processes
setProcesses((prev) => [...prev, jobStatus]);
// Generate destination path using custom storage location if set
// Generate destination path
const filename = generateFilename(item);
let destinationPath: string;
if (storagePath) {
// Use custom storage location
destinationPath = `${storagePath}/${filename}.mp4`;
console.log(`[DOWNLOAD] Using custom storage: ${destinationPath}`);
} else {
// Use default Paths.document
const videoFile = new File(Paths.document, `${filename}.mp4`);
destinationPath = uriToFilePath(videoFile.uri);
}
const videoFile = new File(Paths.document, `${filename}.mp4`);
const destinationPath = uriToFilePath(videoFile.uri);
console.log(`[DOWNLOAD] Starting video: ${item.Name}`);
console.log(`[DOWNLOAD] Download URL: ${downloadUrl}`);

View File

@@ -1,13 +1,8 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type * as NotificationsType from "expo-notifications";
import * as Notifications from "expo-notifications";
import type { TFunction } from "i18next";
import { Platform } from "react-native";
// Conditionally import expo-notifications only on non-TV platforms
const Notifications = Platform.isTV
? null
: (require("expo-notifications") as typeof NotificationsType);
/**
* Generate notification content based on item type
*/
@@ -65,7 +60,7 @@ export async function sendDownloadNotification(
body: string,
data?: Record<string, any>,
): Promise<void> {
if (Platform.isTV || !Notifications) return;
if (Platform.isTV) return;
try {
await Notifications.scheduleNotificationAsync({

View File

@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.48.0" },
clientInfo: { name: "Streamyfin", version: "0.47.1" },
deviceInfo: {
name: deviceName,
id,
@@ -87,7 +87,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.48.0"`,
}, DeviceId="${deviceId}", Version="0.47.1"`,
};
}, [deviceId]);

View File

@@ -3,51 +3,18 @@
const isTV = process.env?.EXPO_TV === "1";
const disableForTV = (_moduleName) =>
isTV
? {
platforms: {
ios: null,
android: null,
},
}
: undefined;
const dependencies = {
"react-native-volume-manager": !isTV
? {
platforms: {
// leaving this blank seems to enable auto-linking which is what we want for mobile
},
}
: {
platforms: {
android: null,
},
},
"expo-notifications": disableForTV("expo-notifications"),
"react-native-image-colors": disableForTV("react-native-image-colors"),
"expo-sharing": disableForTV("expo-sharing"),
"expo-haptics": disableForTV("expo-haptics"),
"expo-brightness": disableForTV("expo-brightness"),
"expo-sensors": disableForTV("expo-sensors"),
"expo-screen-orientation": disableForTV("expo-screen-orientation"),
"react-native-ios-context-menu": disableForTV(
"react-native-ios-context-menu",
),
"react-native-ios-utilities": disableForTV("react-native-ios-utilities"),
"react-native-pager-view": disableForTV("react-native-pager-view"),
};
// Filter out undefined values
const cleanDependencies = Object.fromEntries(
Object.entries(dependencies).filter(([_, value]) => value !== undefined),
);
module.exports = {
dependencies: cleanDependencies,
project: {
ios: {},
android: {},
dependencies: {
"react-native-volume-manager": !isTV
? {
platforms: {
// leaving this blank seems to enable auto-linking which is what we want for mobile
},
}
: {
platforms: {
android: null,
},
},
},
};

View File

@@ -1,10 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: ["./app/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {},
},
plugins: [],
};

View File

@@ -337,8 +337,7 @@
"audio": "Audio",
"subtitle": "Subtitle",
"play": "Play",
"none": "None",
"track": "Track"
"none": "None"
},
"search": {
"search": "Search...",
@@ -445,7 +444,6 @@
"no_similar_items_found": "No Similar Items Found",
"video": "Video",
"more_details": "More Details",
"media_options": "Media Options",
"quality": "Quality",
"audio": "Audio",
"subtitles": "Subtitle",

View File

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

View File

@@ -26,7 +26,7 @@ export type DownloadOption = {
};
export const ScreenOrientationEnum: Record<
(typeof ScreenOrientation.OrientationLock)[keyof typeof ScreenOrientation.OrientationLock],
ScreenOrientation.OrientationLock,
string
> = {
[ScreenOrientation.OrientationLock.DEFAULT]:
@@ -145,7 +145,6 @@ export type Settings = {
marlinServerUrl?: string;
openInVLC?: boolean;
downloadQuality?: DownloadOption;
downloadStorageLocation?: string;
defaultBitrate?: Bitrate;
libraryOptions: LibraryOptions;
defaultAudioLanguage: CultureDto | null;
@@ -155,7 +154,7 @@ export type Settings = {
subtitleMode: SubtitlePlaybackMode;
rememberSubtitleSelections: boolean;
showHomeTitles: boolean;
defaultVideoOrientation: (typeof ScreenOrientation.OrientationLock)[keyof typeof ScreenOrientation.OrientationLock];
defaultVideoOrientation: ScreenOrientation.OrientationLock;
forwardSkipTime: number;
rewindSkipTime: number;
showCustomMenuLinks: boolean;
@@ -204,7 +203,6 @@ export const defaultValues: Settings = {
marlinServerUrl: "",
openInVLC: false,
downloadQuality: DownloadOptions[0],
downloadStorageLocation: undefined,
defaultBitrate: BITRATES[0],
libraryOptions: {
display: "list",

View File

@@ -1,143 +0,0 @@
import { Directory, Paths } from "expo-file-system";
import { Platform } from "react-native";
import { BackgroundDownloader, type StorageLocation } from "@/modules";
let cachedStorageLocations: StorageLocation[] | null = null;
// Debug mode: Set to true to simulate an SD card for testing in emulator
// This creates a real writable directory that mimics SD card behavior
const DEBUG_SIMULATE_SD_CARD = false;
/**
* Get all available storage locations (Android only)
* Returns cached result on subsequent calls
*/
export async function getAvailableStorageLocations(): Promise<
StorageLocation[]
> {
if (Platform.OS !== "android") {
return [];
}
if (cachedStorageLocations !== null) {
return cachedStorageLocations;
}
try {
const locations = await BackgroundDownloader.getAvailableStorageLocations();
// Debug mode: Add a functional simulated SD card for testing
if (DEBUG_SIMULATE_SD_CARD && locations.length === 1) {
// Use a real writable path within the app's document directory
const sdcardSimDir = new Directory(Paths.document, "sdcard_sim");
// Create the directory if it doesn't exist
if (!sdcardSimDir.exists) {
sdcardSimDir.create({ intermediates: true });
}
const mockSdCard: StorageLocation = {
id: "sdcard_sim",
path: sdcardSimDir.uri.replace("file://", ""),
type: "external",
label: "SD Card (Simulated)",
totalSpace: 64 * 1024 * 1024 * 1024, // 64 GB
freeSpace: 32 * 1024 * 1024 * 1024, // 32 GB free
};
locations.push(mockSdCard);
console.log("[DEBUG] Added simulated SD card:", mockSdCard.path);
}
cachedStorageLocations = locations;
return locations;
} catch (error) {
console.error("Failed to get storage locations:", error);
return [];
}
}
/**
* Clear the cached storage locations
* Useful when storage configuration might have changed
*/
export function clearStorageLocationsCache(): void {
cachedStorageLocations = null;
console.log("[Storage] Cache cleared");
}
/**
* Get a simplified label for a storage location ID
* @param storageId - The storage location ID (e.g., "internal", "sdcard_0")
* @returns Human-readable label (e.g., "Internal Storage", "SD Card")
*/
export async function getStorageLabel(storageId?: string): Promise<string> {
if (!storageId || Platform.OS !== "android") {
return "Internal Storage";
}
const locations = await getAvailableStorageLocations();
const location = locations.find((loc) => loc.id === storageId);
return location?.label || "Internal Storage";
}
/**
* Get the filesystem path for a storage location ID
* @param storageId - The storage location ID (e.g., "internal", "sdcard_0")
* @returns The filesystem path, or default path if not found
*/
export async function getStoragePath(storageId?: string): Promise<string> {
if (!storageId || Platform.OS !== "android") {
return getDefaultStoragePath();
}
const locations = await getAvailableStorageLocations();
const location = locations.find((loc) => loc.id === storageId);
if (!location) {
console.warn(`Storage location not found: ${storageId}, using default`);
return getDefaultStoragePath();
}
return location.path;
}
/**
* Get the default storage path (current behavior using Paths.document)
* @returns The default storage path
*/
export function getDefaultStoragePath(): string {
// Paths.document returns a Directory with a URI like "file:///data/user/0/..."
// We need to extract the actual path
const uri = Paths.document.uri;
return uri.replace("file://", "");
}
/**
* Get a storage location by ID
* @param storageId - The storage location ID
* @returns The storage location or undefined if not found
*/
export async function getStorageLocationById(
storageId?: string,
): Promise<StorageLocation | undefined> {
if (!storageId || Platform.OS !== "android") {
return undefined;
}
const locations = await getAvailableStorageLocations();
return locations.find((loc) => loc.id === storageId);
}
/**
* Convert plain file path to file:// URI
* Required for expo-file-system File constructor
* @param path - The file path
* @returns The file:// URI
*/
export function filePathToUri(path: string): string {
if (path.startsWith("file://")) {
return path;
}
return `file://${path}`;
}