Merge branch 'develop' into sonarqube

This commit is contained in:
Gauvain
2025-09-29 12:42:37 +02:00
committed by GitHub
26 changed files with 1488 additions and 348 deletions

View File

@@ -31,13 +31,13 @@ jobs:
fetch-depth: 0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
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@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
uses: github/codeql-action/autobuild@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
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

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

@@ -4,8 +4,8 @@
"": {
"name": "streamyfin",
"dependencies": {
"@bottom-tabs/react-navigation": "^0.9.2",
"@expo/metro-runtime": "~5.0.4",
"@bottom-tabs/react-navigation": "^0.11.2",
"@expo/metro-runtime": "~5.0.5",
"@expo/react-native-action-sheet": "^4.1.1",
"@expo/vector-icons": "^14.1.0",
"@gorhom/bottom-sheet": "^5.1.0",
@@ -18,7 +18,7 @@
"@shopify/flash-list": "^1.8.3",
"@tanstack/react-query": "^5.66.0",
"axios": "^1.7.9",
"expo": "^53.0.22",
"expo": "^53.0.23",
"expo-application": "~6.1.4",
"expo-asset": "~11.1.7",
"expo-background-task": "~0.2.8",
@@ -35,7 +35,7 @@
"expo-linking": "~7.1.4",
"expo-localization": "~16.1.5",
"expo-notifications": "~0.31.2",
"expo-router": "~5.1.5",
"expo-router": "~5.1.7",
"expo-screen-orientation": "~8.1.6",
"expo-sensors": "~14.1.4",
"expo-sharing": "~13.1.5",
@@ -53,7 +53,7 @@
"react-i18next": "^15.4.0",
"react-native": "npm:react-native-tvos@0.79.5-0",
"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",
@@ -315,7 +315,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=="],
@@ -323,7 +323,7 @@
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
"@expo/cli": ["@expo/cli@0.24.21", "", { "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.11", "@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-DT6K9vgFHqqWL/19mU1ofRcPoO1pn4qmgi76GtuiNU4tbBe/02mRHwFsQw7qRfFAT28If5e/wiwVozgSuZVL8g=="],
"@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=="],
"@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=="],
@@ -345,7 +345,7 @@
"@expo/metro-config": ["@expo/metro-config@0.20.17", "", { "dependencies": { "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@babel/parser": "^7.20.0", "@babel/types": "^7.20.0", "@expo/config": "~11.0.12", "@expo/env": "~1.0.7", "@expo/json-file": "~9.1.5", "@expo/spawn-async": "^1.7.2", "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", "jsc-safe-url": "^0.2.4", "lightningcss": "~1.27.0", "minimatch": "^9.0.0", "postcss": "~8.4.32", "resolve-from": "^5.0.0" } }, "sha512-lpntF2UZn5bTwrPK6guUv00Xv3X9mkN3YYla+IhEHiYXWyG7WKOtDU0U4KR8h3ubkZ6SPH3snDyRyAzMsWtZFA=="],
"@expo/metro-runtime": ["@expo/metro-runtime@5.0.4", "", { "peerDependencies": { "react-native": "*" } }, "sha512-r694MeO+7Vi8IwOsDIDzH/Q5RPMt1kUDYbiTJwnO15nIqiDwlE8HU55UlRhffKZy6s5FmxQsZ8HA+T8DqUW8cQ=="],
"@expo/metro-runtime": ["@expo/metro-runtime@5.0.5", "", { "peerDependencies": { "react-native": "*" } }, "sha512-P8UFTi+YsmiD1BmdTdiIQITzDMcZgronsA3RTQ4QKJjHM3bas11oGzLQOnFaIZnlEV8Rrr3m1m+RHxvnpL+t/A=="],
"@expo/osascript": ["@expo/osascript@2.2.5", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "exec-async": "^2.2.0" } }, "sha512-Bpp/n5rZ0UmpBOnl7Li3LtM7la0AR3H9NNesqL+ytW5UiqV/TbonYW3rDZY38u4u/lG7TnYflVIVQPD+iqZJ5w=="],
@@ -817,13 +817,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=="],
@@ -983,7 +983,7 @@
"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=="],
"expo": ["expo@53.0.22", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "0.24.21", "@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-sJ2I4W/e5iiM4u/wYCe3qmW4D7WPCRqByPDD0hJcdYNdjc9HFFFdO4OAudZVyC/MmtoWZEIH5kTJP1cw9FjzYA=="],
"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=="],
"expo-application": ["expo-application@6.1.5", "", { "peerDependencies": { "expo": "*" } }, "sha512-ToImFmzw8luY043pWFJhh2ZMm4IwxXoHXxNoGdlhD4Ym6+CCmkAvCglg0FK8dMLzAb+/XabmOE7Rbm8KZb6NZg=="],
@@ -1037,7 +1037,7 @@
"expo-notifications": ["expo-notifications@0.31.4", "", { "dependencies": { "@expo/image-utils": "^0.7.6", "@ide/backoff": "^1.0.0", "abort-controller": "^3.0.0", "assert": "^2.0.0", "badgin": "^1.1.5", "expo-application": "~6.1.5", "expo-constants": "~17.1.7" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-NnGKIFGpgZU66qfiFUyjEBYsS77VahURpSSeWEOLt+P1zOaUFlgx2XqS+dxH3/Bn1Vm7TMj04qKsK5KvzR/8Lw=="],
"expo-router": ["expo-router@5.1.5", "", { "dependencies": { "@expo/metro-runtime": "5.0.4", "@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-VPhS21DPP+riJIUshs/qpb11L/nzmRK7N7mqSFCr/mjpziznYu/qS+BPeQ88akIuXv6QsXipY5UTfYINdV+P0Q=="],
"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=="],
"expo-screen-orientation": ["expo-screen-orientation@8.1.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-nYwadYtdU6mMDk0MCHMPPPQtBoeFYJ2FspLRW+J35CMLqzE4nbpwGeiImfXzkvD94fpOCfI4KgLj5vGauC3pfA=="],
@@ -1609,7 +1609,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=="],
@@ -2003,6 +2003,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=="],
@@ -2101,6 +2103,12 @@
"@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=="],
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"ansi-fragments/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
@@ -2109,6 +2117,8 @@
"ansi-fragments/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
"ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"babel-preset-expo/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
@@ -2325,12 +2335,26 @@
"@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-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=="],
"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=="],
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
@@ -2423,6 +2447,18 @@
"@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=="],
"ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],

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,749 @@
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 { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Dimensions, Pressable, 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 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 [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 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 && (
<View
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'
/>
</View>
)}
{/* Type and Genres Section */}
<View
style={{
position: "absolute",
bottom: GENRES_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<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,
}}
>
{(() => {
const 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>
</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

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

@@ -24,6 +24,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";
@@ -40,6 +41,7 @@ interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
selectedOptions: SelectedOptions;
isOffline?: boolean;
colors?: ThemeColors;
}
const ANIMATION_DURATION = 500;
@@ -106,6 +108,7 @@ export const PlayButton: React.FC<Props> = ({
item,
selectedOptions,
isOffline,
colors,
...props
}: Props) => {
const { showActionSheetWithOptions } = useActionSheet();
@@ -113,16 +116,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();
@@ -316,7 +322,7 @@ export const PlayButton: React.FC<Props> = ({
);
useAnimatedReaction(
() => colorAtom,
() => effectiveColors,
(newColor) => {
endColor.value = newColor;
colorChangeProgress.value = 0;
@@ -325,19 +331,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
@@ -386,7 +392,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,
@@ -400,15 +406,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();
@@ -100,7 +106,7 @@ export const PlayButton: React.FC<Props> = ({
);
useAnimatedReaction(
() => colorAtom,
() => effectiveColors,
(newColor) => {
endColor.value = newColor;
colorChangeProgress.value = 0;
@@ -109,19 +115,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
@@ -188,7 +194,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

@@ -89,22 +89,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}
@@ -160,6 +162,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const tap = Gesture.Tap()
.maxDuration(2000)
.shouldCancelWhenOutside(true)
.onBegin(() => {
opacity.value = withTiming(0.8, { duration: 100 });
})
@@ -174,25 +177,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

@@ -47,7 +47,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";
@@ -58,6 +57,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";
@@ -133,8 +133,11 @@ export const HomeIndex = () => {
const segments = useSegments() as string[];
useEffect(() => {
const unsubscribe = eventBus.on("scrollToTop", () => {
if (segments[2] === "(home)")
scrollViewRef.current?.scrollTo({ y: -152, animated: true });
if ((segments as string[])[2] === "(home)")
scrollViewRef.current?.scrollTo({
y: Platform.isTV ? -152 : -100,
animated: true,
});
});
return () => {
@@ -200,9 +203,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,
})
@@ -244,8 +247,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",
@@ -258,9 +262,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 || [],
@@ -342,9 +346,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,
});
@@ -451,44 +455,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={`${section.type}-${section.title || "untitled"}-${index}`}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
/>
);
}
if (section.type === "MediaListSection") {
return (
<MediaListSection
key={`${section.type}-${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

@@ -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,8 +22,8 @@
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor"
},
"dependencies": {
"@bottom-tabs/react-navigation": "^0.9.2",
"@expo/metro-runtime": "~5.0.4",
"@bottom-tabs/react-navigation": "^0.11.2",
"@expo/metro-runtime": "~5.0.5",
"@expo/react-native-action-sheet": "^4.1.1",
"@expo/vector-icons": "^14.1.0",
"@gorhom/bottom-sheet": "^5.1.0",
@@ -36,7 +36,7 @@
"@shopify/flash-list": "^1.8.3",
"@tanstack/react-query": "^5.66.0",
"axios": "^1.7.9",
"expo": "^53.0.22",
"expo": "^53.0.23",
"expo-application": "~6.1.4",
"expo-asset": "~11.1.7",
"expo-background-task": "~0.2.8",
@@ -53,7 +53,7 @@
"expo-linking": "~7.1.4",
"expo-localization": "~16.1.5",
"expo-notifications": "~0.31.2",
"expo-router": "~5.1.5",
"expo-router": "~5.1.7",
"expo-screen-orientation": "~8.1.6",
"expo-sensors": "~14.1.4",
"expo-sharing": "~13.1.5",
@@ -71,7 +71,7 @@
"react-i18next": "^15.4.0",
"react-native": "npm:react-native-tvos@0.79.5-0",
"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",

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]);