Merge branch 'develop' into feature/newarch

This commit is contained in:
Fredrik Burmester
2025-09-29 15:07:23 +02:00
38 changed files with 2092 additions and 448 deletions

View File

@@ -31,13 +31,13 @@ jobs:
fetch-depth: 0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4
uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4
uses: github/codeql-action/autobuild@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4
uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5

View File

@@ -57,7 +57,7 @@ jobs:
fetch-depth: 0
- name: Dependency Review
uses: actions/dependency-review-action@595b5aeba73380359d98a5e087f648dbb0edce1b # v4.7.3
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
with:
fail-on-severity: high
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.36.0",
"version": "0.38.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -37,7 +37,7 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 69,
"versionCode": 70,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",

View File

@@ -12,7 +12,7 @@ export default function CustomMenuLayout() {
headerShown: true,
headerLargeTitle: true,
headerTitle: t("tabs.custom_links"),
headerBlurEffect: "prominent",
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}

View File

@@ -11,12 +11,8 @@ export default function SearchLayout() {
name='index'
options={{
headerShown: !Platform.isTV,
headerLargeTitle: true,
headerTitle: t("tabs.favorites"),
headerLargeStyle: {
backgroundColor: "black",
},
headerBlurEffect: "prominent",
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}

View File

@@ -21,19 +21,16 @@ export default function IndexLayout() {
name='index'
options={{
headerShown: !Platform.isTV,
headerLargeTitle: true,
headerTitle: t("tabs.home"),
headerBlurEffect: "prominent",
headerLargeStyle: {
backgroundColor: "black",
},
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerRight: () => (
<View className='flex flex-row items-center space-x-2'>
<View className='flex flex-row items-center px-2'>
{!Platform.isTV && (
<>
<Chromecast.Chromecast />
<Chromecast.Chromecast background='transparent' />
{user?.Policy?.IsAdministrator && <SessionsButton />}
<SettingsButton />
</>
@@ -138,14 +135,13 @@ const SessionsButton = () => {
onPress={() => {
router.push("/(auth)/sessions");
}}
className='mr-4'
>
<View className='mr-4'>
<Ionicons
name='play-circle'
color={sessions.length === 0 ? "white" : "#9333ea"}
size={25}
/>
</View>
<Ionicons
name='play-circle'
color={sessions.length === 0 ? "white" : "#9333ea"}
size={28}
/>
</TouchableOpacity>
);
};

View File

@@ -468,6 +468,7 @@ const TranscodingStreamView = ({
};
const TranscodingView = ({ session }: SessionCardProps) => {
const { t } = useTranslation();
const videoStream = useMemo(() => {
return session.NowPlayingItem?.MediaStreams?.filter(
(s) => s.Type === "Video",
@@ -501,7 +502,7 @@ const TranscodingView = ({ session }: SessionCardProps) => {
return (
<View className='flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2'>
<TranscodingStreamView
title='Video'
title={t("common.video")}
properties={{
resolution: videoStreamTitle(),
bitrate: videoStream?.BitRate,
@@ -518,7 +519,7 @@ const TranscodingView = ({ session }: SessionCardProps) => {
/>
<TranscodingStreamView
title='Audio'
title={t("common.audio")}
properties={{
language: audioStream?.Language,
bitrate: audioStream?.BitRate,
@@ -536,7 +537,7 @@ const TranscodingView = ({ session }: SessionCardProps) => {
{subtitleStream && (
<TranscodingStreamView
title='Subtitle'
title={t("common.subtitle")}
isTranscoding={false}
properties={{
language: subtitleStream?.Language,

View File

@@ -304,7 +304,7 @@ const Page: React.FC = () => {
borderStyle: "solid",
}}
>
<Text className='text-sm'>Play</Text>
<Text className='text-sm'>{t("common.play")}</Text>
</Button>
</View>
)

View File

@@ -1,224 +1,85 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack } from "expo-router";
import { Platform } from "react-native";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity } from "react-native";
import { LibraryOptionsSheet } from "@/components/settings/LibraryOptionsSheet";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useSettings } from "@/utils/atoms/settings";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next";
export default function IndexLayout() {
const { settings, updateSettings, pluginSettings } = useSettings();
const [optionsSheetOpen, setOptionsSheetOpen] = useState(false);
const { t } = useTranslation();
if (!settings?.libraryOptions) return null;
return (
<Stack>
<Stack.Screen
name='index'
options={{
headerShown: !Platform.isTV,
headerLargeTitle: true,
headerTitle: t("tabs.library"),
headerBlurEffect: "prominent",
headerLargeStyle: {
backgroundColor: "black",
},
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerRight: () =>
!pluginSettings?.libraryOptions?.locked &&
!Platform.isTV && (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<>
<Stack>
<Stack.Screen
name='index'
options={{
headerShown: !Platform.isTV,
headerTitle: t("tabs.library"),
headerBlurEffect: "none",
headerTransparent: true,
headerShadowVisible: false,
headerRight: () =>
!pluginSettings?.libraryOptions?.locked &&
!Platform.isTV && (
<TouchableOpacity
onPress={() => setOptionsSheetOpen(true)}
className='flex flex-row items-center justify-center w-9 h-9'
>
<Ionicons
name='ellipsis-horizontal-outline'
size={24}
color='white'
/>
</DropdownMenu.Trigger>
<DropdownMenu.Content
align={"end"}
alignOffset={-10}
avoidCollisions={false}
collisionPadding={0}
loop={false}
side={"bottom"}
sideOffset={10}
>
<DropdownMenu.Label>
{t("library.options.display")}
</DropdownMenu.Label>
<DropdownMenu.Group key='display-group'>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key='image-style-trigger'>
{t("library.options.display")}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
<DropdownMenu.CheckboxItem
key='display-option-1'
value={settings.libraryOptions.display === "row"}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "row",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='display-title-1'>
{t("library.options.row")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key='display-option-2'
value={settings.libraryOptions.display === "list"}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "list",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='display-title-2'>
{t("library.options.list")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key='image-style-trigger'>
{t("library.options.image_style")}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
<DropdownMenu.CheckboxItem
key='poster-option'
value={
settings.libraryOptions.imageStyle === "poster"
}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "poster",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='poster-title'>
{t("library.options.poster")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key='cover-option'
value={settings.libraryOptions.imageStyle === "cover"}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "cover",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='cover-title'>
{t("library.options.cover")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Group>
<DropdownMenu.Group key='show-titles-group'>
<DropdownMenu.CheckboxItem
disabled={settings.libraryOptions.imageStyle === "poster"}
key='show-titles-option'
value={settings.libraryOptions.showTitles}
onValueChange={(newValue: string) => {
if (settings.libraryOptions.imageStyle === "poster")
return;
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showTitles: newValue === "on",
},
});
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='show-titles-title'>
{t("library.options.show_titles")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key='show-stats-option'
value={settings.libraryOptions.showStats}
onValueChange={(newValue: string) => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showStats: newValue === "on",
},
});
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='show-stats-title'>
{t("library.options.show_stats")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.Group>
<DropdownMenu.Separator />
</DropdownMenu.Content>
</DropdownMenu.Root>
),
}}
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='[libraryId]'
options={{
title: "",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: true,
headerShadowVisible: false,
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name='collections/[collectionId]'
options={{
title: "",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: true,
headerShadowVisible: false,
}}
/>
</Stack>
<LibraryOptionsSheet
open={optionsSheetOpen}
setOpen={setOptionsSheetOpen}
settings={settings.libraryOptions}
updateSettings={(options) =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
...options,
},
})
}
disabled={pluginSettings?.libraryOptions?.locked}
/>
<Stack.Screen
name='[libraryId]'
options={{
title: "",
headerShown: !Platform.isTV,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name='collections/[collectionId]'
options={{
title: "",
headerShown: !Platform.isTV,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
</Stack>
</>
);
}

View File

@@ -14,12 +14,8 @@ export default function SearchLayout() {
name='index'
options={{
headerShown: !Platform.isTV,
headerLargeTitle: true,
headerTitle: t("tabs.search"),
headerLargeStyle: {
backgroundColor: "black",
},
headerBlurEffect: "prominent",
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}

View File

@@ -395,12 +395,17 @@ function Layout() {
appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
BackGroundDownloader.checkForExistingDownloads();
BackGroundDownloader.checkForExistingDownloads().catch(
(error: unknown) => {
writeErrorLog("Failed to resume background downloads", error);
},
);
}
});
BackGroundDownloader.checkForExistingDownloads();
BackGroundDownloader.checkForExistingDownloads().catch((error: unknown) => {
writeErrorLog("Failed to resume background downloads", error);
});
return () => {
subscription.remove();
};

118
bun.lock
View File

@@ -4,8 +4,13 @@
"": {
"name": "streamyfin",
"dependencies": {
<<<<<<< HEAD
"@bottom-tabs/react-navigation": "^0.9.2",
"@expo/metro-runtime": "~6.1.1",
=======
"@bottom-tabs/react-navigation": "^0.11.2",
"@expo/metro-runtime": "~5.0.5",
>>>>>>> develop
"@expo/react-native-action-sheet": "^4.1.1",
"@expo/vector-icons": "^15.0.2",
"@gorhom/bottom-sheet": "^5.1.0",
@@ -18,6 +23,7 @@
"@shopify/flash-list": "^1.8.3",
"@tanstack/react-query": "^5.66.0",
"axios": "^1.7.9",
<<<<<<< HEAD
"expo": "^54.0.0-preview.12",
"expo-application": "~7.0.5",
"expo-asset": "~12.0.6",
@@ -44,6 +50,34 @@
"expo-system-ui": "~6.0.5",
"expo-task-manager": "~14.0.5",
"expo-web-browser": "~15.0.5",
=======
"expo": "^53.0.23",
"expo-application": "~6.1.4",
"expo-asset": "~11.1.7",
"expo-background-task": "~0.2.8",
"expo-blur": "~14.1.4",
"expo-brightness": "~13.1.4",
"expo-build-properties": "~0.14.6",
"expo-constants": "~17.1.5",
"expo-dev-client": "^5.2.0",
"expo-device": "~7.1.4",
"expo-font": "~13.3.1",
"expo-haptics": "~14.1.4",
"expo-image": "~2.4.0",
"expo-linear-gradient": "~14.1.4",
"expo-linking": "~7.1.4",
"expo-localization": "~16.1.5",
"expo-notifications": "~0.31.2",
"expo-router": "~5.1.7",
"expo-screen-orientation": "~8.1.6",
"expo-sensors": "~14.1.4",
"expo-sharing": "~13.1.5",
"expo-splash-screen": "~0.30.8",
"expo-status-bar": "~2.2.3",
"expo-system-ui": "~5.0.11",
"expo-task-manager": "~13.1.6",
"expo-web-browser": "~14.2.0",
>>>>>>> develop
"i18next": "^25.0.0",
"jotai": "^2.12.5",
"lodash": "^4.17.21",
@@ -54,7 +88,7 @@
"react-i18next": "^15.4.0",
"react-native": "npm:react-native-tvos@0.81.1-1",
"react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "^0.9.2",
"react-native-bottom-tabs": "^0.11.2",
"react-native-circular-progress": "^1.4.1",
"react-native-collapsible": "^1.6.2",
"react-native-country-flag": "^2.0.2",
@@ -320,7 +354,7 @@
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg=="],
"@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@0.9.2", "", { "dependencies": { "color": "^4.2.3" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-IZZKllcaqCGsKIgeXmYFGU95IXxbBpXtwKws4Lg2GJw/qqAYYsPFEl0JBvnymSD7G1zkHYEilg5UHuTd0NmX7A=="],
"@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@0.11.2", "", { "dependencies": { "color": "^5.0.0" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-xjRZZe3GZ/bIADBkJSe+qjRC/pQKcTEhZgtoGb4lyINq1NPzhKXhlZHwZLzNJng/Q/+F4RD3M7bQ6oCgSHV2WA=="],
"@dominicstop/ts-event-emitter": ["@dominicstop/ts-event-emitter@1.1.0", "", {}, "sha512-CcxmJIvUb1vsFheuGGVSQf4KdPZC44XolpUT34+vlal+LyQoBUOn31pjFET5M9ctOxEpt8xa0M3/2M7uUiAoJw=="],
@@ -328,7 +362,11 @@
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
<<<<<<< HEAD
"@expo/cli": ["@expo/cli@0.26.5", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@expo/code-signing-certificates": "^0.0.5", "@expo/config": "~12.0.5", "@expo/config-plugins": "~11.0.5", "@expo/devcert": "^1.1.2", "@expo/env": "~2.0.4", "@expo/image-utils": "^0.8.4", "@expo/json-file": "^10.0.4", "@expo/metro": "~0.1.1", "@expo/metro-config": "~0.21.6", "@expo/osascript": "^2.3.4", "@expo/package-manager": "^1.9.4", "@expo/plist": "^0.4.4", "@expo/prebuild-config": "^10.0.6", "@expo/schema-utils": "^0.1.4", "@expo/server": "^0.7.2", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.3.0", "@react-native/dev-middleware": "0.81.1", "@urql/core": "^5.0.6", "@urql/exchange-retry": "^1.3.0", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", "freeport-async": "^2.0.0", "getenv": "^2.0.0", "glob": "^10.4.2", "lan-network": "^0.1.6", "minimatch": "^9.0.0", "node-forge": "^1.3.1", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^3.0.1", "pretty-bytes": "^5.6.0", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "qrcode-terminal": "0.11.0", "require-from-string": "^2.0.2", "requireg": "^0.2.2", "resolve": "^1.22.2", "resolve-from": "^5.0.0", "resolve.exports": "^2.0.3", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "tar": "^7.4.3", "terminal-link": "^2.1.1", "undici": "^6.18.2", "wrap-ansi": "^7.0.0", "ws": "^8.12.1" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "build/bin/cli" } }, "sha512-B+z/9OP4NgzupO00JFu/cWtP/zL7IGu1lW96FeV/Xk2aYEpTmPjXbFv0EECxm0N6qsgn6UdBCUVFrPyUsD03mg=="],
=======
"@expo/cli": ["@expo/cli@0.24.22", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@babel/runtime": "^7.20.0", "@expo/code-signing-certificates": "^0.0.5", "@expo/config": "~11.0.13", "@expo/config-plugins": "~10.1.2", "@expo/devcert": "^1.1.2", "@expo/env": "~1.0.7", "@expo/image-utils": "^0.7.6", "@expo/json-file": "^9.1.5", "@expo/metro-config": "~0.20.17", "@expo/osascript": "^2.2.5", "@expo/package-manager": "^1.8.6", "@expo/plist": "^0.3.5", "@expo/prebuild-config": "^9.0.12", "@expo/schema-utils": "^0.1.0", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.3.0", "@react-native/dev-middleware": "0.79.6", "@urql/core": "^5.0.6", "@urql/exchange-retry": "^1.3.0", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", "freeport-async": "^2.0.0", "getenv": "^2.0.0", "glob": "^10.4.2", "lan-network": "^0.1.6", "minimatch": "^9.0.0", "node-forge": "^1.3.1", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^3.0.1", "pretty-bytes": "^5.6.0", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "qrcode-terminal": "0.11.0", "require-from-string": "^2.0.2", "requireg": "^0.2.2", "resolve": "^1.22.2", "resolve-from": "^5.0.0", "resolve.exports": "^2.0.3", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "tar": "^7.4.3", "terminal-link": "^2.1.1", "undici": "^6.18.2", "wrap-ansi": "^7.0.0", "ws": "^8.12.1" }, "bin": { "expo-internal": "build/bin/cli" } }, "sha512-cEg6/F8ZWjoVkEwm0rXKReWbsCUROFbLFBYht+d5RzHnDwJoTX4QWJKx4m+TGNDPamRUIGw36U4z41Fvev0XmA=="],
>>>>>>> develop
"@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.5", "", { "dependencies": { "node-forge": "^1.2.1", "nullthrows": "^1.1.1" } }, "sha512-BNhXkY1bblxKZpltzAx98G2Egj9g1Q+JRcvR7E99DOj862FTCX+ZPsAUtPTr7aHxwtrL7+fL3r0JSmM9kBm+Bw=="],
@@ -350,7 +388,11 @@
"@expo/json-file": ["@expo/json-file@10.0.5", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "json5": "^2.2.3" } }, "sha512-M2ebw0NwGUSxQuFVV40b+Vx4kR7YjZ2A/HFnX/l74622B8qlMj54RktT6R7v5mS1ExMqZdL8y0ldPBPD4+s+vA=="],
<<<<<<< HEAD
"@expo/metro": ["@expo/metro@0.1.1", "", { "dependencies": { "metro": "0.83.1", "metro-babel-transformer": "0.83.1", "metro-cache": "0.83.1", "metro-cache-key": "0.83.1", "metro-config": "0.83.1", "metro-core": "0.83.1", "metro-file-map": "0.83.1", "metro-resolver": "0.83.1", "metro-runtime": "0.83.1", "metro-source-map": "0.83.1", "metro-transform-plugins": "0.83.1", "metro-transform-worker": "0.83.1" } }, "sha512-zvA9BE6myFoCxeiw/q3uE/kVkIwLTy27a+fDoEl7WQ7EvKfFeiXnRVhUplDMLGZIHH8VC38Gay6RBtVhnmOm5w=="],
=======
"@expo/metro-runtime": ["@expo/metro-runtime@5.0.5", "", { "peerDependencies": { "react-native": "*" } }, "sha512-P8UFTi+YsmiD1BmdTdiIQITzDMcZgronsA3RTQ4QKJjHM3bas11oGzLQOnFaIZnlEV8Rrr3m1m+RHxvnpL+t/A=="],
>>>>>>> develop
"@expo/metro-config": ["@expo/metro-config@0.21.7", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@expo/config": "~12.0.6", "@expo/env": "~2.0.5", "@expo/json-file": "~10.0.5", "@expo/metro": "~0.1.1", "@expo/spawn-async": "^1.7.2", "browserslist": "^4.25.0", "chalk": "^4.1.0", "debug": "^4.3.2", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0", "glob": "^10.4.2", "hermes-parser": "^0.29.1", "jsc-safe-url": "^0.2.4", "lightningcss": "~1.27.0", "minimatch": "^9.0.0", "postcss": "~8.4.32", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*" }, "optionalPeers": ["expo"] }, "sha512-YOSHgrAhOa3ulwXkte0K2ziVfQdzxT1JbY+FuuXRSZUXFGTInOc1iRP3F2W8B8SydPvn18Ys6J6RWp+65nLu+w=="],
@@ -838,13 +880,13 @@
"clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"color": ["color@5.0.2", "", { "dependencies": { "color-convert": "^3.0.1", "color-string": "^2.0.0" } }, "sha512-e2hz5BzbUPcYlIRHo8ieAhYgoajrJr+hWoceg6E345TPsATMUKqDgzt8fSXZJJbxfpiPzkWyphz8yn8At7q3fA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-convert": ["color-convert@3.1.2", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-UNqkvCDXstVck3kdowtOTWROIJQwafjOfXSmddoDrXo4cewMKmusCeF22Q24zvjR8nwWib/3S/dfyzPItPEiJg=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"color-name": ["color-name@2.0.2", "", {}, "sha512-9vEt7gE16EW7Eu7pvZnR0abW9z6ufzhXxGXZEVU9IqPdlsUiMwJeJfRtq0zePUmnbHGT9zajca7mX8zgoayo4A=="],
"color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"color-string": ["color-string@2.1.2", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA=="],
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
@@ -1004,7 +1046,11 @@
"expect": ["expect@29.7.0", "", { "dependencies": { "@jest/expect-utils": "^29.7.0", "jest-get-type": "^29.6.3", "jest-matcher-utils": "^29.7.0", "jest-message-util": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw=="],
<<<<<<< HEAD
"expo": ["expo@54.0.0-preview.12", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "0.26.5", "@expo/config": "~12.0.6", "@expo/config-plugins": "~11.0.6", "@expo/devtools": "0.1.5", "@expo/fingerprint": "0.14.5", "@expo/metro": "~0.1.1", "@expo/metro-config": "0.21.7", "@expo/vector-icons": "^15.0.2", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~14.0.5", "expo-asset": "~12.0.6", "expo-constants": "~18.0.6", "expo-file-system": "~19.0.7", "expo-font": "~14.0.6", "expo-keep-awake": "~15.0.5", "expo-modules-autolinking": "3.0.5", "expo-modules-core": "3.0.10", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "expo-modules-autolinking": "bin/autolinking", "fingerprint": "bin/fingerprint" } }, "sha512-ZThlIWhHlDkKak4eOCm3Gbs5JCxXDLDEekbCLLMz9aOViM4yFtuTXbOFAGEJc6C5qrRdF+cwUj8yCgpTkjpqlA=="],
=======
"expo": ["expo@53.0.23", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "0.24.22", "@expo/config": "~11.0.13", "@expo/config-plugins": "~10.1.2", "@expo/fingerprint": "0.13.4", "@expo/metro-config": "0.20.17", "@expo/vector-icons": "^14.0.0", "babel-preset-expo": "~13.2.4", "expo-asset": "~11.1.7", "expo-constants": "~17.1.7", "expo-file-system": "~18.1.11", "expo-font": "~13.3.2", "expo-keep-awake": "~14.1.4", "expo-modules-autolinking": "2.1.14", "expo-modules-core": "2.5.0", "react-native-edge-to-edge": "1.6.0", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-6TOLuNCP3AsSkXBJA5W6U/7wpZUop3Q6BxHMtRD2OOgT7CCPvnYgJdnTzqU+gD1hMfcryD8Ejq9RdHbLduXohg=="],
>>>>>>> develop
"expo-application": ["expo-application@7.0.5", "", { "peerDependencies": { "expo": "*" } }, "sha512-CjBf80j470L2QR4H+7dvz7zfdyf0JTYZTAE+nfcUJfMNJ3cWYIqRadwUTfbWDHpYA4NbKr+G0GtHVejHwVQKMQ=="],
@@ -1058,7 +1104,11 @@
"expo-notifications": ["expo-notifications@0.32.7", "", { "dependencies": { "@expo/image-utils": "^0.8.5", "@ide/backoff": "^1.0.0", "abort-controller": "^3.0.0", "assert": "^2.0.0", "badgin": "^1.1.5", "expo-application": "~7.0.5", "expo-constants": "~18.0.6" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-EW27U063Mjfa/o5ZUq3CtlTBtxigGvPpdtvxGreZ7XpdXrbBezPI9BgXS1OUJd657XFn5ERb6uxkt412gZO9dA=="],
<<<<<<< HEAD
"expo-router": ["expo-router@6.0.0-preview.12", "", { "dependencies": { "@expo/metro-runtime": "6.1.1", "@expo/schema-utils": "^0.1.5", "@expo/server": "^0.7.2", "@radix-ui/react-slot": "1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/native": "^7.1.8", "@react-navigation/native-stack": "^7.3.16", "client-only": "^0.0.1", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.1.6", "semver": "~7.6.3", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "use-latest-callback": "^0.2.1", "vaul": "^1.1.2" }, "peerDependencies": { "@react-navigation/drawer": "^7.5.0", "@testing-library/react-native": ">= 12.0.0", "expo": "*", "expo-constants": "^18.0.6", "expo-linking": "^8.0.6", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-screens": "*", "react-native-web": "*", "react-server-dom-webpack": ">= 19.0.0" }, "optionalPeers": ["@react-navigation/drawer", "@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-pvToyw8ZYHlKljfuwL9MmRpgGjqCclySqv1C09QhlRlED/3WhBrnNHfxqQD4BlFUBiVgqAXpIeGSbi5GsWD7oQ=="],
=======
"expo-router": ["expo-router@5.1.7", "", { "dependencies": { "@expo/metro-runtime": "5.0.5", "@expo/schema-utils": "^0.1.0", "@expo/server": "^0.6.3", "@radix-ui/react-slot": "1.2.0", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/native": "^7.1.6", "@react-navigation/native-stack": "^7.3.10", "client-only": "^0.0.1", "invariant": "^2.2.4", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.1.6", "semver": "~7.6.3", "server-only": "^0.0.1", "shallowequal": "^1.1.0" }, "peerDependencies": { "@react-navigation/drawer": "^7.3.9", "expo": "*", "expo-constants": "*", "expo-linking": "*", "react-native-reanimated": "*", "react-native-safe-area-context": "*", "react-native-screens": "*" }, "optionalPeers": ["@react-navigation/drawer", "react-native-reanimated"] }, "sha512-E7hIqTZs4Cub4sbYPeednfYPi+2cyRGMdqc5IYBJ/vC+WBKoYJ8C9eU13ZLbPz//ZybSo2Dsm7v89uFIlO2Gow=="],
>>>>>>> develop
"expo-screen-orientation": ["expo-screen-orientation@9.0.5", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-WpJfH9Rzy9QM5Kr/BQ6LGWLRKbYhazCTQs/qNnZexiCI8p/hzPLaED/mGYTYfn6rPO4XQhp5KL8uC8ko3CFu8g=="],
@@ -1646,7 +1696,7 @@
"react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="],
"react-native-bottom-tabs": ["react-native-bottom-tabs@0.9.2", "", { "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-kwLx9OM6v5P10TdmNhlEgb8nmwBOpwy3ULIxEv1v6cYjzuRkeYtA2dqYeFhJAn1rmWMrl3MnL3xzW5Q3IQyfAg=="],
"react-native-bottom-tabs": ["react-native-bottom-tabs@0.11.2", "", { "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-2zvR9DgQgqOKhxGeETkphXANDkMyUKN/i0+M+WF52JQd4q4h+uY3ctLnXNQ4pZf1cEDlWQ6aBtYWe3NJKvDIwA=="],
"react-native-circular-progress": ["react-native-circular-progress@1.4.1", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=16.0.0", "react-native": ">=0.50.0", "react-native-svg": ">=7.0.0" } }, "sha512-HEzvI0WPuWvsCgWE3Ff2HBTMgAEQB2GvTFw0KHyD/t1STAlDDRiolu0mEGhVvihKR3jJu3v3V4qzvSklY/7XzQ=="],
@@ -2050,6 +2100,8 @@
"@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/@expo/prebuild-config": ["@expo/prebuild-config@9.0.12", "", { "dependencies": { "@expo/config": "~11.0.13", "@expo/config-plugins": "~10.1.2", "@expo/config-types": "^53.0.5", "@expo/image-utils": "^0.7.6", "@expo/json-file": "^9.1.5", "@react-native/normalize-colors": "0.79.6", "debug": "^4.3.1", "resolve-from": "^5.0.0", "semver": "^7.6.0", "xml2js": "0.6.0" } }, "sha512-AKH5Scf+gEMgGxZZaimrJI2wlUJlRoqzDNn7/rkhZa5gUTnO4l6slKak2YdaH+nXlOWCNfAQWa76NnpQIfmv6Q=="],
"@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=="],
@@ -2144,6 +2196,19 @@
"@react-native/community-cli-plugin/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
<<<<<<< HEAD
=======
"@react-native/dev-middleware/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"@react-native/dev-middleware/open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
"@react-navigation/bottom-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"@react-navigation/elements/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"@react-navigation/material-top-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
>>>>>>> develop
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"ansi-fragments/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
@@ -2152,7 +2217,11 @@
"ansi-fragments/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
<<<<<<< HEAD
"babel-jest/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
=======
"ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
>>>>>>> develop
"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=="],
@@ -2358,13 +2427,31 @@
"@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
"@react-navigation/bottom-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"@react-navigation/bottom-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"@react-navigation/elements/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"@react-navigation/elements/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"@react-navigation/material-top-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"@react-navigation/material-top-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
"ansi-fragments/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@2.0.0", "", {}, "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w=="],
"ansi-fragments/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
<<<<<<< HEAD
"babel-preset-expo/babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
=======
"ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"better-opn/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
>>>>>>> develop
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
@@ -2454,6 +2541,23 @@
"@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
<<<<<<< HEAD
=======
"@react-native/community-cli-plugin/@react-native/dev-middleware/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"@react-navigation/bottom-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"@react-navigation/bottom-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"@react-navigation/elements/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"@react-navigation/elements/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"@react-navigation/material-top-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"@react-navigation/material-top-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
>>>>>>> develop
"ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"babel-preset-expo/babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],

View File

@@ -1,6 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { FC } from "react";
import { View, type ViewProps } from "react-native";
import { Platform, View, type ViewProps } from "react-native";
import { RoundButton } from "@/components/RoundButton";
import { useFavorite } from "@/hooks/useFavorite";
@@ -11,6 +11,18 @@ interface Props extends ViewProps {
export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
const { isFavorite, toggleFavorite } = useFavorite(item);
if (Platform.OS === "ios") {
return (
<View {...props}>
<RoundButton
size='large'
icon={isFavorite ? "heart" : "heart-outline"}
onPress={toggleFavorite}
/>
</View>
);
}
return (
<View {...props}>
<RoundButton

View File

@@ -0,0 +1,776 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getTvShowsApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Dimensions, Pressable, TouchableOpacity, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
Easing,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { ItemImage } from "./common/ItemImage";
import { getItemNavigation } from "./common/TouchableItemRouter";
import type { SelectedOptions } from "./ItemContent";
import { PlayButton } from "./PlayButton";
import { PlayedStatus } from "./PlayedStatus";
interface AppleTVCarouselProps {
initialIndex?: number;
onItemChange?: (index: number) => void;
}
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
// Layout Constants
const CAROUSEL_HEIGHT = screenHeight / 1.45;
const GRADIENT_HEIGHT_TOP = 150;
const GRADIENT_HEIGHT_BOTTOM = 150;
const LOGO_HEIGHT = 80;
// Position Constants
const LOGO_BOTTOM_POSITION = 210;
const GENRES_BOTTOM_POSITION = 170;
const CONTROLS_BOTTOM_POSITION = 100;
const DOTS_BOTTOM_POSITION = 60;
// Size Constants
const DOT_HEIGHT = 6;
const DOT_ACTIVE_WIDTH = 20;
const DOT_INACTIVE_WIDTH = 12;
const PLAY_BUTTON_SKELETON_HEIGHT = 50;
const PLAYED_STATUS_SKELETON_SIZE = 40;
const TEXT_SKELETON_HEIGHT = 20;
const TEXT_SKELETON_WIDTH = 250;
const _EMPTY_STATE_ICON_SIZE = 64;
// Spacing Constants
const HORIZONTAL_PADDING = 40;
const DOT_PADDING = 2;
const DOT_GAP = 4;
const CONTROLS_GAP = 20;
const _TEXT_MARGIN_TOP = 16;
// Border Radius Constants
const DOT_BORDER_RADIUS = 3;
const LOGO_SKELETON_BORDER_RADIUS = 8;
const TEXT_SKELETON_BORDER_RADIUS = 4;
const PLAY_BUTTON_BORDER_RADIUS = 25;
const PLAYED_STATUS_BORDER_RADIUS = 20;
// Animation Constants
const DOT_ANIMATION_DURATION = 300;
const CAROUSEL_TRANSITION_DURATION = 250;
const PAN_ACTIVE_OFFSET = 10;
const TRANSLATION_THRESHOLD = 0.2;
const VELOCITY_THRESHOLD = 400;
// Text Constants
const GENRES_FONT_SIZE = 16;
const _EMPTY_STATE_FONT_SIZE = 18;
const TEXT_SHADOW_RADIUS = 2;
const MAX_GENRES_COUNT = 2;
const MAX_BUTTON_WIDTH = 300;
// Opacity Constants
const OVERLAY_OPACITY = 0.4;
const DOT_INACTIVE_OPACITY = 0.6;
const TEXT_OPACITY = 0.9;
// Color Constants
const SKELETON_BACKGROUND_COLOR = "#1a1a1a";
const SKELETON_ELEMENT_COLOR = "#333";
const SKELETON_ACTIVE_DOT_COLOR = "#666";
const _EMPTY_STATE_COLOR = "#666";
const TEXT_SHADOW_COLOR = "rgba(0, 0, 0, 0.8)";
const LOGO_WIDTH_PERCENTAGE = "80%";
const DotIndicator = ({
index,
currentIndex,
onPress,
}: {
index: number;
currentIndex: number;
onPress: (index: number) => void;
}) => {
const isActive = index === currentIndex;
const animatedStyle = useAnimatedStyle(() => ({
width: withTiming(isActive ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH, {
duration: DOT_ANIMATION_DURATION,
easing: Easing.out(Easing.quad),
}),
opacity: withTiming(isActive ? 1 : DOT_INACTIVE_OPACITY, {
duration: DOT_ANIMATION_DURATION,
easing: Easing.out(Easing.quad),
}),
}));
return (
<Pressable
onPress={() => onPress(index)}
style={{
padding: DOT_PADDING, // Increase touch area
}}
>
<Animated.View
style={[
{
height: DOT_HEIGHT,
backgroundColor: isActive ? "white" : "rgba(255, 255, 255, 0.4)",
borderRadius: DOT_BORDER_RADIUS,
},
animatedStyle,
]}
/>
</Pressable>
);
};
export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
initialIndex = 0,
onItemChange,
}) => {
const { settings } = useSettings();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { isConnected, serverConnected } = useNetworkStatus();
const router = useRouter();
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const translateX = useSharedValue(-currentIndex * screenWidth);
const isQueryEnabled =
!!api && !!user?.Id && isConnected && serverConnected === true;
const { data: continueWatchingData, isLoading: continueWatchingLoading } =
useQuery({
queryKey: ["appleTVCarousel", "continueWatching", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Genres"],
limit: 2,
});
return response.data.Items || [];
},
enabled: isQueryEnabled,
staleTime: 60 * 1000,
});
const { data: nextUpData, isLoading: nextUpLoading } = useQuery({
queryKey: ["appleTVCarousel", "nextUp", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getTvShowsApi(api).getNextUp({
userId: user.Id,
fields: ["MediaSourceCount", "Genres"],
limit: 2,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: false,
});
return response.data.Items || [];
},
enabled: isQueryEnabled,
staleTime: 60 * 1000,
});
const { data: recentlyAddedData, isLoading: recentlyAddedLoading } = useQuery(
{
queryKey: ["appleTVCarousel", "recentlyAdded", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getUserLibraryApi(api).getLatestMedia({
userId: user.Id,
limit: 2,
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
});
return response.data || [];
},
enabled: isQueryEnabled,
staleTime: 60 * 1000,
},
);
const items = useMemo(() => {
const continueItems = continueWatchingData ?? [];
const nextItems = nextUpData ?? [];
const recentItems = recentlyAddedData ?? [];
return [
...continueItems.slice(0, 2),
...nextItems.slice(0, 2),
...recentItems.slice(0, 2),
];
}, [continueWatchingData, nextUpData, recentlyAddedData]);
const isLoading =
continueWatchingLoading || nextUpLoading || recentlyAddedLoading;
const hasItems = items.length > 0;
// Only get play settings if we have valid items
const currentItem = hasItems ? items[currentIndex] : null;
// Extract colors for the current item only (for performance)
const currentItemColors = useImageColorsReturn({ item: currentItem });
// Create a fallback empty item for useDefaultPlaySettings when no item is available
const itemForPlaySettings = currentItem || { MediaSources: [] };
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(itemForPlaySettings as BaseItemDto, settings);
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined
>(undefined);
useEffect(() => {
// Only set options if we have valid current item
if (currentItem) {
setSelectedOptions({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
});
} else {
setSelectedOptions(undefined);
}
}, [
defaultAudioIndex,
defaultBitrate,
defaultSubtitleIndex,
defaultMediaSource,
currentIndex,
currentItem,
]);
useEffect(() => {
if (!hasItems) {
setCurrentIndex(initialIndex);
translateX.value = -initialIndex * screenWidth;
return;
}
setCurrentIndex((prev) => {
const newIndex = Math.min(prev, items.length - 1);
translateX.value = -newIndex * screenWidth;
return newIndex;
});
}, [hasItems, items, initialIndex, translateX]);
useEffect(() => {
if (hasItems) {
onItemChange?.(currentIndex);
}
}, [hasItems, currentIndex, onItemChange]);
const goToIndex = useCallback(
(index: number) => {
if (!hasItems || index < 0 || index >= items.length) return;
translateX.value = withTiming(-index * screenWidth, {
duration: CAROUSEL_TRANSITION_DURATION, // Slightly longer for smoother feel
easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), // iOS-like smooth deceleration curve
});
setCurrentIndex(index);
onItemChange?.(index);
},
[hasItems, items, onItemChange, translateX],
);
const navigateToItem = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, "(home)");
router.push(navigation as any);
},
[router],
);
const panGesture = Gesture.Pan()
.activeOffsetX([-PAN_ACTIVE_OFFSET, PAN_ACTIVE_OFFSET])
.onUpdate((event) => {
translateX.value = -currentIndex * screenWidth + event.translationX;
})
.onEnd((event) => {
const velocity = event.velocityX;
const translation = event.translationX;
let newIndex = currentIndex;
// Improved thresholds for more responsive navigation
if (
Math.abs(translation) > screenWidth * TRANSLATION_THRESHOLD ||
Math.abs(velocity) > VELOCITY_THRESHOLD
) {
if (translation > 0 && currentIndex > 0) {
newIndex = currentIndex - 1;
} else if (
translation < 0 &&
items &&
currentIndex < items.length - 1
) {
newIndex = currentIndex + 1;
}
}
runOnJS(goToIndex)(newIndex);
});
const containerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [{ translateX: translateX.value }],
};
});
const renderDots = () => {
if (!hasItems || items.length <= 1) return null;
return (
<View
style={{
position: "absolute",
bottom: DOTS_BOTTOM_POSITION,
left: 0,
right: 0,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: DOT_GAP,
}}
>
{items.map((_, index) => (
<DotIndicator
key={index}
index={index}
currentIndex={currentIndex}
onPress={goToIndex}
/>
))}
</View>
);
};
const renderSkeletonLoader = () => {
return (
<View
style={{
width: screenWidth,
height: CAROUSEL_HEIGHT,
backgroundColor: "#000",
}}
>
{/* Background Skeleton */}
<View
style={{
width: "100%",
height: "100%",
backgroundColor: SKELETON_BACKGROUND_COLOR,
position: "absolute",
}}
/>
{/* Dark Overlay Skeleton */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
}}
/>
{/* Gradient Fade to Black Top Skeleton */}
<LinearGradient
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.8)", "transparent"]}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
height: GRADIENT_HEIGHT_TOP,
}}
/>
{/* Gradient Fade to Black Bottom Skeleton */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: GRADIENT_HEIGHT_BOTTOM,
}}
/>
{/* Logo Skeleton */}
<View
style={{
position: "absolute",
bottom: LOGO_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<View
style={{
height: LOGO_HEIGHT,
width: LOGO_WIDTH_PERCENTAGE,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: LOGO_SKELETON_BORDER_RADIUS,
}}
/>
</View>
{/* Type and Genres Skeleton */}
<View
style={{
position: "absolute",
bottom: GENRES_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<View
style={{
height: TEXT_SKELETON_HEIGHT,
width: TEXT_SKELETON_WIDTH,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
}}
/>
</View>
{/* Controls Skeleton */}
<View
style={{
position: "absolute",
bottom: CONTROLS_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: CONTROLS_GAP,
}}
>
{/* Play Button Skeleton */}
<View
style={{
height: PLAY_BUTTON_SKELETON_HEIGHT,
flex: 1,
maxWidth: MAX_BUTTON_WIDTH,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: PLAY_BUTTON_BORDER_RADIUS,
}}
/>
{/* Played Status Skeleton */}
<View
style={{
width: PLAYED_STATUS_SKELETON_SIZE,
height: PLAYED_STATUS_SKELETON_SIZE,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: PLAYED_STATUS_BORDER_RADIUS,
}}
/>
</View>
{/* Dots Skeleton */}
<View
style={{
position: "absolute",
bottom: DOTS_BOTTOM_POSITION,
left: 0,
right: 0,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: DOT_GAP,
}}
>
{[1, 2, 3].map((_, index) => (
<View
key={index}
style={{
width: index === 0 ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH,
height: DOT_HEIGHT,
backgroundColor:
index === 0
? SKELETON_ACTIVE_DOT_COLOR
: SKELETON_ELEMENT_COLOR,
borderRadius: DOT_BORDER_RADIUS,
}}
/>
))}
</View>
</View>
);
};
const renderItem = (item: BaseItemDto, _index: number) => {
const itemLogoUrl = api ? getLogoImageUrlById({ api, item }) : null;
return (
<View
key={item.Id}
style={{
width: screenWidth,
height: CAROUSEL_HEIGHT,
position: "relative",
}}
>
{/* Background Backdrop */}
<ItemImage
item={item}
variant='Backdrop'
style={{
width: "100%",
height: "100%",
position: "absolute",
}}
/>
{/* Dark Overlay */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
}}
/>
{/* Gradient Fade to Black at Top */}
<LinearGradient
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.2)", "transparent"]}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
height: GRADIENT_HEIGHT_TOP,
}}
/>
{/* Gradient Fade to Black at Bottom */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: GRADIENT_HEIGHT_BOTTOM,
}}
/>
{/* Logo Section */}
{itemLogoUrl && (
<TouchableOpacity
onPress={() => navigateToItem(item)}
style={{
position: "absolute",
bottom: LOGO_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<Image
source={{
uri: itemLogoUrl,
}}
style={{
height: LOGO_HEIGHT,
width: LOGO_WIDTH_PERCENTAGE,
}}
contentFit='contain'
/>
</TouchableOpacity>
)}
{/* Type and Genres Section */}
<View
style={{
position: "absolute",
bottom: GENRES_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<TouchableOpacity onPress={() => navigateToItem(item)}>
<Animated.Text
style={{
color: `rgba(255, 255, 255, ${TEXT_OPACITY})`,
fontSize: GENRES_FONT_SIZE,
fontWeight: "500",
textAlign: "center",
textShadowColor: TEXT_SHADOW_COLOR,
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: TEXT_SHADOW_RADIUS,
}}
>
{(() => {
let typeLabel = "";
if (item.Type === "Episode") {
// For episodes, show season and episode number
const season = item.ParentIndexNumber;
const episode = item.IndexNumber;
if (season && episode) {
typeLabel = `S${season} • E${episode}`;
} else {
typeLabel = "Episode";
}
} else {
typeLabel =
item.Type === "Series"
? "TV Show"
: item.Type === "Movie"
? "Movie"
: item.Type || "";
}
const genres =
item.Genres && item.Genres.length > 0
? item.Genres.slice(0, MAX_GENRES_COUNT).join(" • ")
: "";
if (typeLabel && genres) {
return `${typeLabel}${genres}`;
} else if (typeLabel) {
return typeLabel;
} else if (genres) {
return genres;
} else {
return "";
}
})()}
</Animated.Text>
</TouchableOpacity>
</View>
{/* Controls Section */}
<View
style={{
position: "absolute",
bottom: CONTROLS_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
}}
>
<View
style={{
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: CONTROLS_GAP,
}}
>
{/* Play Button */}
<View style={{ flex: 1, maxWidth: MAX_BUTTON_WIDTH }}>
{selectedOptions && (
<PlayButton
item={item}
selectedOptions={selectedOptions}
colors={currentItemColors}
/>
)}
</View>
{/* Mark as Played */}
<PlayedStatus items={[item]} size='large' />
</View>
</View>
</View>
);
};
// Handle loading state
if (isLoading) {
return (
<View
style={{
height: CAROUSEL_HEIGHT,
backgroundColor: "#000",
overflow: "hidden",
}}
>
{renderSkeletonLoader()}
</View>
);
}
// Handle empty items
if (!hasItems) {
return null;
}
return (
<View
style={{
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
backgroundColor: "#000",
overflow: "hidden",
}}
>
<GestureDetector gesture={panGesture}>
<Animated.View
style={[
{
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
flexDirection: "row",
width: screenWidth * items.length,
},
containerAnimatedStyle,
]}
>
{items.map((item, index) => renderItem(item, index))}
</Animated.View>
</GestureDetector>
{/* Animated Dots Indicator */}
{renderDots()}
</View>
);
};

View File

@@ -1,6 +1,6 @@
import { Feather } from "@expo/vector-icons";
import { useCallback, useEffect } from "react";
import { Platform } from "react-native";
import { Platform, TouchableOpacity } from "react-native";
import GoogleCast, {
CastButton,
CastContext,
@@ -42,6 +42,22 @@ export function Chromecast({
[Platform.OS],
);
if (Platform.OS === "ios") {
return (
<TouchableOpacity
className='mr-4'
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
{...props}
>
<AndroidCastButton />
<Feather name='cast' size={22} color={"white"} />
</TouchableOpacity>
);
}
if (background === "transparent")
return (
<RoundButton

View File

@@ -225,7 +225,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
if (!mediaSource) {
console.error(`Could not get download URL for ${item.Name}`);
toast.error(
t("Could not get download URL for {{itemName}}", {
t("home.downloads.toasts.could_not_get_download_url_for_item", {
itemName: item.Name,
}),
);

View File

@@ -22,7 +22,7 @@ import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -61,7 +61,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
const [user] = useAtom(userAtom);
const { t } = useTranslation();
useImageColors({ item });
const itemColors = useImageColorsReturn({ item });
const [loadingLogo, setLoadingLogo] = useState(true);
const [headerHeight, setHeaderHeight] = useState(350);
@@ -105,13 +105,27 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
if (!Platform.isTV) {
navigation.setOptions({
headerRight: () =>
item && (
item &&
(Platform.OS === "ios" ? (
<View className='flex flex-row items-center pl-2'>
<Chromecast.Chromecast width={22} height={22} />
{item.Type !== "Program" && (
<View className='flex flex-row items-center'>
{!Platform.isTV && (
<DownloadSingleItem item={item} size='large' />
)}
{user?.Policy?.IsAdministrator && (
<PlayInRemoteSessionButton item={item} size='large' />
)}
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
</View>
)}
</View>
) : (
<View className='flex flex-row items-center space-x-2'>
<Chromecast.Chromecast
background='blur'
width={22}
height={22}
/>
<Chromecast.Chromecast width={22} height={22} />
{item.Type !== "Program" && (
<View className='flex flex-row items-center space-x-2'>
{!Platform.isTV && (
@@ -126,7 +140,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
</View>
)}
</View>
),
)),
});
}
}, [item, navigation, user]);
@@ -253,6 +267,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
selectedOptions={selectedOptions}
item={item}
isOffline={isOffline}
colors={itemColors}
/>
</View>

View File

@@ -23,6 +23,7 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
@@ -39,6 +40,7 @@ interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
selectedOptions: SelectedOptions;
isOffline?: boolean;
colors?: ThemeColors;
}
const ANIMATION_DURATION = 500;
@@ -48,6 +50,7 @@ export const PlayButton: React.FC<Props> = ({
item,
selectedOptions,
isOffline,
colors,
...props
}: Props) => {
const { showActionSheetWithOptions } = useActionSheet();
@@ -55,16 +58,19 @@ export const PlayButton: React.FC<Props> = ({
const mediaStatus = useMediaStatus();
const { t } = useTranslation();
const [colorAtom] = useAtom(itemThemeColorAtom);
const [globalColorAtom] = useAtom(itemThemeColorAtom);
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
// Use colors prop if provided, otherwise fallback to global atom
const effectiveColors = colors || globalColorAtom;
const router = useRouter();
const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0);
const endColor = useSharedValue(colorAtom);
const startColor = useSharedValue(colorAtom);
const endColor = useSharedValue(effectiveColors);
const startColor = useSharedValue(effectiveColors);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const { settings, updateSettings } = useSettings();
@@ -297,7 +303,7 @@ export const PlayButton: React.FC<Props> = ({
);
useAnimatedReaction(
() => colorAtom,
() => effectiveColors,
(newColor) => {
endColor.value = newColor;
colorChangeProgress.value = 0;
@@ -306,19 +312,19 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
[colorAtom],
[effectiveColors],
);
useEffect(() => {
const timeout_2 = setTimeout(() => {
startColor.value = colorAtom;
startColor.value = effectiveColors;
startWidth.value = targetWidth.value;
}, ANIMATION_DURATION);
return () => {
clearTimeout(timeout_2);
};
}, [colorAtom, item]);
}, [effectiveColors, item]);
/**
* ANIMATED STYLES
@@ -367,7 +373,7 @@ export const PlayButton: React.FC<Props> = ({
className={"relative"}
{...props}
>
<View className='absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden'>
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
<Animated.View
style={[
animatedPrimaryStyle,
@@ -381,15 +387,15 @@ export const PlayButton: React.FC<Props> = ({
<Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]}
className='absolute w-full h-full top-0 left-0 rounded-xl'
className='absolute w-full h-full top-0 left-0 rounded-full'
/>
<View
style={{
borderWidth: 1,
borderColor: colorAtom.primary,
borderColor: effectiveColors.primary,
borderStyle: "solid",
}}
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
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'>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>

View File

@@ -15,6 +15,7 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { runtimeTicksToMinutes } from "@/utils/time";
@@ -24,6 +25,7 @@ import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
selectedOptions: SelectedOptions;
colors?: ThemeColors;
}
const ANIMATION_DURATION = 500;
@@ -32,16 +34,20 @@ const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({
item,
selectedOptions,
colors,
...props
}: Props) => {
const [colorAtom] = useAtom(itemThemeColorAtom);
const [globalColorAtom] = useAtom(itemThemeColorAtom);
// Use colors prop if provided, otherwise fallback to global atom
const effectiveColors = colors || globalColorAtom;
const router = useRouter();
const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0);
const endColor = useSharedValue(colorAtom);
const startColor = useSharedValue(colorAtom);
const endColor = useSharedValue(effectiveColors);
const startColor = useSharedValue(effectiveColors);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const { settings } = useSettings();
@@ -101,7 +107,7 @@ export const PlayButton: React.FC<Props> = ({
);
useAnimatedReaction(
() => colorAtom,
() => effectiveColors,
(newColor) => {
endColor.value = newColor;
colorChangeProgress.value = 0;
@@ -110,19 +116,19 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
[colorAtom],
[effectiveColors],
);
useEffect(() => {
const timeout_2 = setTimeout(() => {
startColor.value = colorAtom;
startColor.value = effectiveColors;
startWidth.value = targetWidth.value;
}, ANIMATION_DURATION);
return () => {
clearTimeout(timeout_2);
};
}, [colorAtom, item]);
}, [effectiveColors, item]);
/**
* ANIMATED STYLES
@@ -189,7 +195,7 @@ export const PlayButton: React.FC<Props> = ({
<View
style={{
borderWidth: 1,
borderColor: colorAtom.primary,
borderColor: effectiveColors.primary,
borderStyle: "solid",
}}
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '

View File

@@ -1,6 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
import { View, type ViewProps } from "react-native";
import { Platform, View, type ViewProps } from "react-native";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { RoundButton } from "./RoundButton";
@@ -14,6 +14,21 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
const allPlayed = items.every((item) => item.UserData?.Played);
const toggle = useMarkAsPlayed(items);
if (Platform.OS === "ios") {
return (
<View {...props}>
<RoundButton
color={allPlayed ? "purple" : "white"}
icon={allPlayed ? "checkmark" : "checkmark"}
onPress={async () => {
await toggle(!allPlayed);
}}
size={props.size}
/>
</View>
);
}
return (
<View {...props}>
<RoundButton

View File

@@ -10,6 +10,7 @@ interface Props extends ViewProps {
background?: boolean;
size?: "default" | "large";
fillColor?: "primary";
color?: "white" | "purple";
hapticFeedback?: boolean;
}
@@ -20,6 +21,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
children,
size = "default",
fillColor,
color = "white",
hapticFeedback = true,
...viewProps
}) => {
@@ -34,6 +36,25 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
onPress?.();
};
if (Platform.OS === "ios") {
return (
<TouchableOpacity
onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...(viewProps as any)}
>
{icon ? (
<Ionicons
name={icon}
size={size === "large" ? 22 : 18}
color={color === "white" ? "white" : "#9334E9"}
/>
) : null}
{children ? children : null}
</TouchableOpacity>
);
}
if (fillColor)
return (
<TouchableOpacity

View File

@@ -19,6 +19,18 @@ export const HeaderBackButton: React.FC<Props> = ({
}) => {
const router = useRouter();
if (Platform.OS === "ios") {
return (
<TouchableOpacity
onPress={() => router.back()}
className='flex items-center justify-center w-9 h-9'
{...touchableOpacityProps}
>
<Ionicons name='arrow-back' size={24} color='white' />
</TouchableOpacity>
);
}
if (background === "transparent" && Platform.OS !== "android")
return (
<TouchableOpacity

View File

@@ -6,6 +6,7 @@ import { t } from "i18next";
import { useMemo } from "react";
import {
ActivityIndicator,
Platform,
TouchableOpacity,
type TouchableOpacityProps,
View,
@@ -109,9 +110,9 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
/>
)}
{/* Action buttons in top right corner */}
<View className='absolute top-2 right-2 flex flex-row items-center space-x-2 z-10'>
{process.status === "downloading" && (
{/* Action buttons in bottom right corner */}
<View className='absolute bottom-2 right-2 flex flex-row items-center space-x-2 z-10'>
{process.status === "downloading" && Platform.OS !== "ios" && (
<TouchableOpacity
onPress={() => handlePause(process.id)}
className='p-1'
@@ -119,7 +120,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
<Ionicons name='pause' size={20} color='white' />
</TouchableOpacity>
)}
{process.status === "paused" && (
{process.status === "paused" && Platform.OS !== "ios" && (
<TouchableOpacity
onPress={() => handleResume(process.id)}
className='p-1'

View File

@@ -88,22 +88,24 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
if (!popularItems) return null;
return (
<View className='flex flex-col items-center mt-2' {...props}>
<View className='flex flex-col items-center' {...props}>
<Carousel
ref={ref}
autoPlay={false}
loop={true}
snapEnabled={true}
vertical={false}
mode='parallax'
modeConfig={{
parallaxScrollingScale: 0.86,
parallaxScrollingOffset: 100,
parallaxScrollingScale: 1,
parallaxScrollingOffset: 0,
}}
width={width}
height={204}
height={500}
data={popularItems}
onProgressChange={progress}
renderItem={({ item, index }) => <RenderItem key={index} item={item} />}
scrollAnimationDuration={1000}
/>
<Pagination.Basic
progress={progress}
@@ -159,6 +161,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const tap = Gesture.Tap()
.maxDuration(2000)
.shouldCancelWhenOutside(true)
.onBegin(() => {
opacity.value = withTiming(0.8, { duration: 100 });
})
@@ -173,25 +176,19 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return (
<GestureDetector gesture={tap}>
<Animated.View
style={{
opacity: opacity,
}}
className='px-4'
>
<View className='relative flex justify-center rounded-2xl overflow-hidden border border-neutral-800'>
<Animated.View style={{ opacity }}>
<View className='relative flex justify-center overflow-hidden border border-neutral-800'>
<Image
source={{
uri,
}}
style={{
width: "100%",
height: 200,
borderRadius: 16,
height: 500,
overflow: "hidden",
}}
/>
<View className='absolute bottom-0 left-0 w-full h-24 p-4 flex items-center'>
<View className='absolute bottom-0 left-0 w-full flex items-center'>
<Image
source={{
uri: logoUri,

View File

@@ -1,6 +1,7 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
Alert,
Linking,
@@ -16,6 +17,7 @@ interface Props extends ViewProps {
}
export const ItemActions = ({ item, ...props }: Props) => {
const { t } = useTranslation();
const trailerLink = useMemo(() => {
if ("RemoteTrailers" in item && item.RemoteTrailers?.[0]?.Url) {
return item.RemoteTrailers[0].Url;
@@ -30,7 +32,7 @@ export const ItemActions = ({ item, ...props }: Props) => {
const openTrailer = useCallback(async () => {
if (!trailerLink) {
Alert.alert("No trailer available");
Alert.alert(t("common.no_trailer_available"));
return;
}
@@ -39,7 +41,7 @@ export const ItemActions = ({ item, ...props }: Props) => {
} catch (err) {
console.error("Failed to open trailer link:", err);
}
}, [trailerLink]);
}, [trailerLink, t]);
return (
<View className='' {...props}>

View File

@@ -27,7 +27,6 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
@@ -38,6 +37,7 @@ import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
import { AppleTVCarousel } from "../AppleTVCarousel";
type ScrollingCollectionListSection = {
type: "ScrollingCollectionList";
@@ -126,7 +126,10 @@ export const HomeIndex = () => {
useEffect(() => {
const unsubscribe = eventBus.on("scrollToTop", () => {
if ((segments as string[])[2] === "(home)")
scrollViewRef.current?.scrollTo({ y: -152, animated: true });
scrollViewRef.current?.scrollTo({
y: Platform.isTV ? -152 : -100,
animated: true,
});
});
return () => {
@@ -192,9 +195,9 @@ export const HomeIndex = () => {
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 20,
fields: ["PrimaryImageAspectRatio", "Path"],
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes,
parentId,
})
@@ -236,8 +239,9 @@ export const HomeIndex = () => {
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Genres"],
})
).data.Items || [],
type: "ScrollingCollectionList",
@@ -250,9 +254,9 @@ export const HomeIndex = () => {
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
fields: ["MediaSourceCount", "Genres"],
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: false,
})
).data.Items || [],
@@ -334,9 +338,9 @@ export const HomeIndex = () => {
if (section.nextUp) {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
fields: ["MediaSourceCount", "Genres"],
limit: section.nextUp?.limit || 25,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: section.nextUp?.enableResumable,
enableRewatching: section.nextUp?.enableRewatching,
});
@@ -443,44 +447,60 @@ export const HomeIndex = () => {
scrollToOverflowEnabled={true}
ref={scrollViewRef}
nestedScrollEnabled
contentInsetAdjustmentBehavior='automatic'
contentInsetAdjustmentBehavior='never'
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
<RefreshControl
refreshing={loading}
onRefresh={refetch}
tintColor='white' // For iOS
colors={["white"]} // For Android
progressViewOffset={200} // This offsets the refresh indicator to appear over the carousel
/>
}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
style={{ marginTop: Platform.isTV ? 0 : -100 }}
contentContainerStyle={{ paddingTop: Platform.isTV ? 0 : 100 }}
>
<View className='flex flex-col space-y-4'>
<LargeMovieCarousel />
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
/>
);
}
if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
/>
);
}
return null;
})}
<AppleTVCarousel
initialIndex={0}
onItemChange={(index) => {
console.log(`Now viewing carousel item ${index}`);
}}
/>
<View
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
>
<View className='flex flex-col space-y-4'>
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
/>
);
}
if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
/>
);
}
return null;
})}
</View>
</View>
<View className='h-24' />
</ScrollView>
);
};

View File

@@ -0,0 +1,254 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type React from "react";
import { useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
StyleSheet,
TouchableOpacity,
View,
type ViewProps,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
interface LibraryOptions {
display: "row" | "list";
imageStyle: "poster" | "cover";
showTitles: boolean;
showStats: boolean;
}
interface Props extends ViewProps {
open: boolean;
setOpen: (open: boolean) => void;
settings: LibraryOptions;
updateSettings: (options: Partial<LibraryOptions>) => void;
disabled?: boolean;
}
const OptionGroup: React.FC<{ title: string; children: React.ReactNode }> = ({
title,
children,
}) => (
<View className='mb-6'>
<Text className='text-lg font-semibold mb-3 text-neutral-300'>{title}</Text>
<View
style={{
borderRadius: 12,
overflow: "hidden",
}}
className='bg-neutral-800 rounded-xl overflow-hidden'
>
{children}
</View>
</View>
);
const OptionItem: React.FC<{
label: string;
selected: boolean;
onPress: () => void;
disabled?: boolean;
isLast?: boolean;
}> = ({ label, selected, onPress, disabled: itemDisabled, isLast }) => (
<>
<TouchableOpacity
onPress={onPress}
disabled={itemDisabled}
className={`px-4 py-3 flex flex-row items-center justify-between ${
itemDisabled ? "opacity-50" : ""
}`}
>
<Text className='flex-1 text-white'>{label}</Text>
{selected ? (
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
) : (
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
)}
</TouchableOpacity>
{!isLast && (
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='bg-neutral-700 mx-4'
/>
)}
</>
);
const ToggleItem: React.FC<{
label: string;
value: boolean;
onToggle: () => void;
disabled?: boolean;
isLast?: boolean;
}> = ({ label, value, onToggle, disabled: itemDisabled, isLast }) => (
<>
<TouchableOpacity
onPress={onToggle}
disabled={itemDisabled}
className={`px-4 py-3 flex flex-row items-center justify-between ${
itemDisabled ? "opacity-50" : ""
}`}
>
<Text className='flex-1 text-white'>{label}</Text>
<View
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${
value ? "translate-x-6" : "translate-x-1"
}`}
/>
</View>
</TouchableOpacity>
{!isLast && (
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='bg-neutral-700 mx-4'
/>
)}
</>
);
/**
* LibraryOptionsSheet Component
*
* This component creates a bottom sheet modal for managing library display options.
*/
export const LibraryOptionsSheet: React.FC<Props> = ({
open,
setOpen,
settings,
updateSettings,
disabled = false,
}) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const handlePresentModal = useCallback(() => {
bottomSheetModalRef.current?.present();
}, []);
const handleDismissModal = useCallback(() => {
bottomSheetModalRef.current?.dismiss();
}, []);
useEffect(() => {
if (open) {
handlePresentModal();
} else {
handleDismissModal();
}
}, [open, handlePresentModal, handleDismissModal]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index === -1) {
setOpen(false);
}
},
[setOpen],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
if (disabled) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
enablePanDownToClose
enableDismissOnClose
>
<BottomSheetView>
<View
className='px-4 pb-8 pt-2'
style={{
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
}}
>
<Text className='font-bold text-2xl mb-6'>
{t("library.options.display")}
</Text>
<OptionGroup title={t("library.options.display")}>
<OptionItem
label={t("library.options.row")}
selected={settings.display === "row"}
onPress={() => updateSettings({ display: "row" })}
/>
<OptionItem
label={t("library.options.list")}
selected={settings.display === "list"}
onPress={() => updateSettings({ display: "list" })}
isLast
/>
</OptionGroup>
<OptionGroup title={t("library.options.image_style")}>
<OptionItem
label={t("library.options.poster")}
selected={settings.imageStyle === "poster"}
onPress={() => updateSettings({ imageStyle: "poster" })}
/>
<OptionItem
label={t("library.options.cover")}
selected={settings.imageStyle === "cover"}
onPress={() => updateSettings({ imageStyle: "cover" })}
isLast
/>
</OptionGroup>
<OptionGroup title='Options'>
<ToggleItem
label={t("library.options.show_titles")}
value={settings.showTitles}
onToggle={() =>
updateSettings({ showTitles: !settings.showTitles })
}
disabled={settings.imageStyle === "poster"}
/>
<ToggleItem
label={t("library.options.show_stats")}
value={settings.showStats}
onToggle={() =>
updateSettings({ showStats: !settings.showStats })
}
isLast
/>
</OptionGroup>
</View>
</BottomSheetView>
</BottomSheetModal>
);
};

View File

@@ -41,10 +41,10 @@ export const OtherSettings: React.FC = () => {
if (settings?.autoDownload === true && !registered) {
registerBackgroundFetchAsync();
toast.success("Background downloads enabled");
toast.success(t("home.settings.toasts.background_downloads_enabled"));
} else if (settings?.autoDownload === false && registered) {
unregisterBackgroundFetchAsync();
toast.info("Background downloads disabled");
toast.info(t("home.settings.toasts.background_downloads_disabled"));
} else if (settings?.autoDownload === true && registered) {
// Don't to anything
} else if (settings?.autoDownload === false && !registered) {

View File

@@ -14,6 +14,7 @@ export const commonScreenOptions: ICommonScreenOptions = {
headerShown: true,
headerTransparent: true,
headerShadowVisible: false,
headerBlurEffect: "none",
headerLeft: () => <HeaderBackButton />,
};

View File

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

View File

@@ -0,0 +1,131 @@
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 { getColors, ImageColorsResult } from "react-native-image-colors";
import { apiAtom } from "@/providers/JellyfinProvider";
import {
adjustToNearBlack,
calculateTextColor,
isCloseToBlack,
} from "@/utils/atoms/primaryColor";
import { getItemImage } from "@/utils/getItemImage";
import { storage } from "@/utils/mmkv";
export interface ThemeColors {
primary: string;
text: string;
}
const DEFAULT_COLORS: ThemeColors = {
primary: "#FFFFFF",
text: "#000000",
};
/**
* Custom hook to extract and return image colors for a given item.
* Returns colors as state instead of updating global atom.
*
* @param item - The BaseItemDto object representing the item.
* @param disabled - A boolean flag to disable color extraction.
* @returns ThemeColors object with primary and text colors
*/
export const useImageColorsReturn = ({
item,
url,
disabled,
}: {
item?: BaseItemDto | null;
url?: string | null;
disabled?: boolean;
}): ThemeColors => {
const api = useAtomValue(apiAtom);
const [colors, setColors] = useState<ThemeColors>(DEFAULT_COLORS);
const isTv = Platform.isTV;
const source = useMemo(() => {
if (!api) return;
if (url) return { uri: url };
if (item)
return getItemImage({
item,
api,
variant: "Primary",
quality: 80,
width: 300,
});
return null;
}, [api, item, url]);
useEffect(() => {
// Reset to default colors when item changes
if (!item && !url) {
setColors(DEFAULT_COLORS);
return;
}
if (isTv) return;
if (disabled) return;
if (source?.uri) {
const _primary = storage.getString(`${source.uri}-primary`);
const _text = storage.getString(`${source.uri}-text`);
if (_primary && _text) {
setColors({
primary: _primary,
text: _text,
});
return;
}
// Extract colors from the image
getColors(source.uri, {
fallback: "#fff",
cache: false,
})
.then((colors: ImageColorsResult) => {
let primary = "#fff";
let text = "#000";
let backup = "#fff";
// Select the appropriate color based on the platform
if (colors.platform === "android") {
primary = colors.dominant;
backup = colors.vibrant;
} else if (colors.platform === "ios") {
primary = colors.detail;
backup = colors.primary;
}
// Adjust the primary color if it's too close to black
if (primary && isCloseToBlack(primary)) {
if (backup && !isCloseToBlack(backup)) primary = backup;
primary = adjustToNearBlack(primary);
}
// Calculate the text color based on the primary color
if (primary) text = calculateTextColor(primary);
const newColors = {
primary,
text,
};
setColors(newColors);
// Cache the colors in storage
if (source.uri && primary) {
storage.set(`${source.uri}-primary`, primary);
storage.set(`${source.uri}-text`, text);
}
})
.catch((error: any) => {
console.error("Error getting colors", error);
setColors(DEFAULT_COLORS);
});
}
}, [isTv, source?.uri, disabled, item, url]);
return colors;
};

View File

@@ -22,7 +22,7 @@
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor"
},
"dependencies": {
"@bottom-tabs/react-navigation": "^0.9.2",
"@bottom-tabs/react-navigation": "^0.11.2",
"@expo/metro-runtime": "~6.1.1",
"@expo/react-native-action-sheet": "^4.1.1",
"@expo/vector-icons": "^15.0.2",
@@ -36,7 +36,7 @@
"@shopify/flash-list": "^1.8.3",
"@tanstack/react-query": "^5.66.0",
"axios": "^1.7.9",
"expo": "^54.0.0-preview.12",
"expo": "^54.0.10",
"expo-application": "~7.0.5",
"expo-asset": "~12.0.6",
"expo-background-task": "~1.0.5",
@@ -72,7 +72,7 @@
"react-i18next": "^15.4.0",
"react-native": "npm:react-native-tvos@0.81.1-1",
"react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "^0.9.2",
"react-native-bottom-tabs": "^0.11.2",
"react-native-circular-progress": "^1.4.1",
"react-native-collapsible": "^1.6.2",
"react-native-country-flag": "^2.0.2",
@@ -89,7 +89,7 @@
"react-native-reanimated": "~4.1.0",
"react-native-reanimated-carousel": "4.0.2",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.15.4",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-udp": "^4.1.7",
"react-native-url-polyfill": "^2.0.0",
@@ -110,23 +110,20 @@
"@react-native-tvos/config-tv": "^0.1.1",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.15",
"@types/react": "~19.0.10",
"@types/react": "~19.1.10",
"@types/react-test-renderer": "^19.0.0",
"expo-doctor": "^1.17.0",
"cross-env": "^10.0.0",
"expo-doctor": "^1.17.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.5",
"postinstall-postinstall": "^2.1.0",
"react-test-renderer": "19.1.1",
"typescript": "~5.8.3"
"typescript": "~5.9.2"
},
"expo": {
"install": {
"exclude": [
"react-native",
"@shopify/flash-list",
"react-native-reanimated",
"react-native-pager-view"
"react-native"
]
},
"doctor": {

View File

@@ -50,9 +50,11 @@ function withRNBackgroundDownloader(config) {
// Expo 53's xcodejs doesn't expose pbxTargets().
// Setting the property once at the project level is sufficient.
["Debug", "Release"].forEach((cfg) => {
// Use the detected projectName to set the bridging header path instead of a hardcoded value
const bridgingHeaderPath = `${projectName}/${projectName}-Bridging-Header.h`;
project.updateBuildProperty(
"SWIFT_OBJC_BRIDGING_HEADER",
"Streamyfin/Streamyfin-Bridging-Header.h",
bridgingHeaderPath,
cfg,
);
});

View File

@@ -25,7 +25,7 @@ import { useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device";
import useDownloadHelper from "@/utils/download";
import { getItemImage } from "@/utils/getItemImage";
import { writeToLog } from "@/utils/log";
import { dumpDownloadDiagnostics, writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { fetchAndParseSegments } from "@/utils/segments";
import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay";
@@ -42,37 +42,60 @@ const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
: null;
// Cap progress at 99% to avoid showing 100% before the download is actually complete
const MAX_PROGRESS_BEFORE_COMPLETION = 99;
// Estimate the total download size in bytes for a job. If the media source
// provides a Size, use that. Otherwise, if we have a bitrate and run time
// (RunTimeTicks), approximate size = (bitrate bits/sec * seconds) / 8.
const calculateEstimatedSize = (p: JobStatus): number => {
let size = p.mediaSource.Size;
const maxBitrate = p.maxBitrate.value;
if (
maxBitrate &&
size &&
p.mediaSource.Bitrate &&
maxBitrate < p.mediaSource.Bitrate
) {
size = (size / p.mediaSource.Bitrate) * maxBitrate;
}
// This function is for estimated size, so just return the adjusted size
return size ?? 0;
};
const size = p.mediaSource?.Size || 0;
const maxBitrate = p.maxBitrate?.value;
const runTimeTicks = (p.item?.RunTimeTicks || 0) as number;
// Helper to calculate download speed
const calculateSpeed = (
process: JobStatus,
newBytesDownloaded: number,
): number | undefined => {
const { bytesDownloaded: oldBytes = 0, lastProgressUpdateTime } = process;
const deltaBytes = newBytesDownloaded - oldBytes;
if (lastProgressUpdateTime && deltaBytes > 0) {
const deltaTimeInSeconds =
(Date.now() - new Date(lastProgressUpdateTime).getTime()) / 1000;
if (deltaTimeInSeconds > 0) {
return deltaBytes / deltaTimeInSeconds;
if (!size && maxBitrate && runTimeTicks > 0) {
// Jellyfin RunTimeTicks are in 10,000,000 ticks per second
const seconds = runTimeTicks / 10000000;
if (seconds > 0) {
// maxBitrate is in bits per second; convert to bytes
return Math.round((maxBitrate / 8) * seconds);
}
}
return undefined;
return size || 0;
};
// Calculate download speed in bytes/sec based on a job's last update time
// and previously recorded bytesDownloaded.
const calculateSpeed = (
p: JobStatus,
currentBytesDownloaded?: number,
): number | undefined => {
// Prefer session-only deltas when available: lastSessionBytes + lastSessionUpdateTime
const now = Date.now();
if (p.lastSessionUpdateTime && p.lastSessionBytes !== undefined) {
const last = new Date(p.lastSessionUpdateTime).getTime();
const deltaTime = (now - last) / 1000;
if (deltaTime > 0) {
const current =
currentBytesDownloaded ?? p.bytesDownloaded ?? p.lastSessionBytes;
const deltaBytes = current - p.lastSessionBytes;
if (deltaBytes > 0) return deltaBytes / deltaTime;
}
}
// Fallback to total-based deltas for compatibility
if (!p.lastProgressUpdateTime || p.bytesDownloaded === undefined)
return undefined;
const last = new Date(p.lastProgressUpdateTime).getTime();
const deltaTime = (now - last) / 1000;
if (deltaTime <= 0) return undefined;
const prev = p.bytesDownloaded || 0;
const current = currentBytesDownloaded ?? prev;
const deltaBytes = current - prev;
if (deltaBytes <= 0) return undefined;
return deltaBytes / deltaTime;
};
export const processesAtom = atom<JobStatus[]>([]);
@@ -170,27 +193,96 @@ function useDownloadProvider() {
const currentProcesses = [...processes, ...missingProcesses];
const updatedProcesses = currentProcesses.map((p) => {
// fallback. Doesn't really work for transcodes as they may be a lot smaller.
// We make an wild guess by comparing bitrates
// Enhanced filtering to prevent iOS zombie task interference
// Only update progress for downloads that are actively downloading
if (p.status !== "downloading") {
return p;
}
// Find task for this process
const task = tasks.find((s: any) => s.id === p.id);
if (!task) {
return p; // No task found, keep current state
}
/*
// TODO: Uncomment this block to re-enable iOS zombie task detection
// iOS: Extra validation to prevent zombie task interference
if (Platform.OS === "ios") {
// Check if we have multiple tasks for same ID (zombie detection)
const tasksForId = tasks.filter((t: any) => t.id === p.id);
if (tasksForId.length > 1) {
console.warn(
`[UPDATE] Detected ${tasksForId.length} zombie tasks for ${p.id}, ignoring progress update`,
);
return p; // Don't update progress from potentially conflicting tasks
}
// If task state looks suspicious (e.g., iOS task stuck in background), be conservative
if (
task.state &&
["SUSPENDED", "PAUSED"].includes(task.state) &&
p.status === "downloading"
) {
console.warn(
`[UPDATE] Task ${p.id} has suspicious state ${task.state}, ignoring progress update`,
);
return p;
}
}
*/
if (task && p.status === "downloading") {
const estimatedSize = calculateEstimatedSize(p);
let progress = p.progress;
if (estimatedSize > 0) {
progress = (100 / estimatedSize) * task.bytesDownloaded;
// If we have a pausedProgress snapshot then merge current session
// progress into it. We accept pausedProgress === 0 as valid because
// users can pause immediately after starting.
if (p.pausedProgress !== undefined) {
const totalBytesDownloaded =
(p.pausedBytes ?? 0) + task.bytesDownloaded;
// Calculate progress based on total bytes downloaded vs estimated size
progress =
estimatedSize > 0
? (totalBytesDownloaded / estimatedSize) * 100
: 0;
// Use the total accounted bytes when computing speed so the
// displayed speed and progress remain consistent after resume.
const speed = calculateSpeed(p, totalBytesDownloaded);
return {
...p,
progress: Math.min(progress, MAX_PROGRESS_BEFORE_COMPLETION),
speed,
bytesDownloaded: totalBytesDownloaded,
lastProgressUpdateTime: new Date(),
estimatedTotalSizeBytes: estimatedSize,
// Set session bytes to total bytes downloaded
lastSessionBytes: totalBytesDownloaded,
lastSessionUpdateTime: new Date(),
};
} else {
if (estimatedSize > 0) {
progress = (100 / estimatedSize) * task.bytesDownloaded;
}
if (progress >= 100) {
progress = MAX_PROGRESS_BEFORE_COMPLETION;
}
const speed = calculateSpeed(p, task.bytesDownloaded);
return {
...p,
progress,
speed,
bytesDownloaded: task.bytesDownloaded,
lastProgressUpdateTime: new Date(),
estimatedTotalSizeBytes: estimatedSize,
lastSessionBytes: task.bytesDownloaded,
lastSessionUpdateTime: new Date(),
};
}
if (progress >= 100) {
progress = 99;
}
const speed = calculateSpeed(p, task.bytesDownloaded);
return {
...p,
progress,
speed,
bytesDownloaded: task.bytesDownloaded,
lastProgressUpdateTime: new Date(),
estimatedTotalSizeBytes: estimatedSize,
};
}
return p;
});
@@ -372,10 +464,76 @@ function useDownloadProvider() {
async (process: JobStatus) => {
if (!process?.item.Id || !authHeader) throw new Error("No item id");
// Enhanced cleanup for existing tasks to prevent duplicates
try {
const allTasks = await BackGroundDownloader.checkForExistingDownloads();
const existingTasks = allTasks?.filter((t: any) => t.id === process.id);
if (existingTasks && existingTasks.length > 0) {
console.log(
`[START] Found ${existingTasks.length} existing task(s) for ${process.id}, cleaning up...`,
);
for (let i = 0; i < existingTasks.length; i++) {
const existingTask = existingTasks[i];
console.log(
`[START] Cleaning up task ${i + 1}/${existingTasks.length} for ${process.id}`,
);
try {
/*
// TODO: Uncomment this block to re-enable iOS-specific cleanup
// iOS: More aggressive cleanup sequence
if (Platform.OS === "ios") {
try {
await existingTask.pause();
await new Promise((resolve) => setTimeout(resolve, 50));
} catch (_pauseErr) {
// Ignore pause errors
}
await existingTask.stop();
await new Promise((resolve) => setTimeout(resolve, 50));
// Multiple complete handler calls to ensure cleanup
BackGroundDownloader.completeHandler(process.id);
await new Promise((resolve) => setTimeout(resolve, 25));
} else {
*/
// Simple cleanup for all platforms (currently Android only)
await existingTask.stop();
BackGroundDownloader.completeHandler(process.id);
/* } // End of iOS block - uncomment when re-enabling iOS functionality */
console.log(
`[START] Successfully cleaned up task ${i + 1} for ${process.id}`,
);
} catch (taskError) {
console.warn(
`[START] Failed to cleanup task ${i + 1} for ${process.id}:`,
taskError,
);
}
}
// Cleanup delay (simplified for Android)
const cleanupDelay = 200; // Platform.OS === "ios" ? 500 : 200;
await new Promise((resolve) => setTimeout(resolve, cleanupDelay));
console.log(`[START] Cleanup completed for ${process.id}`);
}
} catch (error) {
console.warn(
`[START] Failed to check/cleanup existing tasks for ${process.id}:`,
error,
);
}
updateProcess(process.id, {
speed: undefined,
status: "downloading",
progress: 0,
progress: process.progress || 0, // Preserve existing progress for resume
});
BackGroundDownloader?.setConfig({
@@ -396,21 +554,42 @@ function useDownloadProvider() {
.begin(() => {
updateProcess(process.id, {
status: "downloading",
progress: 0,
bytesDownloaded: 0,
progress: process.progress || 0,
bytesDownloaded: process.bytesDownloaded || 0,
lastProgressUpdateTime: new Date(),
lastSessionBytes: process.lastSessionBytes || 0,
lastSessionUpdateTime: new Date(),
});
})
.progress(
throttle((data) => {
updateProcess(process.id, (currentProcess) => {
const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
// If this is a resumed download, add the paused bytes to current session bytes
const resumedBytes = currentProcess.pausedBytes || 0;
const totalBytes = data.bytesDownloaded + resumedBytes;
// Calculate progress based on total bytes if we have resumed bytes
let percent: number;
if (resumedBytes > 0 && data.bytesTotal > 0) {
// For resumed downloads, calculate based on estimated total size
const estimatedTotal =
currentProcess.estimatedTotalSizeBytes ||
data.bytesTotal + resumedBytes;
percent = (totalBytes / estimatedTotal) * 100;
} else {
// For fresh downloads, use normal calculation
percent = (data.bytesDownloaded / data.bytesTotal) * 100;
}
return {
speed: calculateSpeed(currentProcess, data.bytesDownloaded),
speed: calculateSpeed(currentProcess, totalBytes),
status: "downloading",
progress: percent,
bytesDownloaded: data.bytesDownloaded,
progress: Math.min(percent, MAX_PROGRESS_BEFORE_COMPLETION),
bytesDownloaded: totalBytes,
lastProgressUpdateTime: new Date(),
// update session-only counters - use current session bytes only for speed calc
lastSessionBytes: data.bytesDownloaded,
lastSessionUpdateTime: new Date(),
};
});
}, 500),
@@ -542,7 +721,17 @@ function useDownloadProvider() {
if (activeDownloads < concurrentLimit) {
const queuedDownload = processes.find((p) => p.status === "queued");
if (queuedDownload) {
startDownload(queuedDownload);
// Reserve the slot immediately to avoid race where startDownload's
// asynchronous begin callback hasn't executed yet and multiple
// downloads are started, bypassing the concurrent limit.
updateProcess(queuedDownload.id, { status: "downloading" });
startDownload(queuedDownload).catch((error) => {
console.error("Failed to start download:", error);
updateProcess(queuedDownload.id, { status: "error" });
toast.error(t("home.downloads.toasts.failed_to_start_download"), {
description: error.message || "Unknown error",
});
});
}
}
}, [processes, settings?.remuxConcurrentLimit, startDownload]);
@@ -551,8 +740,38 @@ function useDownloadProvider() {
async (id: string) => {
const tasks = await BackGroundDownloader.checkForExistingDownloads();
const task = tasks?.find((t: any) => t.id === id);
task?.stop();
BackGroundDownloader.completeHandler(id);
if (task) {
// On iOS, suspended tasks need to be cancelled properly
if (Platform.OS === "ios") {
const state = task.state || task.state?.();
if (
state === "PAUSED" ||
state === "paused" ||
state === "SUSPENDED" ||
state === "suspended"
) {
// For suspended tasks, we need to resume first, then stop
try {
await task.resume();
// Small delay to allow resume to take effect
await new Promise((resolve) => setTimeout(resolve, 100));
} catch (_resumeError) {
// Resume might fail, continue with stop
}
}
}
try {
task.stop();
} catch (_err) {
// ignore stop errors
}
try {
BackGroundDownloader.completeHandler(id);
} catch (_err) {
// ignore
}
}
setProcesses((prev) => prev.filter((process) => process.id !== id));
manageDownloadQueue();
},
@@ -575,7 +794,7 @@ function useDownloadProvider() {
intermediates: true,
});
} catch (_error) {
toast.error(t("Failed to clean cache directory."));
toast.error(t("home.downloads.toasts.failed_to_clean_cache_directory"));
}
};
@@ -611,9 +830,13 @@ function useDownloadProvider() {
status: "queued",
timestamp: new Date(),
};
setProcesses((prev) => [...prev, job]);
setProcesses((prev) => {
// Remove any existing processes for this item to prevent duplicates
const filtered = prev.filter((p) => p.id !== item.Id);
return [...filtered, job];
});
toast.success(
t("home.downloads.toasts.download_stated_for_item", {
t("home.downloads.toasts.download_started_for_item", {
item: item.Name,
}),
{
@@ -791,12 +1014,99 @@ function useDownloadProvider() {
const process = processes.find((p) => p.id === id);
if (!process) throw new Error("No active download");
// TODO: iOS pause functionality temporarily disabled due to background task issues
// Remove this check to re-enable iOS pause functionality in the future
if (Platform.OS === "ios") {
console.warn(
`[PAUSE] Pause functionality temporarily disabled on iOS for ${id}`,
);
throw new Error("Pause functionality is currently disabled on iOS");
}
const tasks = await BackGroundDownloader.checkForExistingDownloads();
const task = tasks?.find((t: any) => t.id === id);
if (!task) throw new Error("No task found");
task.pause();
updateProcess(id, { status: "paused" });
// Get current progress before stopping
const currentProgress = process.progress;
const currentBytes = process.bytesDownloaded || task.bytesDownloaded || 0;
console.log(
`[PAUSE] Starting pause for ${id}. Current bytes: ${currentBytes}, Progress: ${currentProgress}%`,
);
try {
/*
// TODO: Uncomment this block to re-enable iOS pause functionality
// iOS-specific aggressive cleanup approach based on GitHub issue #26
if (Platform.OS === "ios") {
// Get ALL tasks for this ID - there might be multiple zombie tasks
const allTasks =
await BackGroundDownloader.checkForExistingDownloads();
const tasksForId = allTasks?.filter((t: any) => t.id === id) || [];
console.log(`[PAUSE] Found ${tasksForId.length} task(s) for ${id}`);
// Stop ALL tasks for this ID to prevent zombie processes
for (let i = 0; i < tasksForId.length; i++) {
const taskToStop = tasksForId[i];
console.log(
`[PAUSE] Stopping task ${i + 1}/${tasksForId.length} for ${id}`,
);
try {
// iOS: pause → stop sequence with delays (based on issue research)
await taskToStop.pause();
await new Promise((resolve) => setTimeout(resolve, 100));
await taskToStop.stop();
await new Promise((resolve) => setTimeout(resolve, 100));
console.log(
`[PAUSE] Successfully stopped task ${i + 1} for ${id}`,
);
} catch (taskError) {
console.warn(
`[PAUSE] Failed to stop task ${i + 1} for ${id}:`,
taskError,
);
}
}
// Extra cleanup delay for iOS NSURLSession to fully stop
await new Promise((resolve) => setTimeout(resolve, 500));
} else {
*/
// Android: simpler approach (currently the only active platform)
await task.stop();
/* } // End of iOS block - uncomment when re-enabling iOS functionality */
// Clean up the native task handler
try {
BackGroundDownloader.completeHandler(id);
} catch (_err) {
console.warn(`[PAUSE] Handler cleanup warning for ${id}:`, _err);
}
// Update process state to paused
updateProcess(id, {
status: "paused",
progress: currentProgress,
bytesDownloaded: currentBytes,
pausedAt: new Date(),
pausedProgress: currentProgress,
pausedBytes: currentBytes,
lastSessionBytes: process.lastSessionBytes ?? currentBytes,
lastSessionUpdateTime: process.lastSessionUpdateTime ?? new Date(),
});
console.log(`Download paused successfully: ${id}`);
} catch (error) {
console.error("Error pausing task:", error);
throw error;
}
},
[processes, updateProcess],
);
@@ -806,38 +1116,79 @@ function useDownloadProvider() {
const process = processes.find((p) => p.id === id);
if (!process) throw new Error("No active download");
const tasks = await BackGroundDownloader.checkForExistingDownloads();
const task = tasks?.find((t: any) => t.id === id);
if (!task) throw new Error("No task found");
// Check if task state allows resuming
if (task.state === "FAILED") {
// TODO: iOS resume functionality temporarily disabled due to background task issues
// Remove this check to re-enable iOS resume functionality in the future
if (Platform.OS === "ios") {
console.warn(
"Download task failed, cannot resume. Restarting download.",
`[RESUME] Resume functionality temporarily disabled on iOS for ${id}`,
);
// For failed tasks, we need to restart rather than resume
await startDownload(process);
return;
throw new Error("Resume functionality is currently disabled on iOS");
}
try {
task.resume();
updateProcess(id, { status: "downloading" });
} catch (error: any) {
// Handle specific ERROR_CANNOT_RESUME error
if (
error?.error === "ERROR_CANNOT_RESUME" ||
error?.errorCode === 1008
) {
console.warn("Cannot resume download, attempting to restart instead");
await startDownload(process);
return; // Return early to prevent error from bubbling up
} else {
// Only log error for non-handled cases
console.error("Error resuming download:", error);
throw error; // Re-throw other errors
console.log(
`[RESUME] Attempting to resume ${id}. Paused bytes: ${process.pausedBytes}, Progress: ${process.pausedProgress}%`,
);
/*
// TODO: Uncomment this block to re-enable iOS resume functionality
// Enhanced cleanup for iOS based on GitHub issue research
if (Platform.OS === "ios") {
try {
// Clean up any lingering zombie tasks first (critical for iOS)
const allTasks =
await BackGroundDownloader.checkForExistingDownloads();
const existingTasks = allTasks?.filter((t: any) => t.id === id) || [];
if (existingTasks.length > 0) {
console.log(
`[RESUME] Found ${existingTasks.length} lingering task(s), cleaning up...`,
);
for (const task of existingTasks) {
try {
await task.stop();
BackGroundDownloader.completeHandler(id);
} catch (cleanupError) {
console.warn(`[RESUME] Cleanup error:`, cleanupError);
}
}
// Wait for iOS cleanup to complete
await new Promise((resolve) => setTimeout(resolve, 500));
}
} catch (error) {
console.warn(`[RESUME] Pre-resume cleanup failed:`, error);
}
}
*/
// Simple approach: always restart the download from where we left off
// This works consistently across all platforms (currently Android only)
if (
process.pausedProgress !== undefined &&
process.pausedBytes !== undefined
) {
// We have saved pause state - restore it and restart
updateProcess(id, {
progress: process.pausedProgress,
bytesDownloaded: process.pausedBytes,
status: "downloading",
// Reset session counters for proper speed calculation
lastSessionBytes: process.pausedBytes,
lastSessionUpdateTime: new Date(),
});
// Small delay to ensure any cleanup in startDownload completes
await new Promise((resolve) => setTimeout(resolve, 100));
const updatedProcess = processes.find((p) => p.id === id);
await startDownload(updatedProcess || process);
console.log(`Download resumed successfully: ${id}`);
} else {
// No pause state - start from beginning
await startDownload(process);
}
},
[processes, updateProcess, startDownload],
);
@@ -861,6 +1212,21 @@ function useDownloadProvider() {
cleanCacheDirectory,
updateDownloadedItem,
appSizeUsage,
dumpDownloadDiagnostics: async (id?: string) => {
// Collect JS-side processes and native task info (best-effort)
const tasks = BackGroundDownloader
? await BackGroundDownloader.checkForExistingDownloads()
: [];
const extra: any = {
processes,
nativeTasks: tasks || [],
};
if (id) {
const p = processes.find((x) => x.id === id);
extra.focusedProcess = p || null;
}
return dumpDownloadDiagnostics(extra);
},
};
}

View File

@@ -129,4 +129,14 @@ export type JobStatus = {
/** Estimated total size of the download in bytes (optional) this is used when we
* download transcoded content because we don't know the size of the file until it's downloaded */
estimatedTotalSizeBytes?: number;
/** Timestamp when the download was paused (optional) */
pausedAt?: Date;
/** Progress percentage when download was paused (optional) */
pausedProgress?: number;
/** Bytes downloaded when download was paused (optional) */
pausedBytes?: number;
/** Bytes downloaded in the current session (since last resume). Used for session-only speed calculation. */
lastSessionBytes?: number;
/** Timestamp when the session-only bytes were last updated. */
lastSessionUpdateTime?: Date;
};

View File

@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.36.0" },
clientInfo: { name: "Streamyfin", version: "0.38.0" },
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.36.0"`,
}, DeviceId="${deviceId}", Version="0.38.0"`,
};
}, [deviceId]);

View File

@@ -223,7 +223,9 @@
"system": "System"
},
"toasts": {
"error_deleting_files": "Error Deleting Files"
"error_deleting_files": "Error Deleting Files",
"background_downloads_enabled": "Background downloads enabled",
"background_downloads_disabled": "Background downloads disabled"
}
},
"sessions": {
@@ -266,11 +268,23 @@
"download_completed": "Download Completed",
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
"download_completed_for_item": "Download Completed for {{item}}",
"download_started_for_item": "Download Started for {{item}}",
"failed_to_start_download": "Failed to start download",
"all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
"failed_to_clean_cache_directory": "Failed to clean cache directory",
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
"go_to_downloads": "Go to Downloads"
}
}
},
"common": {
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Video",
"audio": "Audio",
"subtitle": "Subtitle",
"play": "Play"
},
"search": {
"search": "Search...",
"x_items": "{{count}} Items",

View File

@@ -77,6 +77,17 @@ export const clearLogs = () => {
storage.remove("logs");
};
export const dumpDownloadDiagnostics = (extra: any = {}) => {
const diagnostics = {
timestamp: new Date().toISOString(),
processes: extra?.processes || [],
nativeTasks: extra?.nativeTasks || [],
focusedProcess: extra?.focusedProcess || null,
};
writeDebugLog("Download diagnostics", diagnostics);
return diagnostics;
};
export function useLog() {
const context = useContext(LogContext);
if (context === null) {