Compare commits

...

24 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
a377317710 Replace password storage with token-based authentication for server switching
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2025-09-05 20:03:33 +00:00
copilot-swe-agent[bot]
85929c2854 Add 'Add New Server' functionality without requiring logout
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2025-09-05 18:15:50 +00:00
copilot-swe-agent[bot]
214832f81c Implement enhanced server switching with credential persistence and auto-login
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2025-09-05 18:14:44 +00:00
copilot-swe-agent[bot]
17f7b42728 Enhance server switcher with current server filtering and loading states
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2025-09-05 15:24:59 +00:00
copilot-swe-agent[bot]
b8c586139f Add server switching functionality to settings
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2025-09-05 15:21:27 +00:00
copilot-swe-agent[bot]
4dd5e97971 Initial plan 2025-09-05 15:10:15 +00:00
retardgerman
32094fbc9f docs: add copilot-instructions.md for context-aware AI suggestions (#1035)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Co-authored-by: lostb1t <coding-mosses0z@icloud.com>
2025-09-05 16:10:16 +02:00
renovate[bot]
b5917821a0 chore(deps): Update actions/setup-node action to v5 (#1038)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-09-05 00:55:24 +02:00
renovate[bot]
42922cc92b chore(deps): Update actions/stale action to v10 (#1039)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 00:23:23 +02:00
lostb1t
0298fb00aa fix: section titles (#1036)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
fixes a bug where the section titles were just "section-[index]"
2025-09-04 16:31:56 +01:00
Fredrik Burmester
e47c863aa4 chore: version
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2025-09-04 07:22:15 +02:00
Fredrik Burmester
0b9bbb63eb fix: download card design and percentage negative number fix
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2025-09-03 22:26:57 +02:00
Fredrik Burmester
b4014c922e feat: native download notifications (#1006) 2025-09-03 21:50:25 +02:00
stenlan
957e60714a fix: BaseItemDto/BaseItemPerson routing bug (#1030) 2025-09-03 21:45:15 +02:00
stenlan
93a63f6b48 fix: #1007 Season episodes temporarily disappearing when marking one as watched (#1031)
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2025-09-03 21:42:03 +02:00
stenlan
c778956a52 fix: Jellyseerr discovery crash (#1032) 2025-09-03 21:41:35 +02:00
MarcoCoreDuo
6308375438 fix: remove unnecessary sort for getTvShowsApi.getEpisodes data in SeasonPicker (#984)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-09-03 15:32:57 +02:00
lance chant
2d3344f013 fix: home screen translation for custom headers (#1001)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-09-03 15:31:43 +02:00
Fredrik Burmester
ae720d6bb4 fix: height for list items 2025-09-03 15:14:36 +02:00
lance chant
62b25d7bf7 fix: offline checking (#989) 2025-09-03 12:26:56 +02:00
Copilot
68e3b74e49 Fix Jellyseerr TV request permission logic bug (#1026)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2025-09-03 07:05:46 +02:00
Uruk
e8c9bb1730 Revert "feat: add password visibility toggle to login forms (#1019)"
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
This reverts commit ae09a59569.
2025-09-02 15:33:41 +02:00
Gauvain
ae09a59569 feat: add password visibility toggle to login forms (#1019)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-09-01 23:45:12 +02:00
renovate[bot]
8a6c6dbd69 chore(deps): Update github/codeql-action action to v3.30.0 (#1021) 2025-09-01 20:33:25 +02:00
80 changed files with 994 additions and 425 deletions

60
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,60 @@
# Copilot Instructions for Streamyfin
## Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
It supports mobile (iOS/Android) and TV platforms, and integrates with Jellyfin and Jellyseerr APIs.
## Main Technologies
- React Native (Expo)
- TypeScript
- React Query
- Jotai (state management)
- Jellyfin SDK (TypeScript)
- BiomeJS (code formatting/linting)
- EAS (Expo Application Services)
- Shell scripting (for automation)
- GitHub Actions (CI/CD)
## Code Structure
- `app/` Main application code (screens, navigation, etc.)
- `components/` Reusable UI components
- `providers/` Context and API providers (e.g., JellyfinProvider.tsx)
- `utils/` Utility functions and atoms
- `assets/` Images and static assets
- `scripts/` Automation scripts (Node.js, Bash)
- `plugins/` Expo/Metro plugins
- `README.md` Project documentation
## Coding Conventions
- Use TypeScript for all new code.
- Prefer functional React components.
- Use hooks for state and side effects.
- Use Jotai for global state.
- Use React Query for data fetching/caching.
- Use BiomeJS for formatting and linting.
- Follow the established folder structure for screens/components.
## API Usage
- Use the Jellyfin SDK for all server interactions.
- Use the `apiAtom` and `userAtom` from `JellyfinProvider` for authenticated API calls.
- For navigation, use `expo-router`.
## Commit Messages
- Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) (e.g., `feat:`, `fix:`, `chore:`).
- Example: `feat(player): add Chromecast support`
## Special Instructions
- When suggesting code, prefer using existing atoms, hooks, and utility functions.
- When adding new features, ensure they are accessible via both mobile and TV navigation if relevant.
- When updating dependencies or scripts, check for compatibility with Expo and EAS.
---
**Copilot: Please use these instructions to provide context-aware suggestions and code completions for this repository.**

View File

@@ -31,13 +31,13 @@ jobs:
fetch-depth: 0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3.29.11
uses: github/codeql-action/init@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3.29.11
uses: github/codeql-action/autobuild@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3.29.11
uses: github/codeql-action/analyze@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0

View File

@@ -106,7 +106,7 @@ jobs:
fetch-depth: 0
- name: "🟢 Setup Node.js"
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: '24.x'

View File

@@ -15,7 +15,7 @@ jobs:
steps:
- name: 🔄 Mark/Close Stale Issues
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
with:
# Global settings
repo-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: "🟢 Setup Node.js"
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: '24.x'
cache: 'npm'

View File

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

View File

@@ -7,6 +7,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { AddNewServer } from "@/components/settings/AddNewServer";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { AudioToggles } from "@/components/settings/AudioToggles";
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
@@ -17,6 +18,7 @@ import { MediaToggles } from "@/components/settings/MediaToggles";
import { OtherSettings } from "@/components/settings/OtherSettings";
import { PluginSettings } from "@/components/settings/PluginSettings";
import { QuickConnect } from "@/components/settings/QuickConnect";
import { ServerSwitcher } from "@/components/settings/ServerSwitcher";
import { StorageSettings } from "@/components/settings/StorageSettings";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { UserInfo } from "@/components/settings/UserInfo";
@@ -64,6 +66,10 @@ export default function settings() {
<View className='p-4 flex flex-col gap-y-4'>
<UserInfo />
<ServerSwitcher className='mb-4' />
<AddNewServer className='mb-4' />
<QuickConnect className='mb-4' />
<MediaProvider>

View File

@@ -12,7 +12,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const [settings, updateSettings, pluginSettings] = useSettings(null);
const { settings, updateSettings, pluginSettings } = useSettings();
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);

View File

@@ -3,7 +3,7 @@ import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const [_settings, _updateSettings, pluginSettings] = useSettings(null);
const { pluginSettings } = useSettings();
return (
<DisabledSetting

View File

@@ -21,7 +21,7 @@ export default function page() {
const { t } = useTranslation();
const [settings, updateSettings, pluginSettings] = useSettings(null);
const { settings, updateSettings, pluginSettings } = useSettings();
const queryClient = useQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");

View File

@@ -15,7 +15,7 @@ import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Dis
export default function page() {
const local = useLocalSearchParams();
const { jellyseerrApi } = useJellyseerr();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { companyId, image, type } = local as unknown as {
companyId: string;
@@ -53,7 +53,10 @@ export default function page() {
uniqBy(
data?.pages
?.filter((p) => p?.results.length)
.flatMap((p) => p?.results ?? []),
.flatMap(
(p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
),
"id",
) ?? [],
[data],
@@ -98,9 +101,7 @@ export default function page() {
}}
/>
}
renderItem={(item, _index) => (
<JellyseerrPoster item={item as MovieResult | TvResult} />
)}
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
/>
);
}

View File

@@ -8,14 +8,10 @@ import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import {
type MovieResult,
type TvResult,
} from "@/utils/jellyseerr/server/models/Search";
export default function page() {
const local = useLocalSearchParams();
const { jellyseerrApi } = useJellyseerr();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { genreId, name, type } = local as unknown as {
genreId: string;
@@ -51,7 +47,10 @@ export default function page() {
uniqBy(
data?.pages
?.filter((p) => p?.results.length)
.flatMap((p) => p?.results ?? []),
.flatMap(
(p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
),
"id",
) ?? [],
[data],
@@ -62,7 +61,7 @@ export default function page() {
jellyseerrApi
? flatData.map((r) =>
jellyseerrApi.imageProxy(
(r as TvResult | MovieResult).backdropPath,
r.backdropPath,
"w1920_and_h800_multi_faces",
),
)
@@ -92,9 +91,7 @@ export default function page() {
{name}
</Text>
}
renderItem={(item, _index) => (
<JellyseerrPoster item={item as MovieResult | TvResult} />
)}
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
/>
);
}

View File

@@ -10,10 +10,6 @@ import { OverviewText } from "@/components/OverviewText";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
export default function page() {
const local = useLocalSearchParams();
@@ -106,9 +102,7 @@ export default function page() {
MainContent={() => (
<OverviewText text={data?.details?.biography} className='mt-4' />
)}
renderItem={(item, _index) => (
<JellyseerrPoster item={item as MovieResult | TvResult} />
)}
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
/>
);
}

View File

@@ -7,7 +7,7 @@ import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorizontalScroll";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
@@ -22,21 +22,21 @@ import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
const page: React.FC = () => {
const local = useLocalSearchParams();
const { actorId } = local as { actorId: string };
const { personId } = local as { personId: string };
const { t } = useTranslation();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: item, isLoading: l1 } = useQuery({
queryKey: ["item", actorId],
queryKey: ["item", personId],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: actorId,
itemId: personId,
}),
enabled: !!actorId && !!api,
enabled: !!personId && !!api,
staleTime: 60,
});
@@ -50,7 +50,7 @@ const page: React.FC = () => {
const response = await getItemsApi(api).getItems({
userId: user.Id,
personIds: [actorId],
personIds: [personId],
startIndex: pageParam,
limit: 16,
sortOrder: ["Descending", "Descending", "Ascending"],
@@ -68,7 +68,7 @@ const page: React.FC = () => {
return response.data;
},
[api, user?.Id, actorId],
[api, user?.Id, personId],
);
const backdropUrl = useMemo(
@@ -131,7 +131,7 @@ const page: React.FC = () => {
</TouchableItemRouter>
)}
queryFn={fetchItems}
queryKey={["actor", "movies", actorId]}
queryKey={["actor", "movies", personId]}
/>
<View className='h-12' />
</View>

View File

@@ -9,7 +9,7 @@ const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next";
export default function IndexLayout() {
const [settings, updateSettings, pluginSettings] = useSettings(null);
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();

View File

@@ -19,7 +19,7 @@ export default function index() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const queryClient = useQueryClient();
const [settings] = useSettings(null);
const { settings } = useSettings();
const { t } = useTranslation();

View File

@@ -71,7 +71,7 @@ export default function search() {
const [api] = useAtom(apiAtom);
const [settings] = useSettings(null);
const { settings } = useSettings();
const { jellyseerrApi } = useJellyseerr();
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
useState<JellyseerrSearchSort>(

View File

@@ -27,7 +27,7 @@ export const NativeTabs = withLayoutContext<
>(Navigator);
export default function TabLayout() {
const [settings] = useSettings(null);
const { settings } = useSettings();
const { t } = useTranslation();
const router = useRouter();

View File

@@ -97,7 +97,7 @@ export default function page() {
/** Playback position in ticks. */
playbackPosition?: string;
}>();
const [_settings] = useSettings(null);
useSettings();
const offline = offlineStr === "true";
const playbackManager = usePlaybackManager();
@@ -748,7 +748,6 @@ export default function page() {
setAspectRatio={setAspectRatio}
setScaleFactor={setScaleFactor}
isVlc
api={api}
downloadedFiles={downloadedFiles}
/>
)}

View File

@@ -230,7 +230,7 @@ const queryClient = new QueryClient({
});
function Layout() {
const [settings] = useSettings(null);
const { settings } = useSettings();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const appState = useRef(AppState.currentState);
@@ -269,6 +269,15 @@ function Layout() {
await Notifications?.setNotificationChannelAsync("default", {
name: "default",
});
// Create dedicated channel for download notifications
console.log("Setting android notification channel 'downloads'");
await Notifications?.setNotificationChannelAsync("downloads", {
name: "Downloads",
importance: Notifications.AndroidImportance.DEFAULT,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#FF231F7C",
});
}
const granted = await checkAndRequestPermissions();

View File

@@ -61,7 +61,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [queue, _setQueue] = useAtom(queueAtom);
const [settings] = useSettings(null);
const { settings } = useSettings();
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
const { processes, startBackgroundDownload, getDownloadedItems } =

View File

@@ -54,7 +54,7 @@ interface ItemContentProps {
export const ItemContent: React.FC<ItemContentProps> = React.memo(
({ item, isOffline }) => {
const [api] = useAtom(apiAtom);
const [settings] = useSettings(null);
const { settings } = useSettings();
const { orientation } = useOrientation();
const navigation = useNavigation();
const insets = useSafeAreaInsets();

View File

@@ -5,7 +5,7 @@ import { useAtom } from "jotai";
import type React from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";

View File

@@ -67,7 +67,7 @@ export const PlayButton: React.FC<Props> = ({
const startColor = useSharedValue(colorAtom);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const [settings, updateSettings] = useSettings(null);
const { settings, updateSettings } = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(

View File

@@ -44,7 +44,7 @@ export const PlayButton: React.FC<Props> = ({
const startColor = useSharedValue(colorAtom);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const [settings] = useSettings(null);
const { settings } = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(

View File

@@ -8,6 +8,10 @@ import { ListItem } from "./list/ListItem";
interface Server {
address: string;
serverName?: string;
serverId?: string;
lastUsername?: string;
savedToken?: string;
}
interface PreviousServersListProps {
@@ -26,6 +30,20 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
const { t } = useTranslation();
const getServerDisplayName = (server: Server) => {
if (server.serverName) {
return `${server.serverName}`;
}
return server.address;
};
const getServerSubtitle = (server: Server) => {
if (server.lastUsername) {
return `${server.address}${server.lastUsername}`;
}
return server.address;
};
if (!previousServers.length) return null;
return (
@@ -35,7 +53,9 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
<ListItem
key={s.address}
onPress={() => onServerSelect(s)}
title={s.address}
title={getServerDisplayName(s)}
subtitle={getServerSubtitle(s)}
icon={s.savedToken ? "key" : "server"}
showArrow
/>
))}

View File

@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { HorizontalScroll } from "./common/HorrizontalScroll";
import { HorizontalScroll } from "./common/HorizontalScroll";
import { Text } from "./common/Text";
import { TouchableItemRouter } from "./common/TouchableItemRouter";
import { ItemCardText } from "./ItemCardText";

View File

@@ -10,6 +10,7 @@ import {
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import type {
MovieResult,
TvResult,
@@ -17,7 +18,7 @@ import type {
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
interface Props extends TouchableOpacityProps {
result?: MovieResult | TvResult | MovieDetails | TvDetails;
result?: MovieResult | TvResult | MovieDetails | TvDetails | PersonCreditCast;
mediaTitle: string;
releaseYear: number;
canRequest: boolean;

View File

@@ -1,8 +1,5 @@
import { useActionSheet } from "@expo/react-native-action-sheet";
import type {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter, useSegments } from "expo-router";
import { type PropsWithChildren, useCallback } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
@@ -14,10 +11,7 @@ interface Props extends TouchableOpacityProps {
isOffline?: boolean;
}
export const itemRouter = (
item: BaseItemDto | BaseItemPerson,
from: string,
) => {
export const itemRouter = (item: BaseItemDto, from: string) => {
if ("CollectionType" in item && item.CollectionType === "livetv") {
return `/(auth)/(tabs)/${from}/livetv`;
}
@@ -26,8 +20,8 @@ export const itemRouter = (
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
}
if (item.Type === "Person" || item.Type === "Actor") {
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
if (item.Type === "Person") {
return `/(auth)/(tabs)/${from}/persons/${item.Id}`;
}
if (item.Type === "BoxSet") {

View File

@@ -1,27 +1,9 @@
import { Ionicons } from "@expo/vector-icons";
import { useQueryClient } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { t } from "i18next";
import { useMemo } from "react";
import {
ActivityIndicator,
TouchableOpacity,
type TouchableOpacityProps,
View,
type ViewProps,
} from "react-native";
import { toast } from "sonner-native";
import { View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { JobStatus } from "@/providers/Downloads/types";
import { storage } from "@/utils/mmkv";
import { formatTimeString } from "@/utils/time";
import { Button } from "../Button";
const bytesToMB = (bytes: number) => {
return bytes / 1024 / 1024;
};
import { DownloadCard } from "./DownloadCard";
interface ActiveDownloadsProps extends ViewProps {}
@@ -52,163 +34,3 @@ export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
</View>
);
}
interface DownloadCardProps extends TouchableOpacityProps {
process: JobStatus;
}
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { startDownload, pauseDownload, resumeDownload, removeProcess } =
useDownload();
const router = useRouter();
const queryClient = useQueryClient();
const handlePause = async (id: string) => {
try {
await pauseDownload(id);
toast.success(t("home.downloads.toasts.download_paused"));
} catch (error) {
console.error("Error pausing download:", error);
toast.error(t("home.downloads.toasts.could_not_pause_download"));
}
};
const handleResume = async (id: string) => {
try {
await resumeDownload(id);
toast.success(t("home.downloads.toasts.download_resumed"));
} catch (error) {
console.error("Error resuming download:", error);
toast.error(t("home.downloads.toasts.could_not_resume_download"));
}
};
const handleDelete = async (id: string) => {
try {
await removeProcess(id);
toast.success(t("home.downloads.toasts.download_deleted"));
queryClient.invalidateQueries({ queryKey: ["downloads"] });
} catch (error) {
console.error("Error deleting download:", error);
toast.error(t("home.downloads.toasts.could_not_delete_download"));
}
};
const eta = (p: JobStatus) => {
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
if (bytesRemaining <= 0) return null;
const secondsRemaining = bytesRemaining / p.speed;
return formatTimeString(secondsRemaining, "s");
};
const base64Image = useMemo(() => {
return storage.getString(process.item.Id!);
}, []);
return (
<TouchableOpacity
onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)}
className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden'
{...props}
>
{process.status === "downloading" && (
<View
className={`
bg-purple-600 h-1 absolute bottom-0 left-0
`}
style={{
width: process.progress
? `${Math.max(5, process.progress)}%`
: "5%",
}}
/>
)}
<View className='px-3 py-1.5 flex flex-col w-full'>
<View className='flex flex-row items-center w-full'>
{base64Image && (
<View className='w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4'>
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
}}
contentFit='cover'
/>
</View>
)}
<View className='shrink mb-1'>
<Text className='text-xs opacity-50'>{process.item.Type}</Text>
<Text className='font-semibold shrink'>{process.item.Name}</Text>
<Text className='text-xs opacity-50'>
{process.item.ProductionYear}
</Text>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
{process.progress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<Text className='text-xs'>{process.progress.toFixed(0)}%</Text>
)}
{process.speed && process.speed > 0 && (
<Text className='text-xs'>
{bytesToMB(process.speed).toFixed(2)} MB/s
</Text>
)}
{eta(process) && (
<Text className='text-xs'>
{t("home.downloads.eta", { eta: eta(process) })}
</Text>
)}
</View>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
<Text className='text-xs capitalize'>{process.status}</Text>
</View>
</View>
<View className='ml-auto flex flex-row items-center space-x-2'>
{process.status === "downloading" && (
<TouchableOpacity
onPress={() => handlePause(process.id)}
className='p-2 rounded-full bg-yellow-600'
>
<Ionicons name='pause' size={20} color='white' />
</TouchableOpacity>
)}
{process.status === "paused" && (
<TouchableOpacity
onPress={() => handleResume(process.id)}
className='p-2 rounded-full bg-green-600'
>
<Ionicons name='play' size={20} color='white' />
</TouchableOpacity>
)}
<TouchableOpacity
onPress={() => handleDelete(process.id)}
className='p-2 rounded-full bg-red-600'
>
<Ionicons name='close' size={20} color='white' />
</TouchableOpacity>
</View>
</View>
{process.status === "completed" && (
<View className='flex flex-row mt-4 space-x-4'>
<Button
onPress={() => {
startDownload(process);
}}
className='w-full'
>
Download now
</Button>
</View>
)}
</View>
</TouchableOpacity>
);
};

View File

@@ -0,0 +1,198 @@
import { Ionicons } from "@expo/vector-icons";
import { useQueryClient } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { t } from "i18next";
import { useMemo } from "react";
import {
ActivityIndicator,
TouchableOpacity,
type TouchableOpacityProps,
View,
} from "react-native";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { JobStatus } from "@/providers/Downloads/types";
import { storage } from "@/utils/mmkv";
import { formatTimeString } from "@/utils/time";
import { Button } from "../Button";
const bytesToMB = (bytes: number) => {
return bytes / 1024 / 1024;
};
interface DownloadCardProps extends TouchableOpacityProps {
process: JobStatus;
}
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { startDownload, pauseDownload, resumeDownload, removeProcess } =
useDownload();
const router = useRouter();
const queryClient = useQueryClient();
const handlePause = async (id: string) => {
try {
await pauseDownload(id);
toast.success(t("home.downloads.toasts.download_paused"));
} catch (error) {
console.error("Error pausing download:", error);
toast.error(t("home.downloads.toasts.could_not_pause_download"));
}
};
const handleResume = async (id: string) => {
try {
await resumeDownload(id);
toast.success(t("home.downloads.toasts.download_resumed"));
} catch (error) {
console.error("Error resuming download:", error);
toast.error(t("home.downloads.toasts.could_not_resume_download"));
}
};
const handleDelete = async (id: string) => {
try {
await removeProcess(id);
toast.success(t("home.downloads.toasts.download_deleted"));
queryClient.invalidateQueries({ queryKey: ["downloads"] });
} catch (error) {
console.error("Error deleting download:", error);
toast.error(t("home.downloads.toasts.could_not_delete_download"));
}
};
const eta = (p: JobStatus) => {
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
if (bytesRemaining <= 0) return null;
const secondsRemaining = bytesRemaining / p.speed;
return formatTimeString(secondsRemaining, "s");
};
const base64Image = useMemo(() => {
return storage.getString(process.item.Id!);
}, []);
// Sanitize progress to ensure it's within valid bounds
const sanitizedProgress = useMemo(() => {
if (
typeof process.progress !== "number" ||
Number.isNaN(process.progress)
) {
return 0;
}
return Math.max(0, Math.min(100, process.progress));
}, [process.progress]);
return (
<TouchableOpacity
onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)}
className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden'
{...props}
>
{process.status === "downloading" && (
<View
className={`
bg-purple-600 h-1 absolute bottom-0 left-0
`}
style={{
width:
sanitizedProgress > 0
? `${Math.max(5, sanitizedProgress)}%`
: "5%",
}}
/>
)}
{/* 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" && (
<TouchableOpacity
onPress={() => handlePause(process.id)}
className='p-1'
>
<Ionicons name='pause' size={20} color='white' />
</TouchableOpacity>
)}
{process.status === "paused" && (
<TouchableOpacity
onPress={() => handleResume(process.id)}
className='p-1'
>
<Ionicons name='play' size={20} color='white' />
</TouchableOpacity>
)}
<TouchableOpacity
onPress={() => handleDelete(process.id)}
className='p-1'
>
<Ionicons name='close' size={20} color='red' />
</TouchableOpacity>
</View>
<View className='px-3 py-1.5 flex flex-col w-full'>
<View className='flex flex-row items-center w-full'>
{base64Image && (
<View className='w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4'>
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
}}
contentFit='cover'
/>
</View>
)}
<View className='shrink mb-1 flex-1'>
<Text className='text-xs opacity-50'>{process.item.Type}</Text>
<Text className='font-semibold shrink'>{process.item.Name}</Text>
<Text className='text-xs opacity-50'>
{process.item.ProductionYear}
</Text>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
{sanitizedProgress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<Text className='text-xs'>{sanitizedProgress.toFixed(0)}%</Text>
)}
{process.speed && process.speed > 0 && (
<Text className='text-xs'>
{bytesToMB(process.speed).toFixed(2)} MB/s
</Text>
)}
{eta(process) && (
<Text className='text-xs'>
{t("home.downloads.eta", { eta: eta(process) })}
</Text>
)}
</View>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
<Text className='text-xs capitalize'>{process.status}</Text>
</View>
</View>
</View>
{process.status === "completed" && (
<View className='flex flex-row mt-4 space-x-4'>
<Button
onPress={() => {
startDownload(process);
}}
className='w-full'
>
Download now
</Button>
</View>
)}
</View>
</TouchableOpacity>
);
};

View File

@@ -26,7 +26,7 @@ import { itemRouter } from "../common/TouchableItemRouter";
interface Props extends ViewProps {}
export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
const [settings] = useSettings(null);
const { settings } = useSettings();
const ref = React.useRef<ICarouselInstance>(null);
const progress = useSharedValue<number>(0);

View File

@@ -11,16 +11,12 @@ import {
useJellyseerr,
} from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
slide,
...props
}) => {
const { jellyseerrApi } = useJellyseerr();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["jellyseerr", "discover", slide.id],
@@ -69,7 +65,9 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
uniqBy(
data?.pages
?.filter((p) => p?.results.length)
.flatMap((p) => p?.results),
.flatMap((p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)),
),
"id",
),
[data],
@@ -86,12 +84,7 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
onEndReached={() => {
if (hasNextPage) fetchNextPage();
}}
renderItem={(item) => (
<JellyseerrPoster
item={item as MovieResult | TvResult}
key={item?.id}
/>
)}
renderItem={(item) => <JellyseerrPoster item={item} key={item?.id} />}
/>
)
);

View File

@@ -41,7 +41,7 @@ const icons: Record<CollectionType, IconName> = {
export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [settings] = useSettings(null);
const { settings } = useSettings();
const { t } = useTranslation();

View File

@@ -34,7 +34,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity
disabled={disabled}
onPress={onPress}
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 pl-4 ${
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${
disabled ? "opacity-50" : ""
}`}
{...(viewProps as any)}
@@ -54,7 +54,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
);
return (
<View
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 pl-4 ${
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${
disabled ? "opacity-50" : ""
}`}
{...viewProps}
@@ -106,7 +106,10 @@ const ListItemContent = ({
{title}
</Text>
{subtitle && (
<Text className='text-[#9899A1] text-sm mt-0.5' numberOfLines={2}>
<Text
className='text-[#9899A1] text-[12px] mt-0.5'
numberOfLines={2}
>
{subtitle}
</Text>
)}

View File

@@ -12,7 +12,7 @@ import { useAtom } from "jotai";
import { useCallback } from "react";
import { View, type ViewProps } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { InfiniteHorizontalScroll } from "../common/InfiniteHorrizontalScroll";
import { InfiniteHorizontalScroll } from "../common/InfiniteHorizontalScroll";
import { Text } from "../common/Text";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText";

View File

@@ -20,6 +20,7 @@ import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { DownloadingItem } from "@/utils/jellyseerr/server/lib/downloadtracker";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import type {
MovieResult,
TvResult,
@@ -27,7 +28,7 @@ import type {
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
interface Props extends ViewProps {
item?: MovieResult | TvResult | MovieDetails | TvDetails;
item?: MovieResult | TvResult | MovieDetails | TvDetails | PersonCreditCast;
horizontal?: boolean;
showDownloadInfo?: boolean;
mediaRequest?: MediaRequest;

View File

@@ -10,7 +10,7 @@ import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { HorizontalScroll } from "../common/HorizontalScroll";
import { Text } from "../common/Text";
import { itemRouter } from "../common/TouchableItemRouter";
import Poster from "../posters/Poster";
@@ -27,16 +27,18 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
const from = segments[2];
const destinctPeople = useMemo(() => {
const people: BaseItemPerson[] = [];
const people: Record<string, BaseItemPerson> = {};
item?.People?.forEach((person) => {
const existingPerson = people.find((p) => p.Id === person.Id);
if (!person.Id) return;
const existingPerson = people[person.Id];
if (existingPerson) {
existingPerson.Role = `${existingPerson.Role}, ${person.Role}`;
} else {
people.push(person);
people[person.Id] = person;
}
});
return people;
return Object.values(people);
}, [item?.People]);
if (!from) return null;
@@ -54,7 +56,13 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
renderItem={(i) => (
<TouchableOpacity
onPress={() => {
const url = itemRouter(i, from);
const url = itemRouter(
{
Id: i.Id,
Type: "Person",
},
from,
);
// @ts-expect-error
router.push(url);
}}

View File

@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { HorizontalScroll } from "../common/HorizontalScroll";
import { Text } from "../common/Text";
import Poster from "../posters/Poster";

View File

@@ -11,7 +11,7 @@ import { orderBy } from "lodash";
import type React from "react";
import { useCallback, useMemo, useState } from "react";
import { Alert, TouchableOpacity, View } from "react-native";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
import { Text } from "@/components/common/Text";
import { Tags } from "@/components/GenreTags";
import { dateOpts } from "@/components/jellyseerr/DetailFacts";

View File

@@ -12,7 +12,7 @@ import ContinueWatchingPoster from "../ContinueWatchingPoster";
import {
HorizontalScroll,
type HorizontalScrollRef,
} from "../common/HorrizontalScroll";
} from "../common/HorizontalScroll";
import { ItemCardText } from "../ItemCardText";
interface Props extends ViewProps {
@@ -42,11 +42,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
return item?.SeasonId;
}, [item]);
const {
data: episodes,
isLoading,
isFetching,
} = useQuery({
const { data: episodes, isPending } = useQuery({
queryKey: ["episodes", seasonId, isOffline],
queryFn: async () => {
if (isOffline) {
@@ -132,7 +128,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
ref={scrollRef}
data={episodes}
extraData={item}
loading={loading || isLoading || isFetching}
loading={loading || isPending}
renderItem={(_item, _idx) => (
<TouchableOpacity
key={_item.Id}

View File

@@ -74,7 +74,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
return season.Id!;
}, [seasons, seasonIndex]);
const { data: episodes, isFetching } = useQuery({
const { data: episodes, isPending } = useQuery({
queryKey: ["episodes", item.Id, selectedSeasonId],
queryFn: async () => {
if (!api || !user?.Id || !item.Id || !selectedSeasonId) {
@@ -98,10 +98,6 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
return res.data.Items;
},
select: (data) =>
[...(data || [])].sort(
(a, b) => (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
),
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
});
@@ -169,7 +165,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
) : null}
</View>
<View className='px-4 flex flex-col mt-4'>
{isFetching ? (
{isPending ? (
<View
style={{
minHeight: 144 * nrOfEpisodes,

View File

@@ -0,0 +1,124 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, View, type ViewProps } from "react-native";
import { useJellyfin } from "@/providers/JellyfinProvider";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { Input } from "../common/Input";
import { Button } from "../Button";
interface Props extends ViewProps {}
export const AddNewServer: React.FC<Props> = ({ ...props }) => {
const [showForm, setShowForm] = useState(false);
const [serverUrl, setServerUrl] = useState("");
const [loading, setLoading] = useState(false);
const { addNewServer } = useJellyfin();
const { t } = useTranslation();
const handleAddServer = async () => {
if (!serverUrl.trim()) {
Alert.alert(t("login.error_title"), "Please enter a server URL");
return;
}
setLoading(true);
try {
// Validate URL format
const cleanUrl = serverUrl.trim().replace(/\/$/, "");
// Test connection to the server
const baseUrl = cleanUrl.replace(/^https?:\/\//i, "");
const protocols = ["https", "http"];
let validUrl: string | null = null;
for (const protocol of protocols) {
try {
const response = await fetch(
`${protocol}://${baseUrl}/System/Info/Public`,
{ mode: "cors" }
);
if (response.ok) {
validUrl = `${protocol}://${baseUrl}`;
break;
}
} catch (error) {
// Continue to next protocol
}
}
if (!validUrl) {
Alert.alert(
t("login.connection_failed"),
t("login.could_not_connect_to_server")
);
return;
}
// Add the server to the list
await addNewServer({ address: validUrl });
Alert.alert(
"Success",
`Server ${validUrl} has been added to your server list. You can now switch to it from Quick Switch Servers.`
);
setServerUrl("");
setShowForm(false);
} catch (error) {
console.error("Failed to add server:", error);
Alert.alert(
t("login.error_title"),
"Failed to add server. Please try again."
);
} finally {
setLoading(false);
}
};
return (
<View {...props}>
<ListGroup title={t("server.add_new_server")}>
{!showForm ? (
<ListItem
onPress={() => setShowForm(true)}
title="Add Server"
icon="add"
showArrow
/>
) : (
<View className="p-4 space-y-4">
<Input
placeholder={t("server.server_url_placeholder")}
value={serverUrl}
onChangeText={setServerUrl}
keyboardType="url"
autoCapitalize="none"
textContentType="URL"
maxLength={500}
/>
<View className="flex-row space-x-2">
<Button
onPress={handleAddServer}
loading={loading}
disabled={loading || !serverUrl.trim()}
className="flex-1"
>
Add Server
</Button>
<Button
onPress={() => {
setShowForm(false);
setServerUrl("");
}}
className="flex-1 bg-neutral-800 border border-neutral-700"
>
Cancel
</Button>
</View>
</View>
)}
</ListGroup>
</View>
);
};

View File

@@ -12,7 +12,7 @@ interface Props extends ViewProps {}
export const AppLanguageSelector: React.FC<Props> = () => {
const isTv = Platform.isTV;
const [settings, updateSettings] = useSettings(null);
const { settings, updateSettings } = useSettings();
const { t } = useTranslation();
if (isTv) return null;

View File

@@ -17,7 +17,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
const isTv = Platform.isTV;
const media = useMedia();
const [_, __, pluginSettings] = useSettings(null);
const { pluginSettings } = useSettings();
const { settings, updateSettings } = media;
const cultures = media.cultures;
const { t } = useTranslation();

View File

@@ -4,7 +4,7 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const ChromecastSettings: React.FC = ({ ...props }) => {
const [settings, updateSettings] = useSettings(null);
const { settings, updateSettings } = useSettings();
return (
<View {...props}>
<ListGroup title={"Chromecast"}>

View File

@@ -7,7 +7,7 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const Dashboard = () => {
const [settings, _updateSettings] = useSettings(null);
const { settings } = useSettings();
const { sessions = [] } = useSessions({} as useSessionsProps);
const router = useRouter();

View File

@@ -7,7 +7,7 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export default function DownloadSettings({ ...props }) {
const [settings, updateSettings, pluginSettings] = useSettings(null);
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();
const allDisabled = useMemo(

View File

@@ -13,7 +13,7 @@ interface Props extends ViewProps {}
export const GestureControls: React.FC<Props> = ({ ...props }) => {
const { t } = useTranslation();
const [settings, updateSettings, pluginSettings] = useSettings(null);
const { settings, updateSettings, pluginSettings } = useSettings();
const disabled = useMemo(
() =>

View File

@@ -11,7 +11,6 @@ import {
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
import { type QueryFunction, useQuery } from "@tanstack/react-query";
import { useNavigation, useRouter, useSegments } from "expo-router";
import { useAtomValue } from "jotai";
@@ -33,6 +32,7 @@ import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionLi
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -64,16 +64,7 @@ export const HomeIndex = () => {
const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false);
const [
settings,
_updateSettings,
_pluginSettings,
_setPluginSettings,
refreshStreamyfinPluginSettings,
] = useSettings(null);
const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false);
const { settings, refreshStreamyfinPluginSettings } = useSettings();
const navigation = useNavigation();
@@ -83,6 +74,7 @@ export const HomeIndex = () => {
const { getDownloadedItems, cleanCacheDirectory } = useDownload();
const prevIsConnected = useRef<boolean | null>(false);
const { isConnected, loading: retryLoading, retryCheck } = useNetworkStatus();
const invalidateCache = useInvalidatePlaybackProgressCache();
useEffect(() => {
// Only invalidate cache when transitioning from offline to online
@@ -137,29 +129,6 @@ export const HomeIndex = () => {
};
}, [segments]);
const checkConnection = useCallback(async () => {
setLoadingRetry(true);
const state = await NetInfo.fetch();
setIsConnected(state.isConnected);
setLoadingRetry(false);
}, []);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected === false || state.isInternetReachable === false)
setIsConnected(false);
else setIsConnected(true);
});
NetInfo.fetch().then((state) => {
setIsConnected(state.isConnected);
});
return () => {
unsubscribe();
};
}, []);
const {
data,
isError: e1,
@@ -339,9 +308,9 @@ export const HomeIndex = () => {
if (!api || !user?.Id || !settings?.home?.sections) return [];
const ss: Section[] = [];
for (const [index, section] of settings.home.sections.entries()) {
const id = section.items?.title || `section-${index}`;
const id = section.title || `section-${index}`;
ss.push({
title: id,
title: t(`${id}`),
queryKey: ["home", id],
queryFn: async () => {
if (section.items) {
@@ -397,30 +366,30 @@ export const HomeIndex = () => {
{t("home.no_internet_message")}
</Text>
<View className='mt-4'>
<Button
color='purple'
onPress={() => router.push("/(auth)/downloads")}
justify='center'
iconRight={
<Ionicons name='arrow-forward' size={20} color='white' />
}
>
{t("home.go_to_downloads")}
</Button>
{!Platform.isTV && (
<Button
color='purple'
onPress={() => router.push("/(auth)/downloads")}
justify='center'
iconRight={
<Ionicons name='arrow-forward' size={20} color='white' />
}
>
{t("home.go_to_downloads")}
</Button>
)}
<Button
color='black'
onPress={() => {
checkConnection();
}}
onPress={retryCheck}
justify='center'
className='mt-2'
iconRight={
loadingRetry ? null : (
retryLoading ? null : (
<Ionicons name='refresh' size={20} color='white' />
)
}
>
{loadingRetry ? (
{retryLoading ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
"Retry"

View File

@@ -20,7 +20,7 @@ export const JellyseerrSettings = () => {
const { t } = useTranslation();
const [user] = useAtom(userAtom);
const [settings, updateSettings, _pluginSettings] = useSettings(null);
const { settings, updateSettings } = useSettings();
const [jellyseerrPassword, setJellyseerrPassword] = useState<
string | undefined

View File

@@ -28,7 +28,7 @@ export const useMedia = () => {
};
export const MediaProvider = ({ children }: { children: ReactNode }) => {
const [settings, updateSettings] = useSettings(null);
const { settings, updateSettings } = useSettings();
const api = useAtomValue(apiAtom);
const queryClient = useQueryClient();

View File

@@ -13,7 +13,7 @@ interface Props extends ViewProps {}
export const MediaToggles: React.FC<Props> = ({ ...props }) => {
const { t } = useTranslation();
const [settings, updateSettings, pluginSettings] = useSettings(null);
const { settings, updateSettings, pluginSettings } = useSettings();
const disabled = useMemo(
() =>

View File

@@ -23,7 +23,7 @@ import { ListItem } from "../list/ListItem";
export const OtherSettings: React.FC = () => {
const router = useRouter();
const [settings, updateSettings, pluginSettings] = useSettings(null);
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();

View File

@@ -6,7 +6,7 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const PluginSettings = () => {
const [settings, _updateSettings] = useSettings(null);
const { settings } = useSettings();
const router = useRouter();

View File

@@ -0,0 +1,89 @@
import { useAtom } from "jotai";
import type React from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import { useMMKVString } from "react-native-mmkv";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
interface Server {
address: string;
serverName?: string;
serverId?: string;
lastUsername?: string;
savedToken?: string;
}
interface Props extends ViewProps {}
export const ServerSwitcher: React.FC<Props> = ({ ...props }) => {
const [_previousServers] = useMMKVString("previousServers");
const [api] = useAtom(apiAtom);
const [switchingServer, setSwitchingServer] = useState<string | null>(null);
const { switchServer } = useJellyfin();
const { t } = useTranslation();
const previousServers = useMemo(() => {
const servers = JSON.parse(_previousServers || "[]") as Server[];
// Filter out the current server since we don't need to "switch" to it
const currentServer = api?.basePath;
return servers.filter((server) => server.address !== currentServer);
}, [_previousServers, api?.basePath]);
const handleServerSwitch = async (server: Server) => {
try {
setSwitchingServer(server.address);
await switchServer(server);
} catch (error) {
console.error("Failed to switch server:", error);
setSwitchingServer(null);
}
};
const getServerDisplayName = (server: Server) => {
if (server.serverName) {
return `${server.serverName} (${server.address})`;
}
return server.address;
};
const getServerSubtitle = (server: Server) => {
if (server.lastUsername) {
const hasToken = !!server.savedToken;
return hasToken
? `${server.lastUsername} • Auto-login available`
: `Last user: ${server.lastUsername}`;
}
return undefined;
};
if (!previousServers.length) {
return (
<View {...props}>
<ListGroup title={t("server.quick_switch")}>
<ListItem title={t("server.no_previous_servers")} disabled />
</ListGroup>
</View>
);
}
return (
<View {...props}>
<ListGroup title={t("server.quick_switch")}>
{previousServers.map((server) => (
<ListItem
key={server.address}
onPress={() => handleServerSwitch(server)}
title={getServerDisplayName(server)}
subtitle={getServerSubtitle(server)}
icon={server.savedToken ? "key" : "server"}
showArrow
disabled={switchingServer === server.address}
/>
))}
</ListGroup>
</View>
);
};

View File

@@ -20,7 +20,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
const isTv = Platform.isTV;
const media = useMedia();
const [_, __, pluginSettings] = useSettings(null);
const { pluginSettings } = useSettings();
const { settings, updateSettings } = media;
const cultures = media.cultures;
const { t } = useTranslation();

View File

@@ -17,7 +17,7 @@ export const commonScreenOptions: ICommonScreenOptions = {
headerLeft: () => <HeaderBackButton />,
};
const routes = ["actors/[actorId]", "items/page", "series/[id]"];
const routes = ["persons/[personId]", "items/page", "series/[id]"];
export const nestedTabPageScreenOptions: Record<string, ICommonScreenOptions> =
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));

View File

@@ -88,7 +88,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
trickplayInfo,
time,
}) => {
const [settings] = useSettings(null);
const { settings } = useSettings();
const insets = useSafeAreaInsets();
return (

View File

@@ -30,7 +30,7 @@ export const CenterControls: FC<CenterControlsProps> = ({
handleSkipBackward,
handleSkipForward,
}) => {
const [settings] = useSettings(null);
const { settings } = useSettings();
const insets = useSafeAreaInsets();
return (

View File

@@ -16,7 +16,7 @@ export interface ContinueWatchingOverlayProps {
const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
goToNextItem,
}) => {
const [settings] = useSettings(null);
const { settings } = useSettings();
const router = useRouter();
return settings.autoPlayEpisodeCount >=

View File

@@ -116,7 +116,7 @@ export const Controls: FC<Props> = ({
api = null,
downloadedFiles = undefined,
}) => {
const [settings, updateSettings] = useSettings(api);
const { settings, updateSettings } = useSettings();
const router = useRouter();
const lightHapticFeedback = useHaptic("light");

View File

@@ -11,7 +11,7 @@ import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import {
HorizontalScroll,
type HorizontalScrollRef,
} from "@/components/common/HorrizontalScroll";
} from "@/components/common/HorizontalScroll";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import {

View File

@@ -31,7 +31,7 @@ export const GestureOverlay = ({
onSkipForward,
onSkipBackward,
}: Props) => {
const [settings] = useSettings(null);
const { settings } = useSettings();
const lightHaptic = useHaptic("light");
const [feedback, setFeedback] = useState<FeedbackState>({

View File

@@ -70,7 +70,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
setVideoAspectRatio,
setVideoScaleFactor,
}) => {
const [settings] = useSettings(null);
const { settings } = useSettings();
const router = useRouter();
const insets = useSafeAreaInsets();
const { width: screenWidth } = useWindowDimensions();

View File

@@ -20,7 +20,7 @@ export function useVideoNavigation({
seek,
play,
}: UseVideoNavigationProps) {
const [settings] = useSettings(null);
const { settings } = useSettings();
const lightHapticFeedback = useHaptic("light");
const wasPlayingRef = useRef(false);

View File

@@ -26,13 +26,6 @@
"EXPO_PUBLIC_WRITE_DEBUG": "1"
}
},
"preview": {
"environment": "development",
"distribution": "internal",
"env": {
"EXPO_PUBLIC_WRITE_DEBUG": "1"
}
},
"development-simulator": {
"environment": "development",
"developmentClient": true,
@@ -44,16 +37,22 @@
"EXPO_PUBLIC_WRITE_DEBUG": "1"
}
},
"preview": {
"distribution": "internal",
"env": {
"EXPO_PUBLIC_WRITE_DEBUG": "1"
}
},
"production": {
"environment": "production",
"channel": "0.35.0",
"channel": "0.36.0",
"android": {
"image": "latest"
}
},
"production-apk": {
"environment": "production",
"channel": "0.35.0",
"channel": "0.36.0",
"android": {
"buildType": "apk",
"image": "latest"
@@ -61,7 +60,7 @@
},
"production-apk-tv": {
"environment": "production",
"channel": "0.35.0",
"channel": "0.36.0",
"android": {
"buildType": "apk",
"image": "latest"

View File

@@ -14,7 +14,7 @@ export type HapticFeedbackType =
| "error";
export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
const [settings] = useSettings(null);
const { settings } = useSettings();
const isTv = Platform.isTV;
const isDisabled =
isTv ||

View File

@@ -14,7 +14,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { t } from "i18next";
import { useCallback, useMemo } from "react";
import { toast } from "sonner-native";
import { defaultValues, Settings } from "@/utils/atoms/settings";
import { useSettings } from "@/utils/atoms/settings";
import type { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes";
import {
IssueStatus,
@@ -40,6 +40,7 @@ import type { UserResultsResponse } from "@/utils/jellyseerr/server/interfaces/a
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type {
CombinedCredit,
PersonCreditCast,
PersonDetails,
} from "@/utils/jellyseerr/server/models/Person";
import type {
@@ -416,10 +417,8 @@ export class JellyseerrApi {
const jellyseerrUserAtom = atom(storage.get<JellyseerrUser>(JELLYSEERR_USER));
export const useJellyseerr = (
settings: Settings = defaultValues,
updateSettings: (update: Partial<Settings>) => void = () => {},
) => {
export const useJellyseerr = () => {
const { settings, updateSettings } = useSettings();
const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom);
const queryClient = useQueryClient();
@@ -468,49 +467,47 @@ export const useJellyseerr = (
[jellyseerrApi],
);
const isJellyseerrResult = (
const isJellyseerrMovieOrTvResult = (
items: any | null | undefined,
): items is Results => {
): items is MovieResult | TvResult => {
return (
items &&
Object.hasOwn(items, "mediaType") &&
Object.values(MediaType).includes(items.mediaType as MediaType)
(items.mediaType === MediaType.MOVIE || items.mediaType === MediaType.TV)
);
};
const getTitle = (
item?: TvResult | TvDetails | MovieResult | MovieDetails,
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
) => {
return isJellyseerrResult(item)
return isJellyseerrMovieOrTvResult(item)
? item.mediaType === MediaType.MOVIE
? item?.title
: item?.name
: item?.mediaInfo.mediaType === MediaType.MOVIE
: item?.mediaInfo?.mediaType === MediaType.MOVIE
? (item as MovieDetails)?.title
: (item as TvDetails)?.name;
};
const getYear = (
item?: TvResult | TvDetails | MovieResult | MovieDetails,
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
) => {
return new Date(
(isJellyseerrResult(item)
(isJellyseerrMovieOrTvResult(item)
? item.mediaType === MediaType.MOVIE
? item?.releaseDate
: item?.firstAirDate
: item?.mediaInfo.mediaType === MediaType.MOVIE
: item?.mediaInfo?.mediaType === MediaType.MOVIE
? (item as MovieDetails)?.releaseDate
: (item as TvDetails)?.firstAirDate) || "",
)?.getFullYear?.();
};
const getMediaType = (
item?: TvResult | TvDetails | MovieResult | MovieDetails,
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
): MediaType => {
return isJellyseerrResult(item)
? item.mediaType === "movie"
? MediaType.MOVIE
: MediaType.TV
return isJellyseerrMovieOrTvResult(item)
? (item.mediaType as MediaType)
: item?.mediaInfo?.mediaType;
};
@@ -528,7 +525,7 @@ export const useJellyseerr = (
jellyseerrUser,
setJellyseerrUser,
clearAllJellyseerData,
isJellyseerrResult,
isJellyseerrMovieOrTvResult,
getTitle,
getYear,
getMediaType,

30
hooks/useNetworkStatus.ts Normal file
View File

@@ -0,0 +1,30 @@
import NetInfo from "@react-native-community/netinfo";
import { useCallback, useEffect, useState } from "react";
export function useNetworkStatus() {
const [isConnected, setIsConnected] = useState(false);
const [loading, setLoading] = useState(false);
// Manual check (optional)
const retryCheck = useCallback(async () => {
setLoading(true);
const state = await NetInfo.fetch();
setIsConnected(!!state.isConnected && !!state.isInternetReachable);
setLoading(false);
}, []);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
setIsConnected(!!state.isConnected && !!state.isInternetReachable);
});
// Initial state
NetInfo.fetch().then((state) => {
setIsConnected(!!state.isConnected && !!state.isInternetReachable);
});
return () => unsubscribe();
}, []);
return { isConnected, loading, retryCheck };
}

View File

@@ -1,12 +1,12 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getPlaystateApi, getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useNetInfo } from "@react-native-community/netinfo";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { useMemo } from "react";
import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useNetworkStatus } from "./useNetworkStatus";
interface PlaybackManagerProps {
item?: BaseItemDto | null;
@@ -65,12 +65,12 @@ export const usePlaybackManager = ({
}: PlaybackManagerProps = {}) => {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const netInfo = useNetInfo();
const { isConnected } = useNetworkStatus();
const { getDownloadedItemById, updateDownloadedItem, getDownloadedItems } =
useDownload();
/** Whether the device is online. actually it's connected to the internet. */
const isOnline = useMemo(() => netInfo.isConnected, [netInfo.isConnected]);
const isOnline = isConnected;
// Adjacent episodes logic
const { data: adjacentItems } = useQuery({

View File

@@ -1,8 +1,8 @@
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useNetInfo } from "@react-native-community/netinfo";
import { useAtomValue } from "jotai";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "../providers/JellyfinProvider";
import { useNetworkStatus } from "./useNetworkStatus";
/**
* This hook is used to sync the playback state of a downloaded item with the server
@@ -11,8 +11,8 @@ import { apiAtom, userAtom } from "../providers/JellyfinProvider";
export const useTwoWaySync = () => {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const netInfo = useNetInfo();
const { getDownloadedItemById, updateDownloadedItem } = useDownload();
const { isConnected } = useNetworkStatus();
/**
* Syncs the playback state of an offline item with the server.
@@ -21,7 +21,7 @@ export const useTwoWaySync = () => {
* @returns A Promise<boolean> indicating whether a server update was made (true) or not (false).
*/
const syncPlaybackState = async (itemId: string): Promise<boolean> => {
if (!api || !user || !netInfo.isConnected) {
if (!api || !user || !isConnected) {
// Cannot sync if offline or not logged in
return false;
}

View File

@@ -4,6 +4,7 @@ import type {
} from "@jellyfin/sdk/lib/generated-client/models";
import * as Application from "expo-application";
import * as FileSystem from "expo-file-system";
import * as Notifications from "expo-notifications";
import { router } from "expo-router";
import { atom, useAtom } from "jotai";
import { throttle } from "lodash";
@@ -87,9 +88,72 @@ function useDownloadProvider() {
const { saveSeriesPrimaryImage } = useDownloadHelper();
const { saveImage } = useImageStorage();
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
const [settings] = useSettings(null);
const { settings } = useSettings();
const successHapticFeedback = useHaptic("success");
// Generate notification content based on item type
const getNotificationContent = useCallback(
(item: BaseItemDto, isSuccess: boolean) => {
if (item.Type === "Episode") {
const season = item.ParentIndexNumber
? String(item.ParentIndexNumber).padStart(2, "0")
: "??";
const episode = item.IndexNumber
? String(item.IndexNumber).padStart(2, "0")
: "??";
const subtitle = `${item.Name} - [S${season}E${episode}] (${item.SeriesName})`;
return {
title: isSuccess ? "Download complete" : "Download failed",
body: subtitle,
};
} else if (item.Type === "Movie") {
const year = item.ProductionYear ? ` (${item.ProductionYear})` : "";
const subtitle = `${item.Name}${year}`;
return {
title: isSuccess ? "Download complete" : "Download failed",
body: subtitle,
};
} else {
// Fallback for other types
return {
title: isSuccess
? t("home.downloads.toasts.download_completed_for_item", {
item: item.Name,
})
: t("home.downloads.toasts.download_failed_for_item", {
item: item.Name,
}),
body: item.Name || "Unknown item",
};
}
},
[t],
);
// Send local notification for download events
const sendDownloadNotification = useCallback(
async (title: string, body: string, data?: Record<string, any>) => {
if (Platform.isTV) return;
try {
await Notifications.scheduleNotificationAsync({
content: {
title,
body,
data,
...(Platform.OS === "android" && { channelId: "downloads" }),
},
trigger: null, // Show immediately
});
} catch (error) {
console.error("Failed to send notification:", error);
}
},
[],
);
/// Cant use the background downloader callback. As its not triggered if size is unknown.
const updateProgress = async () => {
const tasks = await BackGroundDownloader.checkForExistingDownloads();
@@ -418,6 +482,21 @@ function useDownloadProvider() {
}
await saveDownloadsDatabase(db);
// Send native notification for successful download
const successNotification = getNotificationContent(
process.item,
true,
);
await sendDownloadNotification(
successNotification.title,
successNotification.body,
{
itemId: process.item.Id,
itemName: process.item.Name,
type: "download_completed",
},
);
toast.success(
t("home.downloads.toasts.download_completed_for_item", {
item: process.item.Name,
@@ -425,8 +504,25 @@ function useDownloadProvider() {
);
removeProcess(process.id);
})
.error((error: any) => {
.error(async (error: any) => {
console.error("Download error:", error);
// Send native notification for failed download
const failureNotification = getNotificationContent(
process.item,
false,
);
await sendDownloadNotification(
failureNotification.title,
failureNotification.body,
{
itemId: process.item.Id,
itemName: process.item.Name,
type: "download_failed",
error: error?.message || "Unknown error",
},
);
toast.error(
t("home.downloads.toasts.download_failed_for_item", {
item: process.item.Name,
@@ -435,7 +531,7 @@ function useDownloadProvider() {
removeProcess(process.id);
});
},
[authHeader],
[authHeader, sendDownloadNotification, getNotificationContent],
);
const manageDownloadQueue = useCallback(() => {

View File

@@ -30,6 +30,10 @@ import { store } from "@/utils/store";
interface Server {
address: string;
serverName?: string;
serverId?: string;
lastUsername?: string;
savedToken?: string;
}
export const apiAtom = atom<Api | null>(null);
@@ -40,7 +44,9 @@ interface JellyfinContextValue {
discoverServers: (url: string) => Promise<Server[]>;
setServer: (server: Server) => Promise<void>;
removeServer: () => void;
login: (username: string, password: string) => Promise<void>;
switchServer: (server: Server) => Promise<void>;
addNewServer: (server: Server) => Promise<void>;
login: (username: string, password: string, saveCredentials?: boolean) => Promise<void>;
logout: () => Promise<void>;
initiateQuickConnect: () => Promise<string | undefined>;
}
@@ -64,7 +70,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.35.0" },
clientInfo: { name: "Streamyfin", version: "0.36.0" },
deviceInfo: {
name: deviceName,
id,
@@ -79,24 +85,15 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const [user, setUser] = useAtom(userAtom);
const [isPolling, setIsPolling] = useState<boolean>(false);
const [secret, setSecret] = useState<string | null>(null);
const [
_settings,
_updateSettings,
_pluginSettings,
setPluginSettings,
refreshStreamyfinPluginSettings,
] = useSettings(api);
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr(
_settings || {},
_updateSettings,
);
const { setPluginSettings, refreshStreamyfinPluginSettings } = useSettings();
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
const headers = useMemo(() => {
if (!deviceId) return {};
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.35.0"`,
}, DeviceId="${deviceId}", Version="0.36.0"`,
};
}, [deviceId]);
@@ -189,6 +186,21 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
if (!apiInstance?.basePath) throw new Error("Failed to connect");
// Get server info to obtain serverId and serverName
try {
const response = await fetch(
`${server.address}/System/Info/Public`,
{ mode: "cors" }
);
if (response.ok) {
const data = await response.json();
server.serverId = data.Id;
server.serverName = data.ServerName;
}
} catch (error) {
console.warn("Could not get server info:", error);
}
setApi(apiInstance);
storage.set("serverUrl", server.address);
},
@@ -224,9 +236,11 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
mutationFn: async ({
username,
password,
saveCredentials = true,
}: {
username: string;
password: string;
saveCredentials?: boolean;
}) => {
if (!api || !jellyfin) throw new Error("API not initialized");
@@ -239,6 +253,26 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
storage.set("token", auth.data?.AccessToken);
// Save token to the current server if requested
if (saveCredentials && api.basePath) {
const previousServers = JSON.parse(
storage.getString("previousServers") || "[]",
) as Server[];
const updatedServers = previousServers.map((server) => {
if (server.address === api.basePath) {
return {
...server,
lastUsername: username,
savedToken: auth.data.AccessToken
};
}
return server;
});
storage.set("previousServers", JSON.stringify(updatedServers));
}
const recentPluginSettings = await refreshStreamyfinPluginSettings();
if (recentPluginSettings?.jellyseerrServerUrl?.value) {
const jellyseerrApi = new JellyseerrApi(
@@ -306,6 +340,112 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
},
});
const switchServerMutation = useMutation({
mutationFn: async (server: Server) => {
// Get current server info for comparison
const currentServerId = await getCurrentServerId();
// If switching to same server (different URL), try auto-login with saved token
if (server.serverId && server.serverId === currentServerId && server.savedToken) {
try {
// Create API instance with saved token
const apiInstance = jellyfin?.createApi(server.address, server.savedToken);
if (!apiInstance) throw new Error("Failed to create API instance");
// Validate the token by making an authenticated request
const userApi = getUserApi(apiInstance);
const userResponse = await userApi.getCurrentUser();
if (userResponse.data) {
// Token is valid, update the API and user
setApi(apiInstance);
setUser(userResponse.data);
storage.set("serverUrl", server.address);
storage.set("token", server.savedToken);
storage.set("user", JSON.stringify(userResponse.data));
return;
}
} catch (error) {
console.warn("Saved token is invalid, falling back to manual login:", error);
// Remove invalid token from server
const previousServers = JSON.parse(
storage.getString("previousServers") || "[]",
) as Server[];
const updatedServers = previousServers.map((s) => {
if (s.address === server.address) {
const { savedToken, ...serverWithoutToken } = s;
return serverWithoutToken;
}
return s;
});
storage.set("previousServers", JSON.stringify(updatedServers));
}
}
// For different servers or if auto-login fails, do the normal logout → set server flow
await logoutMutation.mutateAsync();
await setServerMutation.mutateAsync(server);
},
onError: (error) => {
console.error("Failed to switch server:", error);
},
});
const addNewServerMutation = useMutation({
mutationFn: async (server: Server) => {
// Add a new server to the list without switching to it
const previousServers = JSON.parse(
storage.getString("previousServers") || "[]",
) as Server[];
// Get server info first
try {
const response = await fetch(
`${server.address}/System/Info/Public`,
{ mode: "cors" }
);
if (response.ok) {
const data = await response.json();
server.serverId = data.Id;
server.serverName = data.ServerName;
}
} catch (error) {
console.warn("Could not get server info:", error);
}
const updatedServers = [
server,
...previousServers.filter((s: Server) => s.address !== server.address),
];
storage.set(
"previousServers",
JSON.stringify(updatedServers.slice(0, 5)),
);
},
onError: (error) => {
console.error("Failed to add new server:", error);
},
});
const getCurrentServerId = async (): Promise<string | null> => {
if (!api?.basePath) return null;
try {
const response = await fetch(
`${api.basePath}/System/Info/Public`,
{ mode: "cors" }
);
if (response.ok) {
const data = await response.json();
return data.Id;
}
} catch (error) {
console.warn("Could not get current server ID:", error);
}
return null;
};
const [loaded, setLoaded] = useState(false);
const [initialLoaded, setInitialLoaded] = useState(false);
@@ -320,6 +460,23 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
if (!jellyfin) return;
try {
// Migrate any existing savedCredentials to remove them
const previousServers = JSON.parse(
storage.getString("previousServers") || "[]",
) as any[];
if (previousServers.length > 0) {
const migratedServers = previousServers.map((server) => {
if (server.savedCredentials) {
// Remove savedCredentials field for security
const { savedCredentials, ...serverWithoutCredentials } = server;
return serverWithoutCredentials;
}
return server;
});
storage.set("previousServers", JSON.stringify(migratedServers));
}
const token = getTokenFromStorage();
const serverUrl = getServerUrlFromStorage();
const storedUser = getUserFromStorage();
@@ -349,8 +506,10 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
discoverServers,
setServer: (server) => setServerMutation.mutateAsync(server),
removeServer: () => removeServerMutation.mutateAsync(),
login: (username, password) =>
loginMutation.mutateAsync({ username, password }),
switchServer: (server) => switchServerMutation.mutateAsync(server),
addNewServer: (server) => addNewServerMutation.mutateAsync(server),
login: (username, password, saveCredentials = true) =>
loginMutation.mutateAsync({ username, password, saveCredentials }),
logout: () => logoutMutation.mutateAsync(),
initiateQuickConnect,
};

View File

@@ -32,7 +32,12 @@
"clear_button": "Clear",
"search_for_local_servers": "Search for local servers",
"searching": "Searching...",
"servers": "Servers"
"servers": "Servers",
"quick_switch": "Quick Switch Servers",
"switch_server": "Switch Server",
"no_previous_servers": "No previous servers available",
"add_new_server": "Add New Server",
"auto_login_available": "Auto-login available"
},
"home": {
"no_internet": "No Internet",

View File

@@ -3,11 +3,13 @@ import { useJellyseerr } from "@/hooks/useJellyseerr";
import {
MediaRequestStatus,
MediaStatus,
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import {
hasPermission,
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import type {
MovieResult,
TvResult,
@@ -17,7 +19,7 @@ import type { MovieDetails } from "../jellyseerr/server/models/Movie";
import type { TvDetails } from "../jellyseerr/server/models/Tv";
export const useJellyseerrCanRequest = (
item?: MovieResult | TvResult | MovieDetails | TvDetails,
item?: MovieResult | TvResult | MovieDetails | TvDetails | PersonCreditCast,
) => {
const { jellyseerrUser } = useJellyseerr();
@@ -40,7 +42,7 @@ export const useJellyseerrCanRequest = (
const userHasPermission = hasPermission(
[
Permission.REQUEST,
item?.mediaInfo?.mediaType
item?.mediaInfo?.mediaType === MediaType.MOVIE
? Permission.REQUEST_MOVIE
: Permission.REQUEST_TV,
],

View File

@@ -56,7 +56,7 @@ export const useJobProcessor = () => {
const [queue, setQueue] = useAtom(queueAtom);
const [running, setRunning] = useAtom(runningAtom);
const [processes] = useAtom<JobStatus[]>(processesAtom);
const [settings] = useSettings(null);
const { settings } = useSettings();
useEffect(() => {
if (

View File

@@ -1,4 +1,3 @@
import type { Api } from "@jellyfin/sdk";
import {
type BaseItemKind,
type CultureDto,
@@ -7,11 +6,12 @@ import {
type SortOrder,
SubtitlePlaybackMode,
} from "@jellyfin/sdk/lib/generated-client";
import { atom, useAtom } from "jotai";
import { atom, useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo } from "react";
import { Platform } from "react-native";
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider";
import { writeInfoLog } from "@/utils/log";
import { storage } from "../mmkv";
@@ -278,7 +278,8 @@ export const pluginSettingsAtom = atom<PluginLockableSettings | undefined>(
loadPluginSettings(),
);
export const useSettings = (api: Api | null) => {
export const useSettings = () => {
const api = useAtomValue(apiAtom);
const [_settings, setSettings] = useAtom(settingsAtom);
const [pluginSettings, _setPluginSettings] = useAtom(pluginSettingsAtom);
@@ -302,11 +303,11 @@ export const useSettings = (api: Api | null) => {
return;
}
const settings = await api.getStreamyfinPluginConfig().then(
({ data }: any) => {
({ data }) => {
writeInfoLog("Got plugin settings", data?.settings);
return data?.settings;
},
(_err: any) => undefined,
(_err) => undefined,
);
setPluginSettings(settings);
return settings;
@@ -367,11 +368,11 @@ export const useSettings = (api: Api | null) => {
};
}, [_settings, pluginSettings]);
return [
return {
settings,
updateSettings,
pluginSettings,
setPluginSettings,
refreshStreamyfinPluginSettings,
] as const;
};
};