mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
Compare commits
138 Commits
v0.51.0
...
remove-opt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a17e36882 | ||
|
|
391ca897a5 | ||
|
|
c8fefdf4a1 | ||
|
|
ae658aa5b0 | ||
|
|
956eea8848 | ||
|
|
895c245254 | ||
|
|
376d2e84da | ||
|
|
81f79a54af | ||
|
|
ca1b640a61 | ||
|
|
e771949c95 | ||
|
|
12047cbe12 | ||
|
|
32f4bbcc7d | ||
|
|
78bfa68a17 | ||
|
|
ac59615d79 | ||
|
|
4dd80cd8f5 | ||
|
|
db9f02b225 | ||
|
|
7a0bbb1084 | ||
|
|
05925530c0 | ||
|
|
625a292e26 | ||
|
|
1acd3102ea | ||
|
|
543881dc41 | ||
|
|
5d93483dc2 | ||
|
|
d54a29020a | ||
|
|
1d04e39b85 | ||
|
|
ecc62259fc | ||
|
|
ffd96e05fe | ||
|
|
8541ba02d4 | ||
|
|
6c955d8a2a | ||
|
|
b0bb6c6c9a | ||
|
|
82abc291d4 | ||
|
|
3da4b42ca3 | ||
|
|
16940075b2 | ||
|
|
a3bbb1bc3a | ||
|
|
138a86f473 | ||
|
|
6dd111defe | ||
|
|
4ba03b669e | ||
|
|
aacf5327d1 | ||
|
|
1874c116a6 | ||
|
|
f543fa9e3e | ||
|
|
4385fe5502 | ||
|
|
3d72c9c783 | ||
|
|
e41d1b4818 | ||
|
|
fd3766fc23 | ||
|
|
f211a9ce7a | ||
|
|
b7db06f53d | ||
|
|
ceac74dbfa | ||
|
|
b6bd427e19 | ||
|
|
47bf1c9201 | ||
|
|
4aaddd2104 | ||
|
|
4a75e8f551 | ||
|
|
7a0f70778d | ||
|
|
6957c4fd64 | ||
|
|
0c8c27bfc0 | ||
|
|
67e61f3ab8 | ||
|
|
c66541ce4d | ||
|
|
1c0ed82deb | ||
|
|
ad54823f96 | ||
|
|
cfa638afc6 | ||
|
|
467bea7192 | ||
|
|
ac9ac5d423 | ||
|
|
62b45121e5 | ||
|
|
0e238ad10e | ||
|
|
ce793e3469 | ||
|
|
beba4853b9 | ||
|
|
d1b15a9dde | ||
|
|
12847894df | ||
|
|
773eae8cb9 | ||
|
|
781c309332 | ||
|
|
8a7f5fd4c5 | ||
|
|
838c8f48a3 | ||
|
|
399eb0a351 | ||
|
|
f1575ca48b | ||
|
|
df2f44e086 | ||
|
|
9d566d697d | ||
|
|
35f7257a6d | ||
|
|
d4ab62bdd6 | ||
|
|
ca7591ad21 | ||
|
|
7514bc2c2c | ||
|
|
241f8c949a | ||
|
|
f369738f7b | ||
|
|
ac6288d9a4 | ||
|
|
91128944c6 | ||
|
|
81449963fa | ||
|
|
8569cd390b | ||
|
|
3959aa2f72 | ||
|
|
097001b092 | ||
|
|
d1387ec725 | ||
|
|
51ecde1565 | ||
|
|
0a0da687d5 | ||
|
|
0a3cc87b2a | ||
|
|
77596bd788 | ||
|
|
630e7a1a36 | ||
|
|
04bc412bce | ||
|
|
99aa6bd342 | ||
|
|
a3751c4801 | ||
|
|
e8ddd1cad7 | ||
|
|
be8651357b | ||
|
|
588c8ffeb5 | ||
|
|
3e01310466 | ||
|
|
f3417b5a75 | ||
|
|
f97d7d78c1 | ||
|
|
eaa519fdda | ||
|
|
d84ff82590 | ||
|
|
ea271b8cdd | ||
|
|
b6270f1157 | ||
|
|
fe6299c22b | ||
|
|
88722b3e17 | ||
|
|
1a4159ffee | ||
|
|
bd13791a83 | ||
|
|
968c3e35d5 | ||
|
|
b822e315a8 | ||
|
|
f862a2424e | ||
|
|
77ee4aca27 | ||
|
|
d4ea8debfa | ||
|
|
e1dd410f73 | ||
|
|
896c7460df | ||
|
|
3453fd22b8 | ||
|
|
1c3369c61f | ||
|
|
99c9caf03c | ||
|
|
055357de60 | ||
|
|
a24e254a9e | ||
|
|
4bdb8003bb | ||
|
|
9825ccce6e | ||
|
|
9bfac2a4d9 | ||
|
|
478052599f | ||
|
|
37b0b10098 | ||
|
|
9ca852bb7e | ||
|
|
a37a8753c2 | ||
|
|
5eae6e6cd0 | ||
|
|
e0c408452d | ||
|
|
4c8dfa0e2f | ||
|
|
6c95962e12 | ||
|
|
cbbac3c25c | ||
|
|
964d53cc79 | ||
|
|
a26980ddab | ||
|
|
090ed98233 | ||
|
|
07a0a48613 | ||
|
|
b630e0784b |
37
.claude/commands/reflect.md
Normal file
37
.claude/commands/reflect.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
description: Reflect on this session to extract and store learned facts about the codebase
|
||||||
|
---
|
||||||
|
|
||||||
|
Analyze the current conversation to extract useful facts that should be remembered for future sessions. Focus on:
|
||||||
|
|
||||||
|
1. **Corrections**: Things the user corrected you about
|
||||||
|
2. **Clarifications**: Misunderstandings about how the codebase works
|
||||||
|
3. **Patterns**: Important conventions or patterns you learned
|
||||||
|
4. **Gotchas**: Surprising behaviors or edge cases discovered
|
||||||
|
5. **Locations**: Files or code that was hard to find
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
1. Read the existing facts file at `.claude/learned-facts.md`
|
||||||
|
2. Review this conversation for learnings worth preserving
|
||||||
|
3. For each new fact:
|
||||||
|
- Write it concisely (1-2 sentences max)
|
||||||
|
- Include context for why it matters
|
||||||
|
- Add today's date
|
||||||
|
4. Skip facts that duplicate existing entries
|
||||||
|
5. Append new facts to `.claude/learned-facts.md`
|
||||||
|
|
||||||
|
## Fact Format
|
||||||
|
|
||||||
|
Use this format for each fact:
|
||||||
|
```
|
||||||
|
- **[Brief Topic]**: [Concise description of the fact] _(YYYY-MM-DD)_
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Facts
|
||||||
|
|
||||||
|
- **State management**: Use Jotai atoms for global state, NOT React Context - atoms are in `utils/atoms/` _(2025-01-09)_
|
||||||
|
- **Package manager**: Always use `bun`, never npm or yarn - the project is configured for bun only _(2025-01-09)_
|
||||||
|
- **TV platform**: Check `Platform.isTV` for TV-specific code paths, not just OS checks _(2025-01-09)_
|
||||||
|
|
||||||
|
After updating the file, summarize what facts you added (or note if nothing new was learned this session).
|
||||||
27
.claude/learned-facts.md
Normal file
27
.claude/learned-facts.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Learned Facts
|
||||||
|
|
||||||
|
This file contains facts about the codebase learned from past sessions. These are things Claude got wrong or needed clarification on, stored here to prevent the same mistakes in future sessions.
|
||||||
|
|
||||||
|
This file is auto-imported into CLAUDE.md and loaded at the start of each session.
|
||||||
|
|
||||||
|
## Facts
|
||||||
|
|
||||||
|
<!-- New facts will be appended below this line -->
|
||||||
|
|
||||||
|
- **Native bottom tabs + useRouter conflict**: When using `@bottom-tabs/react-navigation` with Expo Router, avoid using the `useRouter()` hook in components rendered at the provider level (outside the tab navigator). The hook subscribes to navigation state changes and can cause unexpected tab switches. Use the static `router` import from `expo-router` instead. _(2025-01-09)_
|
||||||
|
|
||||||
|
- **IntroSheet rendering location**: The `IntroSheet` component is rendered inside `IntroSheetProvider` which wraps the entire navigation stack. Any hooks in IntroSheet that interact with navigation state can affect the native bottom tabs. _(2025-01-09)_
|
||||||
|
|
||||||
|
- **Intro modal trigger location**: The intro modal trigger logic should be in the `Home.tsx` component, not in the tabs `_layout.tsx`. Triggering modals from tab layout can interfere with native bottom tabs navigation. _(2025-01-09)_
|
||||||
|
|
||||||
|
- **Tab folder naming**: The tab folders use underscore prefix naming like `(_home)` instead of just `(home)` based on the project's file structure conventions. _(2025-01-09)_
|
||||||
|
|
||||||
|
- **macOS header buttons fix**: Header buttons (`headerRight`/`headerLeft`) don't respond to touches on macOS Catalyst builds when using standard React Native `TouchableOpacity`. Fix by using `Pressable` from `react-native-gesture-handler` instead. The library is already installed and `GestureHandlerRootView` wraps the app. _(2026-01-10)_
|
||||||
|
|
||||||
|
- **Header button locations**: Header buttons are defined in multiple places: `app/(auth)/(tabs)/(home)/_layout.tsx` (SettingsButton, SessionsButton, back buttons), `components/common/HeaderBackButton.tsx` (reusable), `components/Chromecast.tsx`, `components/RoundButton.tsx`, and dynamically via `navigation.setOptions()` in `components/home/Home.tsx` and `app/(auth)/(tabs)/(home)/downloads/index.tsx`. _(2026-01-10)_
|
||||||
|
|
||||||
|
- **useNetworkAwareQueryClient limitations**: The `useNetworkAwareQueryClient` hook uses `Object.create(queryClient)` which breaks QueryClient methods that use JavaScript private fields (like `getQueriesData`, `setQueriesData`, `setQueryData`). Only use it when you ONLY need `invalidateQueries`. For cache manipulation, use standard `useQueryClient` from `@tanstack/react-query`. _(2026-01-10)_
|
||||||
|
|
||||||
|
- **Mark as played flow**: The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`. _(2026-01-10)_
|
||||||
|
|
||||||
|
- **Stack screen header configuration**: Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling. _(2026-01-10)_
|
||||||
41
.github/copilot-instructions.md
vendored
41
.github/copilot-instructions.md
vendored
@@ -3,7 +3,7 @@
|
|||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
|
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
|
||||||
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Jellyseerr APIs,
|
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Seerr APIs,
|
||||||
and provides seamless media streaming with offline capabilities and Chromecast support.
|
and provides seamless media streaming with offline capabilities and Chromecast support.
|
||||||
|
|
||||||
## Main Technologies
|
## Main Technologies
|
||||||
@@ -40,9 +40,30 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
|||||||
- `scripts/` – Automation scripts (Node.js, Bash)
|
- `scripts/` – Automation scripts (Node.js, Bash)
|
||||||
- `plugins/` – Expo/Metro plugins
|
- `plugins/` – Expo/Metro plugins
|
||||||
|
|
||||||
## Coding Standards
|
## Code Quality Standards
|
||||||
|
|
||||||
|
**CRITICAL: Code must be production-ready, reliable, and maintainable**
|
||||||
|
|
||||||
|
### Type Safety
|
||||||
- Use TypeScript for ALL files (no .js files)
|
- Use TypeScript for ALL files (no .js files)
|
||||||
|
- **NEVER use `any` type** - use proper types, generics, or `unknown` with type guards
|
||||||
|
- Use `@ts-expect-error` with detailed comments only when necessary (e.g., library limitations)
|
||||||
|
- When facing type issues, create proper type definitions and helper functions instead of using `any`
|
||||||
|
- Use type assertions (`as`) only as a last resort with clear documentation explaining why
|
||||||
|
- For Expo Router navigation: prefer string URLs with `URLSearchParams` over object syntax to avoid type conflicts
|
||||||
|
- Enable and respect strict TypeScript compiler options
|
||||||
|
- Define explicit return types for functions
|
||||||
|
- Use discriminated unions for complex state
|
||||||
|
|
||||||
|
### Code Reliability
|
||||||
|
- Implement comprehensive error handling with try-catch blocks
|
||||||
|
- Validate all external inputs (API responses, user input, query params)
|
||||||
|
- Handle edge cases explicitly (empty arrays, null, undefined)
|
||||||
|
- Use optional chaining (`?.`) and nullish coalescing (`??`) appropriately
|
||||||
|
- Add runtime checks for critical operations
|
||||||
|
- Implement proper loading and error states in components
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
- Use descriptive English names for variables, functions, and components
|
- Use descriptive English names for variables, functions, and components
|
||||||
- Prefer functional React components with hooks
|
- Prefer functional React components with hooks
|
||||||
- Use Jotai atoms for global state management
|
- Use Jotai atoms for global state management
|
||||||
@@ -50,8 +71,10 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
|||||||
- Follow BiomeJS formatting and linting rules
|
- Follow BiomeJS formatting and linting rules
|
||||||
- Use `const` over `let`, avoid `var` entirely
|
- Use `const` over `let`, avoid `var` entirely
|
||||||
- Implement proper error boundaries
|
- Implement proper error boundaries
|
||||||
- Use React.memo() for performance optimization
|
- Use React.memo() for performance optimization when needed
|
||||||
- Handle both mobile and TV navigation patterns
|
- Handle both mobile and TV navigation patterns
|
||||||
|
- Write self-documenting code with clear intent
|
||||||
|
- Add comments only when code complexity requires explanation
|
||||||
|
|
||||||
## API Integration
|
## API Integration
|
||||||
|
|
||||||
@@ -85,6 +108,18 @@ Exemples:
|
|||||||
- `fix(auth): handle expired JWT tokens`
|
- `fix(auth): handle expired JWT tokens`
|
||||||
- `chore(deps): update Jellyfin SDK`
|
- `chore(deps): update Jellyfin SDK`
|
||||||
|
|
||||||
|
## Internationalization (i18n)
|
||||||
|
|
||||||
|
- **Primary workflow**: Always edit `translations/en.json` for new translation keys or updates
|
||||||
|
- **Translation files** (ar.json, ca.json, cs.json, de.json, etc.):
|
||||||
|
- **NEVER add or remove keys** - Crowdin manages the key structure
|
||||||
|
- **Editing translation values is safe** - Bidirectional sync handles merges
|
||||||
|
- Prefer letting Crowdin translators update values, but direct edits work if needed
|
||||||
|
- **Crowdin workflow**:
|
||||||
|
- New keys added to `en.json` sync to Crowdin automatically
|
||||||
|
- Approved translations sync back to language files via GitHub integration
|
||||||
|
- The source of truth is `en.json` for structure, Crowdin for translations
|
||||||
|
|
||||||
## Special Instructions
|
## Special Instructions
|
||||||
|
|
||||||
- Prioritize cross-platform compatibility (mobile + TV)
|
- Prioritize cross-platform compatibility (mobile + TV)
|
||||||
|
|||||||
12
.github/crowdin.yml
vendored
12
.github/crowdin.yml
vendored
@@ -1,12 +0,0 @@
|
|||||||
"project_id_env": "CROWDIN_PROJECT_ID"
|
|
||||||
"api_token_env": "CROWDIN_PERSONAL_TOKEN"
|
|
||||||
"base_path": "."
|
|
||||||
|
|
||||||
"preserve_hierarchy": true
|
|
||||||
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"source": "translations/en.json",
|
|
||||||
"translation": "translations/%two_letters_code%.json"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
121
.github/workflows/build-apps.yml
vendored
121
.github/workflows/build-apps.yml
vendored
@@ -20,8 +20,20 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: 🗑️ Free Disk Space
|
||||||
|
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||||
|
with:
|
||||||
|
tool-cache: false
|
||||||
|
mandb: true
|
||||||
|
android: false
|
||||||
|
dotnet: true
|
||||||
|
haskell: true
|
||||||
|
large-packages: true
|
||||||
|
docker-images: true
|
||||||
|
swap-storage: false
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -29,12 +41,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||||
@@ -48,7 +60,7 @@ jobs:
|
|||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -61,7 +73,7 @@ jobs:
|
|||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
- name: 💾 Cache project Gradle (.gradle)
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
@@ -76,7 +88,7 @@ jobs:
|
|||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: 📤 Upload APK artifact
|
- name: 📤 Upload APK artifact
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
|
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
|
||||||
path: |
|
path: |
|
||||||
@@ -91,8 +103,20 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: 🗑️ Free Disk Space
|
||||||
|
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||||
|
with:
|
||||||
|
tool-cache: false
|
||||||
|
mandb: true
|
||||||
|
android: false
|
||||||
|
dotnet: true
|
||||||
|
haskell: true
|
||||||
|
large-packages: true
|
||||||
|
docker-images: true
|
||||||
|
swap-storage: false
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -100,12 +124,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||||
@@ -119,7 +143,7 @@ jobs:
|
|||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -132,7 +156,7 @@ jobs:
|
|||||||
run: bun run prebuild:tv
|
run: bun run prebuild:tv
|
||||||
|
|
||||||
- name: 💾 Cache project Gradle (.gradle)
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
@@ -147,7 +171,7 @@ jobs:
|
|||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: 📤 Upload APK artifact
|
- name: 📤 Upload APK artifact
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
|
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
|
||||||
path: |
|
path: |
|
||||||
@@ -163,7 +187,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -171,12 +195,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
@@ -194,7 +218,7 @@ jobs:
|
|||||||
- name: 🔧 Setup Xcode
|
- name: 🔧 Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
||||||
with:
|
with:
|
||||||
xcode-version: "26.0.1"
|
xcode-version: "26.2"
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@main
|
uses: expo/expo-github-action@main
|
||||||
@@ -203,9 +227,6 @@ jobs:
|
|||||||
token: ${{ secrets.EXPO_TOKEN }}
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
eas-cache: true
|
eas-cache: true
|
||||||
|
|
||||||
- name: ⚙️ Ensure iOS SDKs installed
|
|
||||||
run: xcodebuild -downloadPlatform iOS
|
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
EXPO_TV: 0
|
EXPO_TV: 0
|
||||||
@@ -215,12 +236,69 @@ jobs:
|
|||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: 📤 Upload IPA artifact
|
- name: 📤 Upload IPA artifact
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
|
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
|
||||||
path: build-*.ipa
|
path: build-*.ipa
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
build-ios-phone-unsigned:
|
||||||
|
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||||
|
runs-on: macos-26
|
||||||
|
name: 🍎 Build iOS IPA (Phone - Unsigned)
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout code
|
||||||
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
show-progress: false
|
||||||
|
|
||||||
|
- name: 🍞 Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: 💾 Cache Bun dependencies
|
||||||
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
|
with:
|
||||||
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-cache
|
||||||
|
|
||||||
|
- name: 📦 Install dependencies and reload submodules
|
||||||
|
run: |
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
bun run submodule-reload
|
||||||
|
|
||||||
|
- name: 🛠️ Generate project files
|
||||||
|
run: bun run prebuild
|
||||||
|
|
||||||
|
- name: 🔧 Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
||||||
|
with:
|
||||||
|
xcode-version: "26.2"
|
||||||
|
|
||||||
|
- name: 🚀 Build iOS app
|
||||||
|
env:
|
||||||
|
EXPO_TV: 0
|
||||||
|
run: bun run ios:unsigned-build ${{ github.event_name == 'pull_request' && '-- --verbose' || '' }}
|
||||||
|
|
||||||
|
- name: 📅 Set date tag
|
||||||
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: 📤 Upload IPA artifact
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
|
with:
|
||||||
|
name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }}
|
||||||
|
path: build/*.ipa
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
# Disabled for now - uncomment when ready to build iOS TV
|
# Disabled for now - uncomment when ready to build iOS TV
|
||||||
# build-ios-tv:
|
# build-ios-tv:
|
||||||
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||||
@@ -271,9 +349,6 @@ jobs:
|
|||||||
# token: ${{ secrets.EXPO_TOKEN }}
|
# token: ${{ secrets.EXPO_TOKEN }}
|
||||||
# eas-cache: true
|
# eas-cache: true
|
||||||
#
|
#
|
||||||
# - name: ⚙️ Ensure tvOS SDKs installed
|
|
||||||
# run: xcodebuild -downloadPlatform tvOS
|
|
||||||
#
|
|
||||||
# - name: 🚀 Build iOS app
|
# - name: 🚀 Build iOS app
|
||||||
# env:
|
# env:
|
||||||
# EXPO_TV: 1
|
# EXPO_TV: 1
|
||||||
|
|||||||
6
.github/workflows/check-lockfile.yml
vendored
6
.github/workflows/check-lockfile.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
show-progress: false
|
show-progress: false
|
||||||
@@ -27,12 +27,12 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.bun/install/cache
|
~/.bun/install/cache
|
||||||
|
|||||||
8
.github/workflows/ci-codeql.yml
vendored
8
.github/workflows/ci-codeql.yml
vendored
@@ -24,16 +24,16 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended,security-and-quality
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
- name: 🛠️ Autobuild
|
- name: 🛠️ Autobuild
|
||||||
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||||
|
|||||||
9
.github/workflows/crowdin.yml
vendored
9
.github/workflows/crowdin.yml
vendored
@@ -23,12 +23,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout Repository
|
- name: 📥 Checkout Repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🌐 Sync Translations with Crowdin
|
- name: 🌐 Sync Translations with Crowdin
|
||||||
uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0
|
uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0
|
||||||
with:
|
with:
|
||||||
upload_sources: true
|
upload_sources: true
|
||||||
upload_translations: true
|
upload_translations: true
|
||||||
@@ -40,11 +40,12 @@ jobs:
|
|||||||
pull_request_base_branch_name: "develop"
|
pull_request_base_branch_name: "develop"
|
||||||
pull_request_labels: "🌐 translation"
|
pull_request_labels: "🌐 translation"
|
||||||
# Quality control options
|
# Quality control options
|
||||||
skip_untranslated_strings: true
|
skip_untranslated_strings: false
|
||||||
|
skip_untranslated_files: false
|
||||||
export_only_approved: false
|
export_only_approved: false
|
||||||
# Commit customization
|
# Commit customization
|
||||||
commit_message: "feat(i18n): update translations from Crowdin"
|
commit_message: "feat(i18n): update translations from Crowdin"
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||||
|
|||||||
10
.github/workflows/linting.yml
vendored
10
.github/workflows/linting.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -69,14 +69,14 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: 🛒 Checkout repository
|
- name: 🛒 Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "📥 Checkout PR code"
|
- name: "📥 Checkout PR code"
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
@@ -112,7 +112,7 @@ jobs:
|
|||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
|
|
||||||
- name: "🍞 Setup Bun"
|
- name: "🍞 Setup Bun"
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
|
|||||||
1
.github/workflows/notification.yml
vendored
1
.github/workflows/notification.yml
vendored
@@ -1,4 +1,5 @@
|
|||||||
name: 🛎️ Discord Notification
|
name: 🛎️ Discord Notification
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|||||||
49
.github/workflows/stale.yml
vendored
49
.github/workflows/stale.yml
vendored
@@ -1,49 +0,0 @@
|
|||||||
name: 🕒 Handle Stale Issues
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
# Runs daily at 1:30 AM UTC (3:30 AM CEST - France time)
|
|
||||||
- cron: "30 1 * * *"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
stale-issues:
|
|
||||||
name: 🗑️ Cleanup Stale Issues
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: 🔄 Mark/Close Stale Issues
|
|
||||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
|
||||||
with:
|
|
||||||
# Global settings
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
operations-per-run: 500 # Increase if you have >1000 issues
|
|
||||||
enable-statistics: true
|
|
||||||
|
|
||||||
# Issue configuration
|
|
||||||
days-before-issue-stale: 90
|
|
||||||
days-before-issue-close: 7
|
|
||||||
stale-issue-label: "🕰️ stale"
|
|
||||||
exempt-issue-labels: "Roadmap v1,help needed,enhancement"
|
|
||||||
|
|
||||||
# Notifications messages
|
|
||||||
stale-issue-message: |
|
|
||||||
⏳ This issue has been automatically marked as **stale** because it has had no activity for 90 days.
|
|
||||||
|
|
||||||
**Next steps:**
|
|
||||||
- If this is still relevant, add a comment to keep it open
|
|
||||||
- Otherwise, it will be closed in 7 days
|
|
||||||
|
|
||||||
Thank you for your contributions! 🙌
|
|
||||||
|
|
||||||
close-issue-message: |
|
|
||||||
🚮 This issue has been automatically closed due to inactivity (7 days since being marked stale).
|
|
||||||
|
|
||||||
**Need to reopen?**
|
|
||||||
Click "Reopen" and add a comment explaining why this should stay open.
|
|
||||||
|
|
||||||
# Disable PR handling
|
|
||||||
days-before-pr-stale: -1
|
|
||||||
days-before-pr-close: -1
|
|
||||||
4
.github/workflows/update-issue-form.yml
vendored
4
.github/workflows/update-issue-form.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
dry_run: no-push
|
dry_run: no-push
|
||||||
|
|
||||||
- name: 📬 Commit and create pull request
|
- name: 📬 Commit and create pull request
|
||||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8
|
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||||
with:
|
with:
|
||||||
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
||||||
branch: ci-update-bug-report
|
branch: ci-update-bug-report
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -19,7 +19,7 @@ web-build/
|
|||||||
/androidtv
|
/androidtv
|
||||||
|
|
||||||
# Module-specific Builds
|
# Module-specific Builds
|
||||||
modules/vlc-player/android/build
|
modules/mpv-player/android/build
|
||||||
modules/player/android
|
modules/player/android
|
||||||
modules/hls-downloader/android/build
|
modules/hls-downloader/android/build
|
||||||
|
|
||||||
@@ -66,7 +66,10 @@ streamyfin-4fec1-firebase-adminsdk.json
|
|||||||
|
|
||||||
# Version and Backup Files
|
# Version and Backup Files
|
||||||
/version-backup-*
|
/version-backup-*
|
||||||
modules/background-downloader/android/build/*
|
|
||||||
/modules/sf-player/android/build
|
/modules/sf-player/android/build
|
||||||
/modules/music-controls/android/build
|
/modules/music-controls/android/build
|
||||||
|
modules/background-downloader/android/build/*
|
||||||
/modules/mpv-player/android/build
|
/modules/mpv-player/android/build
|
||||||
|
|
||||||
|
# ios:unsigned-build Artifacts
|
||||||
|
build/
|
||||||
19
CLAUDE.md
19
CLAUDE.md
@@ -1,10 +1,12 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
|
@.claude/learned-facts.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration.
|
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Seerr integration.
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
@@ -75,6 +77,21 @@ bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build error
|
|||||||
- File-based routing in `app/` directory
|
- File-based routing in `app/` directory
|
||||||
- Tab navigation: `(home)`, `(search)`, `(favorites)`, `(libraries)`, `(watchlists)`
|
- Tab navigation: `(home)`, `(search)`, `(favorites)`, `(libraries)`, `(watchlists)`
|
||||||
- Shared routes use parenthesized groups like `(home,libraries,search,favorites,watchlists)`
|
- Shared routes use parenthesized groups like `(home,libraries,search,favorites,watchlists)`
|
||||||
|
- **IMPORTANT**: Always use `useAppRouter` from `@/hooks/useAppRouter` instead of `useRouter` from `expo-router`. This custom hook automatically handles offline mode state preservation across navigation:
|
||||||
|
```typescript
|
||||||
|
// ✅ Correct
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// ❌ Never use this
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Offline Mode**:
|
||||||
|
- Use `OfflineModeProvider` from `@/providers/OfflineModeProvider` to wrap pages that support offline content
|
||||||
|
- Use `useOfflineMode()` hook to check if current context is offline
|
||||||
|
- The `useAppRouter` hook automatically injects `offline=true` param when navigating within an offline context
|
||||||
|
|
||||||
**Providers** (wrapping order in `app/_layout.tsx`):
|
**Providers** (wrapping order in `app/_layout.tsx`):
|
||||||
1. JotaiProvider
|
1. JotaiProvider
|
||||||
|
|||||||
108
README.md
108
README.md
@@ -5,6 +5,12 @@
|
|||||||
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
|
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://discord.gg/aJvAYeycyY">
|
||||||
|
<img alt="Streamyfin Discord" src="https://img.shields.io/badge/Discord-Streamyfin-blue?style=flat-square&logo=discord">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
**Streamyfin is a user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
|
**Streamyfin is a user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -16,53 +22,75 @@
|
|||||||
|
|
||||||
<img src="./assets/images/screenshots/screenshot2.png" width="20%">
|
<img src="./assets/images/screenshots/screenshot2.png" width="20%">
|
||||||
|
|
||||||
<img src="./assets/images/jellyseerr.PNG" width="21%">
|
<img src="./assets/images/seerr.PNG" width="21%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
|
|
||||||
- 🚀 **Skip Intro / Credits Support**: Lets you quickly skip intros and credits during playback
|
### 🎬 Media Playback
|
||||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking
|
- 🚀 **Skip Intro / Credits**: Automatically skip intros and credits during playback
|
||||||
- 📥 **Download media**: Save your media locally and watch it offline
|
- 🖼️ **Trickplay Images**: Chapter previews with thumbnails when seeking
|
||||||
- ⚙️ **Settings management**: Manage app configurations for all users through our plugin
|
- 🎵 **Music Library**: Full support for music playback with playlists and queue management
|
||||||
- 🤖 **Seerr (formerly Jellyseerr) integration**: Request media directly in the app
|
- 📺 **Live TV**: Watch and record live television streams
|
||||||
- 👁️ **Sessions view:** View all active sessions currently streaming on your server
|
|
||||||
- 📡 **Chromecast**: Cast your media to any Chromecast-enabled device
|
- 📡 **Chromecast**: Cast your media to any Chromecast-enabled device
|
||||||
|
- 🎥 **MPV Player**: Powerful open-source player with wide format support
|
||||||
|
|
||||||
## 🧪 Experimental Features
|
### 📱 Media Management
|
||||||
|
- 📥 **Download Media**: Save movies, shows, and music locally for offline viewing
|
||||||
|
- ⭐ **Favorites**: Quick access to your favorite content
|
||||||
|
- 📋 **Watchlists**: Create and manage custom watchlists with Streamystats integration
|
||||||
|
- 🔖 **Continue Watching**: Pick up right where you left off
|
||||||
|
- 🎯 **Next Up**: Smart suggestions for your next episode
|
||||||
|
|
||||||
Streamyfin offers exciting experimental features such as media downloading and Chromecast support. These features are under active development, and your feedback and patience help us make them even better.
|
### ⚙️ Advanced Features
|
||||||
|
- 🤖 **Seerr Integration**: Request new media directly in the app
|
||||||
|
- 🔍 **Smart Search**: Powerful search with Marlin Search and Streamystats support
|
||||||
|
- 👁️ **Active Sessions**: View all active streams on your server
|
||||||
|
- 🌐 **Multi-Language**: Available in 20+ languages with Crowdin integration
|
||||||
|
- 🎨 **Customizable**: Personalize your home screen and settings
|
||||||
|
- 🔌 **Plugin System**: Centralized settings sync across all devices via Jellyfin plugin
|
||||||
|
|
||||||
### 📥 Downloading
|
## 🧩 How It Works
|
||||||
|
|
||||||
|
### 📥 Downloads
|
||||||
|
|
||||||
Downloading works by using FFmpeg to convert an HLS stream into a video file on your device. This lets you download and watch any content that you can stream. The conversion is handled in real time by Jellyfin on the server during the download. While this may take a bit longer, it ensures compatibility with any file your server can transcode.
|
Downloading works by using FFmpeg to convert an HLS stream into a video file on your device. This lets you download and watch any content that you can stream. The conversion is handled in real time by Jellyfin on the server during the download. While this may take a bit longer, it ensures compatibility with any file your server can transcode.
|
||||||
|
|
||||||
### 🧩 Streamyfin Plugin
|
### 🧩 Streamyfin Plugin
|
||||||
|
|
||||||
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
|
The Jellyfin Plugin for Streamyfin synchronizes settings across all your devices and users. Install it on your Jellyfin server to enable:
|
||||||
|
|
||||||
- Automatic Seerr login with no user input required
|
- Automatic Seerr login with no user input required
|
||||||
- Set your preferred default languages
|
- Default language preferences for audio and subtitles
|
||||||
- Configure download method and search provider
|
- Configure download settings and search providers (Marlin, Streamystats)
|
||||||
- Personalize your home screen
|
- Customize your home screen layout and sections
|
||||||
|
- Centralized configuration management
|
||||||
- And much more
|
- And much more
|
||||||
|
|
||||||
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
||||||
|
|
||||||
### 📡 Chromecast
|
### 🎬 MPV Player
|
||||||
|
|
||||||
Chromecast support is currently under development. Video casting is already available, and we're actively working on adding subtitle support and additional features.
|
Streamyfin uses [MPV](https://mpv.io/) as its primary video player on all platforms, powered by [MPVKit](https://github.com/mpvkit/MPVKit). MPV is a powerful, open-source media player known for its wide format support and high-quality playback.
|
||||||
|
|
||||||
### 🔍 Jellysearch
|
Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building the native MPV module in Streamyfin.
|
||||||
|
|
||||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin
|
### 🎵 Music Library
|
||||||
|
|
||||||
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
Full music library support with playlists, queue management, background playback, and offline downloads.
|
||||||
|
|
||||||
|
### 🔍 Search Providers
|
||||||
|
|
||||||
|
Streamyfin supports multiple search providers:
|
||||||
|
|
||||||
|
- **Marlin Search**: Fast semantic search for your Jellyfin library
|
||||||
|
- **Streamystats**: Advanced statistics and personalized recommendations
|
||||||
|
- **Jellysearch**: Fast full-text search proxy ([Jellysearch](https://gitlab.com/DomiStyle/jellysearch))
|
||||||
|
|
||||||
## 🛣️ Roadmap
|
## 🛣️ Roadmap
|
||||||
|
|
||||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
||||||
|
|
||||||
## 📥 Download Streamyfin
|
## 📥 Download Streamyfin
|
||||||
|
|
||||||
@@ -102,13 +130,13 @@ You can contribute translations directly on our [Crowdin project page](https://c
|
|||||||
|
|
||||||
### 👨💻 Development Info
|
### 👨💻 Development Info
|
||||||
|
|
||||||
1. Use node `>20`
|
1. Use Node.js `>20`
|
||||||
2. Install dependencies `bun i && bun run submodule-reload`
|
2. Install dependencies: `bun i && bun run submodule-reload`
|
||||||
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
3. Make sure you have Xcode and/or Android Studio installed ([Expo setup guide](https://docs.expo.dev/workflow/android-studio-emulator/))
|
||||||
- If iOS builds fail with `missing Metal Toolchain` (KSPlayer shaders), run `npm run ios:install-metal-toolchain` once
|
- If iOS builds fail with `missing Metal Toolchain` (KSPlayer shaders), run `npm run ios:install-metal-toolchain` once
|
||||||
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
|
4. Install the [BiomeJS extension](https://biomejs.dev/) in your IDE
|
||||||
4. run `npm run prebuild`
|
5. Run `npm run prebuild`
|
||||||
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
|
6. Create an Expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
|
||||||
|
|
||||||
For the TV version suffix the npm commands with `:tv`.
|
For the TV version suffix the npm commands with `:tv`.
|
||||||
|
|
||||||
@@ -126,10 +154,20 @@ Need assistance or have any questions?
|
|||||||
|
|
||||||
## ❓ FAQ
|
## ❓ FAQ
|
||||||
|
|
||||||
1. Q: Why can't I see my libraries in Streamyfin?
|
1. **Q: Why can't I see my libraries in Streamyfin?**
|
||||||
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only
|
A: Ensure your Jellyfin server is running a recent version (10.10.0+) and that you have proper permissions to access the libraries.
|
||||||
2. Q: Why can't I see my music library?
|
|
||||||
A: We don't currently support music and are unlikely to support music in the near future
|
2. **Q: How do I enable downloads?**
|
||||||
|
A: Downloads use FFmpeg to convert HLS streams. Ensure your server has transcoding enabled and sufficient resources.
|
||||||
|
|
||||||
|
3. **Q: Does Streamyfin support subtitles?**
|
||||||
|
A: Yes, with full customization including size, color, position, and automatic language selection.
|
||||||
|
|
||||||
|
4. **Q: Can I use Streamyfin on Apple TV or Android TV?**
|
||||||
|
A: Yes, Streamyfin has dedicated TV builds optimized for remote control navigation. Please note that TV platforms are currently in early development and not very stable. Android TV is currently the most reliable platform for testing.
|
||||||
|
|
||||||
|
5. **Q: How do I set up Seerr integration?**
|
||||||
|
A: Go to Settings → Plugins → Seerr, enter your server URL and Jellyfin credentials.
|
||||||
|
|
||||||
## 📝 Credits
|
## 📝 Credits
|
||||||
|
|
||||||
@@ -230,6 +268,7 @@ We also thank all other developers who have contributed to Streamyfin, your effo
|
|||||||
|
|
||||||
A special mention to the following people and projects for their contributions:
|
A special mention to the following people and projects for their contributions:
|
||||||
|
|
||||||
|
- [@Alexk2309](https://github.com/Alexk2309) for building the native MPV module that integrates [MPVKit](https://github.com/mpvkit/MPVKit) with React Native
|
||||||
- [Reiverr](https://github.com/aleksilassila/reiverr) for invaluable help with understanding the Jellyfin API
|
- [Reiverr](https://github.com/aleksilassila/reiverr) for invaluable help with understanding the Jellyfin API
|
||||||
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for providing the TypeScript SDK
|
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for providing the TypeScript SDK
|
||||||
- [Seerr](https://github.com/seerr-team/seerr) for enabling API integration with their project
|
- [Seerr](https://github.com/seerr-team/seerr) for enabling API integration with their project
|
||||||
@@ -242,7 +281,9 @@ A special mention to the following people and projects for their contributions:
|
|||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0).
|
Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0).
|
||||||
|
|
||||||
This means you are free to use, modify, and distribute this software. The MPL-2.0 is a copyleft license that allows for more flexibility in combining the software with proprietary code.
|
This means you are free to use, modify, and distribute this software. The MPL-2.0 is a copyleft license that allows for more flexibility in combining the software with proprietary code.
|
||||||
|
|
||||||
Key points of the MPL-2.0:
|
Key points of the MPL-2.0:
|
||||||
|
|
||||||
- You can use the software for any purpose
|
- You can use the software for any purpose
|
||||||
@@ -251,10 +292,13 @@ Key points of the MPL-2.0:
|
|||||||
- You must disclose your source code for any modifications to the covered files
|
- You must disclose your source code for any modifications to the covered files
|
||||||
- Larger works may combine MPL code with code under other licenses
|
- Larger works may combine MPL code with code under other licenses
|
||||||
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license
|
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license
|
||||||
- For the full text of the license, please see the LICENSE file in this repository
|
|
||||||
|
For the full text of the license, please see the LICENSE file in this repository.
|
||||||
|
|
||||||
## ⚠️ Disclaimer
|
## ⚠️ Disclaimer
|
||||||
|
|
||||||
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions, support requests, or references to piracy, as well as any tools, software, or websites related to piracy, are strictly prohibited across all our channels.
|
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions, support requests, or references to piracy, as well as any tools, software, or websites related to piracy, are strictly prohibited across all our channels.
|
||||||
|
|
||||||
## 🤝 Sponsorship
|
## 🤝 Sponsorship
|
||||||
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)
|
|
||||||
|
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster).
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ module.exports = ({ config }) => {
|
|||||||
"react-native-google-cast",
|
"react-native-google-cast",
|
||||||
{ useDefaultExpandedMediaControls: true },
|
{ useDefaultExpandedMediaControls: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// KSPlayer for iOS (GPU acceleration + native PiP)
|
|
||||||
config.plugins.push("./plugins/withKSPlayer.js");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only override googleServicesFile if env var is set
|
// Only override googleServicesFile if env var is set
|
||||||
|
|||||||
31
app.json
31
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.51.0",
|
"version": "0.52.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
||||||
"UIBackgroundModes": ["audio", "fetch"],
|
"UIBackgroundModes": ["audio", "fetch"],
|
||||||
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
||||||
|
"NSLocationWhenInUseUsageDescription": "Streamyfin uses your location to detect your home WiFi network for automatic local server switching.",
|
||||||
"NSAppTransportSecurity": {
|
"NSAppTransportSecurity": {
|
||||||
"NSAllowsArbitraryLoads": true
|
"NSAllowsArbitraryLoads": true
|
||||||
},
|
},
|
||||||
@@ -28,13 +29,16 @@
|
|||||||
"usesNonExemptEncryption": false
|
"usesNonExemptEncryption": false
|
||||||
},
|
},
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
|
"entitlements": {
|
||||||
|
"com.apple.developer.networking.wifi-info": true
|
||||||
|
},
|
||||||
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
||||||
"icon": "./assets/images/icon-ios-liquid-glass.icon",
|
"icon": "./assets/images/icon-ios-liquid-glass.icon",
|
||||||
"appleTeamId": "MWD5K362T8"
|
"appleTeamId": "MWD5K362T8"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 91,
|
"versionCode": 92,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
@@ -44,7 +48,8 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"android.permission.FOREGROUND_SERVICE",
|
"android.permission.FOREGROUND_SERVICE",
|
||||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
||||||
"android.permission.WRITE_SETTINGS"
|
"android.permission.WRITE_SETTINGS",
|
||||||
|
"android.permission.ACCESS_FINE_LOCATION"
|
||||||
],
|
],
|
||||||
"blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"],
|
"blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"],
|
||||||
"googleServicesFile": "./google-services.json"
|
"googleServicesFile": "./google-services.json"
|
||||||
@@ -58,7 +63,8 @@
|
|||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
"ios": {
|
"ios": {
|
||||||
"deploymentTarget": "15.6"
|
"deploymentTarget": "15.6",
|
||||||
|
"useFrameworks": "static"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"buildArchs": ["arm64-v8a", "x86_64"],
|
"buildArchs": ["arm64-v8a", "x86_64"],
|
||||||
@@ -66,7 +72,7 @@
|
|||||||
"targetSdkVersion": 35,
|
"targetSdkVersion": 35,
|
||||||
"buildToolsVersion": "35.0.0",
|
"buildToolsVersion": "35.0.0",
|
||||||
"kotlinVersion": "2.0.21",
|
"kotlinVersion": "2.0.21",
|
||||||
"minSdkVersion": 24,
|
"minSdkVersion": 26,
|
||||||
"usesCleartextTraffic": true,
|
"usesCleartextTraffic": true,
|
||||||
"packagingOptions": {
|
"packagingOptions": {
|
||||||
"jniLibs": {
|
"jniLibs": {
|
||||||
@@ -84,12 +90,6 @@
|
|||||||
"initialOrientation": "DEFAULT"
|
"initialOrientation": "DEFAULT"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
|
||||||
"expo-sensors",
|
|
||||||
{
|
|
||||||
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"expo-localization",
|
"expo-localization",
|
||||||
"expo-asset",
|
"expo-asset",
|
||||||
[
|
[
|
||||||
@@ -120,7 +120,14 @@
|
|||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||||
["./plugins/withAndroidManifest.js"],
|
["./plugins/withAndroidManifest.js"],
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
["./plugins/withGradleProperties.js"]
|
["./plugins/withGradleProperties.js"],
|
||||||
|
[
|
||||||
|
"./plugins/withGitPod.js",
|
||||||
|
{
|
||||||
|
"podName": "MPVKit-GPL",
|
||||||
|
"podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec"
|
||||||
|
}
|
||||||
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default function menuLinks() {
|
|||||||
);
|
);
|
||||||
const config = response?.data;
|
const config = response?.data;
|
||||||
|
|
||||||
if (!config && !Object.hasOwn(config, "menuLinks")) {
|
if (!config || !Object.hasOwn(config, "menuLinks")) {
|
||||||
console.error("Menu links not found");
|
console.error("Menu links not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
|
||||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||||
|
|
||||||
@@ -46,32 +48,13 @@ export default function IndexLayout() {
|
|||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
title: t("home.downloads.downloads_title"),
|
title: t("home.downloads.downloads_title"),
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='downloads/[seriesId]'
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
|
||||||
headerShadowVisible: false,
|
|
||||||
title: t("home.downloads.tvseries"),
|
|
||||||
headerLeft: () => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -84,13 +67,13 @@ export default function IndexLayout() {
|
|||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -102,13 +85,13 @@ export default function IndexLayout() {
|
|||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -120,13 +103,13 @@ export default function IndexLayout() {
|
|||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -138,13 +121,13 @@ export default function IndexLayout() {
|
|||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -156,13 +139,13 @@ export default function IndexLayout() {
|
|||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -174,13 +157,13 @@ export default function IndexLayout() {
|
|||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -192,13 +175,13 @@ export default function IndexLayout() {
|
|||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -210,13 +193,13 @@ export default function IndexLayout() {
|
|||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -228,31 +211,31 @@ export default function IndexLayout() {
|
|||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings/plugins/jellyseerr/page'
|
name='settings/plugins/seerr/page'
|
||||||
options={{
|
options={{
|
||||||
title: "Jellyseerr",
|
title: "Seerr",
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -264,13 +247,13 @@ export default function IndexLayout() {
|
|||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -282,13 +265,13 @@ export default function IndexLayout() {
|
|||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -300,13 +283,13 @@ export default function IndexLayout() {
|
|||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -318,27 +301,32 @@ export default function IndexLayout() {
|
|||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='intro/page'
|
name='settings/network/page'
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
title: t("home.settings.network.title"),
|
||||||
title: "",
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
presentation: "modal",
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
@@ -349,9 +337,9 @@ export default function IndexLayout() {
|
|||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
<Pressable onPress={() => _router.back()} className='pl-0.5'>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
@@ -367,13 +355,13 @@ const SettingsButton = () => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/settings");
|
router.push("/(auth)/settings");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Feather name='settings' color={"white"} size={22} />
|
<Feather name='settings' color={"white"} size={22} />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -382,7 +370,7 @@ const SessionsButton = () => {
|
|||||||
const { sessions = [] } = useSessions({} as useSessionsProps);
|
const { sessions = [] } = useSessions({} as useSessionsProps);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/sessions");
|
router.push("/(auth)/sessions");
|
||||||
}}
|
}}
|
||||||
@@ -393,6 +381,6 @@ const SessionsButton = () => {
|
|||||||
color={sessions.length === 0 ? "white" : "#9333ea"}
|
color={sessions.length === 0 ? "white" : "#9333ea"}
|
||||||
size={28}
|
size={28}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
|
||||||
import {
|
|
||||||
SeasonDropdown,
|
|
||||||
type SeasonIndexState,
|
|
||||||
} from "@/components/series/SeasonDropdown";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const local = useLocalSearchParams();
|
|
||||||
const { seriesId, episodeSeasonIndex } = local as {
|
|
||||||
seriesId: string;
|
|
||||||
episodeSeasonIndex: number | string | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
const { downloadedItems, deleteItems } = useDownload();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const series = useMemo(() => {
|
|
||||||
try {
|
|
||||||
return (
|
|
||||||
downloadedItems
|
|
||||||
?.filter((f) => f.item.SeriesId === seriesId)
|
|
||||||
?.sort(
|
|
||||||
(a, b) =>
|
|
||||||
(a.item.ParentIndexNumber ?? 0) - (b.item.ParentIndexNumber ?? 0),
|
|
||||||
) || []
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [downloadedItems, seriesId]);
|
|
||||||
|
|
||||||
// Group episodes by season in a single pass
|
|
||||||
const seasonGroups = useMemo(() => {
|
|
||||||
const groups: Record<number, BaseItemDto[]> = {};
|
|
||||||
|
|
||||||
series.forEach((episode) => {
|
|
||||||
const seasonNumber = episode.item.ParentIndexNumber;
|
|
||||||
if (seasonNumber !== undefined && seasonNumber !== null) {
|
|
||||||
if (!groups[seasonNumber]) {
|
|
||||||
groups[seasonNumber] = [];
|
|
||||||
}
|
|
||||||
groups[seasonNumber].push(episode.item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort episodes within each season
|
|
||||||
Object.values(groups).forEach((episodes) => {
|
|
||||||
episodes.sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0));
|
|
||||||
});
|
|
||||||
|
|
||||||
return groups;
|
|
||||||
}, [series]);
|
|
||||||
|
|
||||||
// Get unique seasons (just the season numbers, sorted)
|
|
||||||
const uniqueSeasons = useMemo(() => {
|
|
||||||
const seasonNumbers = Object.keys(seasonGroups)
|
|
||||||
.map(Number)
|
|
||||||
.sort((a, b) => a - b);
|
|
||||||
return seasonNumbers.map((seasonNum) => seasonGroups[seasonNum][0]); // First episode of each season
|
|
||||||
}, [seasonGroups]);
|
|
||||||
|
|
||||||
const seasonIndex =
|
|
||||||
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ??
|
|
||||||
episodeSeasonIndex ??
|
|
||||||
series?.[0]?.item?.ParentIndexNumber ??
|
|
||||||
"";
|
|
||||||
|
|
||||||
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
|
||||||
return seasonGroups[Number(seasonIndex)] ?? [];
|
|
||||||
}, [seasonGroups, seasonIndex]);
|
|
||||||
|
|
||||||
const initialSeasonIndex = useMemo(
|
|
||||||
() =>
|
|
||||||
groupBySeason?.[0]?.ParentIndexNumber ??
|
|
||||||
series?.[0]?.item?.ParentIndexNumber,
|
|
||||||
[groupBySeason, series],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (series.length > 0) {
|
|
||||||
navigation.setOptions({
|
|
||||||
title: series[0].item.SeriesName,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
storage.remove(seriesId);
|
|
||||||
router.back();
|
|
||||||
}
|
|
||||||
}, [series]);
|
|
||||||
|
|
||||||
const deleteSeries = useCallback(() => {
|
|
||||||
Alert.alert(
|
|
||||||
"Delete season",
|
|
||||||
"Are you sure you want to delete the entire season?",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: "Cancel",
|
|
||||||
style: "cancel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Delete",
|
|
||||||
onPress: () =>
|
|
||||||
deleteItems(
|
|
||||||
groupBySeason
|
|
||||||
.map((item) => item.Id)
|
|
||||||
.filter((id) => id !== undefined),
|
|
||||||
),
|
|
||||||
style: "destructive",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}, [groupBySeason, deleteItems]);
|
|
||||||
|
|
||||||
const ListHeaderComponent = useCallback(() => {
|
|
||||||
if (series.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='flex flex-row items-center justify-start pb-2'>
|
|
||||||
<SeasonDropdown
|
|
||||||
item={series[0].item}
|
|
||||||
seasons={uniqueSeasons}
|
|
||||||
state={seasonIndexState}
|
|
||||||
initialSeasonIndex={initialSeasonIndex!}
|
|
||||||
onSelect={(season) => {
|
|
||||||
setSeasonIndexState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'>
|
|
||||||
<Text className='text-xs font-bold'>{groupBySeason.length}</Text>
|
|
||||||
</View>
|
|
||||||
<View className='bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto'>
|
|
||||||
<TouchableOpacity onPress={deleteSeries}>
|
|
||||||
<Ionicons name='trash' size={20} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
series,
|
|
||||||
uniqueSeasons,
|
|
||||||
seasonIndexState,
|
|
||||||
initialSeasonIndex,
|
|
||||||
groupBySeason,
|
|
||||||
deleteSeries,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='flex-1'>
|
|
||||||
<FlashList
|
|
||||||
key={seasonIndex}
|
|
||||||
data={groupBySeason}
|
|
||||||
renderItem={({ item }) => <EpisodeCard item={item} />}
|
|
||||||
keyExtractor={(item, index) => item.Id ?? `episode-${index}`}
|
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingLeft: insets.left + 16,
|
|
||||||
paddingRight: insets.right + 16,
|
|
||||||
paddingTop: Platform.OS === "android" ? 10 : 8,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,10 @@
|
|||||||
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { Alert, Platform, ScrollView, View } from "react-native";
|
||||||
Alert,
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
Platform,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -18,8 +13,10 @@ import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
|||||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { type DownloadedItem } from "@/providers/Downloads/types";
|
import { type DownloadedItem } from "@/providers/Downloads/types";
|
||||||
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
import { queueAtom } from "@/utils/atoms/queue";
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
|
|
||||||
@@ -103,12 +100,12 @@ export default function page() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={bottomSheetModalRef.current?.present}
|
onPress={bottomSheetModalRef.current?.present}
|
||||||
className='px-2'
|
className='px-2'
|
||||||
>
|
>
|
||||||
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}, [downloadedFiles]);
|
}, [downloadedFiles]);
|
||||||
@@ -166,145 +163,99 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<OfflineModeProvider isOffline={true}>
|
||||||
showsVerticalScrollIndicator={false}
|
<ScrollView
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
|
>
|
||||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
|
||||||
{/* Queue card - hidden */}
|
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||||
{/* <View className='bg-neutral-900 p-4 rounded-2xl'>
|
<ActiveDownloads />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{movies.length > 0 && (
|
||||||
|
<View className='mb-4'>
|
||||||
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
<Text className='text-lg font-bold'>
|
<Text className='text-lg font-bold'>
|
||||||
{t("home.downloads.queue")}
|
{t("home.downloads.movies")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='text-xs opacity-70 text-red-600'>
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
{t("home.downloads.queue_hint")}
|
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
||||||
</Text>
|
</View>
|
||||||
<View className='flex flex-col space-y-2 mt-2'>
|
</View>
|
||||||
{queue.map((q, index) => (
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
<TouchableOpacity
|
<View className='px-4 flex flex-row'>
|
||||||
onPress={() =>
|
{movies?.map((item) => (
|
||||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
<TouchableItemRouter item={item.item} key={item.item.Id}>
|
||||||
}
|
<MovieCard item={item.item} />
|
||||||
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between'
|
</TouchableItemRouter>
|
||||||
key={index}
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text className='font-semibold'>{q.item.Name}</Text>
|
|
||||||
<Text className='text-xs opacity-50'>
|
|
||||||
{q.item.Type}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
removeProcess(q.id);
|
|
||||||
setQueue((prev) => {
|
|
||||||
if (!prev) return [];
|
|
||||||
return [...prev.filter((i) => i.id !== q.id)];
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='close' size={24} color='red' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
</ScrollView>
|
||||||
{queue.length === 0 && (
|
|
||||||
<Text className='opacity-50'>
|
|
||||||
{t("home.downloads.no_items_in_queue")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View> */}
|
|
||||||
|
|
||||||
<ActiveDownloads />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{movies.length > 0 && (
|
|
||||||
<View className='mb-4'>
|
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
|
||||||
<Text className='text-lg font-bold'>
|
|
||||||
{t("home.downloads.movies")}
|
|
||||||
</Text>
|
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
|
||||||
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
)}
|
||||||
<View className='px-4 flex flex-row'>
|
{groupedBySeries.length > 0 && (
|
||||||
{movies?.map((item) => (
|
<View className='mb-4'>
|
||||||
<TouchableItemRouter
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
item={item.item}
|
<Text className='text-lg font-bold'>
|
||||||
isOffline
|
{t("home.downloads.tvseries")}
|
||||||
key={item.item.Id}
|
|
||||||
>
|
|
||||||
<MovieCard item={item.item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{groupedBySeries.length > 0 && (
|
|
||||||
<View className='mb-4'>
|
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
|
||||||
<Text className='text-lg font-bold'>
|
|
||||||
{t("home.downloads.tvseries")}
|
|
||||||
</Text>
|
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
|
||||||
<Text className='text-xs font-bold'>
|
|
||||||
{groupedBySeries?.length}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
|
<Text className='text-xs font-bold'>
|
||||||
|
{groupedBySeries?.length}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
<View className='px-4 flex flex-row'>
|
||||||
<View className='px-4 flex flex-row'>
|
{groupedBySeries?.map((items) => (
|
||||||
{groupedBySeries?.map((items) => (
|
<View
|
||||||
<View className='mb-2 last:mb-0' key={items[0].item.SeriesId}>
|
className='mb-2 last:mb-0'
|
||||||
<SeriesCard
|
|
||||||
items={items.map((i) => i.item)}
|
|
||||||
key={items[0].item.SeriesId}
|
key={items[0].item.SeriesId}
|
||||||
/>
|
>
|
||||||
</View>
|
<SeriesCard
|
||||||
))}
|
items={items.map((i) => i.item)}
|
||||||
</View>
|
key={items[0].item.SeriesId}
|
||||||
</ScrollView>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
))}
|
||||||
|
</View>
|
||||||
{otherMedia.length > 0 && (
|
</ScrollView>
|
||||||
<View className='mb-4'>
|
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
|
||||||
<Text className='text-lg font-bold'>
|
|
||||||
{t("home.downloads.other_media")}
|
|
||||||
</Text>
|
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
|
||||||
<Text className='text-xs font-bold'>{otherMedia?.length}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
)}
|
||||||
<View className='px-4 flex flex-row'>
|
|
||||||
{otherMedia?.map((item) => (
|
{otherMedia.length > 0 && (
|
||||||
<TouchableItemRouter
|
<View className='mb-4'>
|
||||||
item={item.item}
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
isOffline
|
<Text className='text-lg font-bold'>
|
||||||
key={item.item.Id}
|
{t("home.downloads.other_media")}
|
||||||
>
|
</Text>
|
||||||
<MovieCard item={item.item} />
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
</TouchableItemRouter>
|
<Text className='text-xs font-bold'>
|
||||||
))}
|
{otherMedia?.length}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
</View>
|
<View className='px-4 flex flex-row'>
|
||||||
)}
|
{otherMedia?.map((item) => (
|
||||||
{downloadedFiles?.length === 0 && (
|
<TouchableItemRouter item={item.item} key={item.item.Id}>
|
||||||
<View className='flex px-4'>
|
<MovieCard item={item.item} />
|
||||||
<Text className='opacity-50'>
|
</TouchableItemRouter>
|
||||||
{t("home.downloads.no_downloaded_items")}
|
))}
|
||||||
</Text>
|
</View>
|
||||||
</View>
|
</ScrollView>
|
||||||
)}
|
</View>
|
||||||
</View>
|
)}
|
||||||
</ScrollView>
|
{downloadedFiles?.length === 0 && (
|
||||||
|
<View className='flex px-4'>
|
||||||
|
<Text className='opacity-50'>
|
||||||
|
{t("home.downloads.no_downloaded_items")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</OfflineModeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useFocusEffect, useRouter } from "expo-router";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Linking, Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
storage.set("hasShownIntro", true);
|
|
||||||
}, []),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 space-y-4" : "py-16 space-y-8"} px-4`}
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text className='text-3xl font-bold text-center mb-2'>
|
|
||||||
{t("home.intro.welcome_to_streamyfin")}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-center'>
|
|
||||||
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View>
|
|
||||||
<Text className='text-lg font-bold'>
|
|
||||||
{t("home.intro.features_title")}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-xs'>{t("home.intro.features_description")}</Text>
|
|
||||||
<View className='flex flex-row items-center mt-4'>
|
|
||||||
<Image
|
|
||||||
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
|
||||||
style={{
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View className='shrink ml-2'>
|
|
||||||
<Text className='font-bold mb-1'>Jellyseerr</Text>
|
|
||||||
<Text className='shrink text-xs'>
|
|
||||||
{t("home.intro.jellyseerr_feature_description")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{!Platform.isTV && (
|
|
||||||
<>
|
|
||||||
<View className='flex flex-row items-center mt-4'>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
}}
|
|
||||||
className='flex items-center justify-center'
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name='cloud-download-outline'
|
|
||||||
size={32}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View className='shrink ml-2'>
|
|
||||||
<Text className='font-bold mb-1'>
|
|
||||||
{t("home.intro.downloads_feature_title")}
|
|
||||||
</Text>
|
|
||||||
<Text className='shrink text-xs'>
|
|
||||||
{t("home.intro.downloads_feature_description")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View className='flex flex-row items-center mt-4'>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
}}
|
|
||||||
className='flex items-center justify-center'
|
|
||||||
>
|
|
||||||
<Feather name='cast' size={28} color={"white"} />
|
|
||||||
</View>
|
|
||||||
<View className='shrink ml-2'>
|
|
||||||
<Text className='font-bold mb-1'>Chromecast</Text>
|
|
||||||
<Text className='shrink text-xs'>
|
|
||||||
{t("home.intro.chromecast_feature_description")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<View className='flex flex-row items-center mt-4'>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
}}
|
|
||||||
className='flex items-center justify-center'
|
|
||||||
>
|
|
||||||
<Feather name='settings' size={28} color={"white"} />
|
|
||||||
</View>
|
|
||||||
<View className='shrink ml-2'>
|
|
||||||
<Text className='font-bold mb-1'>
|
|
||||||
{t("home.intro.centralised_settings_plugin_title")}
|
|
||||||
</Text>
|
|
||||||
<View className='flex-row flex-wrap items-baseline'>
|
|
||||||
<Text className='shrink text-xs'>
|
|
||||||
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
Linking.openURL(
|
|
||||||
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='text-xs text-purple-600 underline'>
|
|
||||||
{t("home.intro.read_more")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<Button
|
|
||||||
onPress={() => {
|
|
||||||
router.back();
|
|
||||||
}}
|
|
||||||
className='mt-4'
|
|
||||||
>
|
|
||||||
{t("home.intro.done_button")}
|
|
||||||
</Button>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.back();
|
|
||||||
router.push("/settings");
|
|
||||||
}}
|
|
||||||
className='mt-4'
|
|
||||||
>
|
|
||||||
<Text className='text-purple-600 text-center'>
|
|
||||||
{t("home.intro.go_to_settings_button")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -11,6 +11,7 @@ import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
|||||||
import { QuickConnect } from "@/components/settings/QuickConnect";
|
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||||
import { UserInfo } from "@/components/settings/UserInfo";
|
import { UserInfo } from "@/components/settings/UserInfo";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
@@ -90,6 +91,11 @@ export default function settings() {
|
|||||||
showArrow
|
showArrow
|
||||||
title={t("home.settings.intro.title")}
|
title={t("home.settings.intro.title")}
|
||||||
/>
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/network/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.network.title")}
|
||||||
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/logs/page")}
|
onPress={() => router.push("/settings/logs/page")}
|
||||||
showArrow
|
showArrow
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function page() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Platform, ScrollView, View } from "react-native";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { AudioToggles } from "@/components/settings/AudioToggles";
|
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||||
|
import { MpvSubtitleSettings } from "@/components/settings/MpvSubtitleSettings";
|
||||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||||
|
|
||||||
export default function AudioSubtitlesPage() {
|
export default function AudioSubtitlesPage() {
|
||||||
@@ -22,6 +23,7 @@ export default function AudioSubtitlesPage() {
|
|||||||
<MediaProvider>
|
<MediaProvider>
|
||||||
<AudioToggles className='mb-4' />
|
<AudioToggles className='mb-4' />
|
||||||
<SubtitleToggles className='mb-4' />
|
<SubtitleToggles className='mb-4' />
|
||||||
|
<MpvSubtitleSettings className='mb-4' />
|
||||||
</MediaProvider>
|
</MediaProvider>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function page() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, ScrollView, View } from "react-native";
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
export default function IntroPage() {
|
export default function IntroPage() {
|
||||||
const router = useRouter();
|
const { showIntro } = useIntroSheet();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export default function IntroPage() {
|
|||||||
<ListGroup title={t("home.settings.intro.title")}>
|
<ListGroup title={t("home.settings.intro.title")}>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/intro/page");
|
showIntro();
|
||||||
}}
|
}}
|
||||||
title={t("home.settings.intro.show_intro")}
|
title={t("home.settings.intro.show_intro")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useMemo } from "react";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, ScrollView, View } from "react-native";
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
import { Switch } from "react-native-gesture-handler";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
|
import {
|
||||||
|
clearCache,
|
||||||
|
clearPermanentDownloads,
|
||||||
|
getStorageStats,
|
||||||
|
} from "@/providers/AudioStorage";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
const CACHE_SIZE_OPTIONS = [
|
const CACHE_SIZE_OPTIONS = [
|
||||||
@@ -29,6 +38,40 @@ export default function MusicSettingsPage() {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
const successHapticFeedback = useHaptic("success");
|
||||||
|
const errorHapticFeedback = useHaptic("error");
|
||||||
|
|
||||||
|
const { data: musicCacheStats } = useQuery({
|
||||||
|
queryKey: ["musicCacheStats"],
|
||||||
|
queryFn: () => getStorageStats(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onClearMusicCacheClicked = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await clearCache();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["musicCacheStats"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
||||||
|
successHapticFeedback();
|
||||||
|
toast.success(t("home.settings.storage.music_cache_cleared"));
|
||||||
|
} catch (_e) {
|
||||||
|
errorHapticFeedback();
|
||||||
|
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||||
|
}
|
||||||
|
}, [queryClient, successHapticFeedback, errorHapticFeedback, t]);
|
||||||
|
|
||||||
|
const onDeleteDownloadedSongsClicked = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await clearPermanentDownloads();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["musicCacheStats"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
||||||
|
successHapticFeedback();
|
||||||
|
toast.success(t("home.settings.storage.downloaded_songs_deleted"));
|
||||||
|
} catch (_e) {
|
||||||
|
errorHapticFeedback();
|
||||||
|
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||||
|
}
|
||||||
|
}, [queryClient, successHapticFeedback, errorHapticFeedback, t]);
|
||||||
|
|
||||||
const cacheSizeOptions = useMemo(
|
const cacheSizeOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -171,6 +214,37 @@ export default function MusicSettingsPage() {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{!Platform.isTV && (
|
||||||
|
<View className='mt-4'>
|
||||||
|
<ListGroup
|
||||||
|
title={t("home.settings.storage.music_cache_title")}
|
||||||
|
description={
|
||||||
|
<Text className='text-[#8E8D91] text-xs'>
|
||||||
|
{t("home.settings.storage.music_cache_description")}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
onPress={onClearMusicCacheClicked}
|
||||||
|
title={t("home.settings.storage.clear_music_cache")}
|
||||||
|
subtitle={t("home.settings.storage.music_cache_size", {
|
||||||
|
size: (musicCacheStats?.cacheSize ?? 0).bytesToReadable(),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
<ListGroup>
|
||||||
|
<ListItem
|
||||||
|
textColor='red'
|
||||||
|
onPress={onDeleteDownloadedSongsClicked}
|
||||||
|
title={t("home.settings.storage.delete_all_downloaded_songs")}
|
||||||
|
subtitle={t("home.settings.storage.downloaded_songs_size", {
|
||||||
|
size: (musicCacheStats?.permanentSize ?? 0).bytesToReadable(),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
48
app/(auth)/(tabs)/(home)/settings/network/page.tsx
Normal file
48
app/(auth)/(tabs)/(home)/settings/network/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { LocalNetworkSettings } from "@/components/settings/LocalNetworkSettings";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
|
export default function NetworkSettingsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
|
const remoteUrl = storage.getString("serverUrl");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: insets.bottom + 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='p-4 flex flex-col'
|
||||||
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
>
|
||||||
|
<ListGroup title={t("home.settings.network.current_server")}>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.network.remote_url")}
|
||||||
|
subtitle={remoteUrl ?? t("home.settings.network.not_configured")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.network.active_url")}
|
||||||
|
subtitle={api?.basePath ?? t("home.settings.network.not_connected")}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<View className='mt-4'>
|
||||||
|
<LocalNetworkSettings />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -16,6 +15,7 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
@@ -26,7 +26,7 @@ export default function page() {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
|
||||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { ScrollView } from "react-native";
|
import { ScrollView } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
import { SeerrSettings } from "@/components/settings/Seerr";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function Page() {
|
||||||
const { pluginSettings } = useSettings();
|
const { pluginSettings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
@@ -17,10 +17,10 @@ export default function page() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
disabled={pluginSettings?.seerrServerUrl?.locked === true}
|
||||||
className='px-4'
|
className='px-4'
|
||||||
>
|
>
|
||||||
<JellyseerrSettings />
|
<SeerrSettings />
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -15,6 +14,7 @@ import { toast } from "sonner-native";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
@@ -28,7 +28,7 @@ export default function page() {
|
|||||||
pluginSettings,
|
pluginSettings,
|
||||||
refreshStreamyfinPluginSettings,
|
refreshStreamyfinPluginSettings,
|
||||||
} = useSettings();
|
} = useSettings();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
|
||||||
// Local state for all editable fields
|
// Local state for all editable fields
|
||||||
const [url, setUrl] = useState<string>(settings?.streamyStatsServerUrl || "");
|
const [url, setUrl] = useState<string>(settings?.streamyStatsServerUrl || "");
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Animated, {
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ItemContent } from "@/components/ItemContent";
|
import { ItemContent } from "@/components/ItemContent";
|
||||||
import { useItemQuery } from "@/hooks/useItemQuery";
|
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||||
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const { id } = useLocalSearchParams() as { id: string };
|
const { id } = useLocalSearchParams() as { id: string };
|
||||||
@@ -75,39 +76,35 @@ const Page: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-1 relative'>
|
<OfflineModeProvider isOffline={isOffline}>
|
||||||
<Animated.View
|
<View className='flex flex-1 relative'>
|
||||||
pointerEvents={"none"}
|
<Animated.View
|
||||||
style={[animatedStyle]}
|
pointerEvents={"none"}
|
||||||
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black'
|
style={[animatedStyle]}
|
||||||
>
|
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black'
|
||||||
<View
|
>
|
||||||
style={{
|
<View
|
||||||
height: item?.Type === "Episode" ? 300 : 450,
|
style={{
|
||||||
}}
|
height: item?.Type === "Episode" ? 300 : 450,
|
||||||
className='bg-transparent rounded-lg mb-4 w-full'
|
}}
|
||||||
/>
|
className='bg-transparent rounded-lg mb-4 w-full'
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
|
/>
|
||||||
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
|
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
|
||||||
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
|
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
|
||||||
<View className='flex flex-row space-x-1 mb-8'>
|
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
<View className='flex flex-row space-x-1 mb-8'>
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
</View>
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
|
</View>
|
||||||
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
|
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
|
||||||
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||||
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||||
</Animated.View>
|
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
||||||
{item && (
|
</Animated.View>
|
||||||
<ItemContent
|
{item && <ItemContent item={item} itemWithSources={itemWithSources} />}
|
||||||
item={item}
|
</View>
|
||||||
isOffline={isOffline}
|
</OfflineModeProvider>
|
||||||
itemWithSources={itemWithSources}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ export default function ArtistDetailScreen() {
|
|||||||
{section.type === "albums" ? (
|
{section.type === "albums" ? (
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={section.data}
|
data={section.data}
|
||||||
height={200}
|
height={178}
|
||||||
keyExtractor={(item) => item.Id!}
|
keyExtractor={(item) => item.Id!}
|
||||||
renderItem={(item) => <MusicAlbumCard album={item} />}
|
renderItem={(item) => <MusicAlbumCard album={item} />}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,12 +8,7 @@ import { useLocalSearchParams, useNavigation } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
ActivityIndicator,
|
|
||||||
Dimensions,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -30,8 +25,7 @@ import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
|||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
const ARTWORK_SIZE = 120;
|
||||||
const ARTWORK_SIZE = SCREEN_WIDTH * 0.5;
|
|
||||||
|
|
||||||
export default function PlaylistDetailScreen() {
|
export default function PlaylistDetailScreen() {
|
||||||
const { playlistId } = useLocalSearchParams<{ playlistId: string }>();
|
const { playlistId } = useLocalSearchParams<{ playlistId: string }>();
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { Image } from "expo-image";
|
|||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { uniqBy } from "lodash";
|
import { uniqBy } from "lodash";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
import SeerrPoster from "@/components/posters/SeerrPoster";
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
|
||||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
import { Endpoints, useSeerr } from "@/hooks/useSeerr";
|
||||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
import {
|
import {
|
||||||
type MovieResult,
|
type MovieResult,
|
||||||
@@ -13,9 +13,9 @@ import {
|
|||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||||
|
|
||||||
export default function page() {
|
export default function CompanyPage() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
|
||||||
|
|
||||||
const { companyId, image, type } = local as unknown as {
|
const { companyId, image, type } = local as unknown as {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
@@ -25,12 +25,12 @@ export default function page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
|
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
|
||||||
queryKey: ["jellyseerr", "company", type, companyId],
|
queryKey: ["seerr", "company", type, companyId],
|
||||||
queryFn: async ({ pageParam }) => {
|
queryFn: async ({ pageParam }) => {
|
||||||
const params: any = {
|
const params: any = {
|
||||||
page: Number(pageParam),
|
page: Number(pageParam),
|
||||||
};
|
};
|
||||||
return jellyseerrApi?.discover(
|
return seerrApi?.discover(
|
||||||
`${
|
`${
|
||||||
Number(type) === DiscoverSliderType.NETWORKS
|
Number(type) === DiscoverSliderType.NETWORKS
|
||||||
? Endpoints.DISCOVER_TV_NETWORK
|
? Endpoints.DISCOVER_TV_NETWORK
|
||||||
@@ -39,7 +39,7 @@ export default function page() {
|
|||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
enabled: !!jellyseerrApi && !!companyId,
|
enabled: !!seerrApi && !!companyId,
|
||||||
initialPageParam: 1,
|
initialPageParam: 1,
|
||||||
getNextPageParam: (lastPage, pages) =>
|
getNextPageParam: (lastPage, pages) =>
|
||||||
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
||||||
@@ -53,25 +53,24 @@ export default function page() {
|
|||||||
data?.pages
|
data?.pages
|
||||||
?.filter((p) => p?.results.length)
|
?.filter((p) => p?.results.length)
|
||||||
.flatMap(
|
.flatMap(
|
||||||
(p) =>
|
(p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r)) ?? [],
|
||||||
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
|
|
||||||
),
|
),
|
||||||
"id",
|
"id",
|
||||||
) ?? [],
|
) ?? [],
|
||||||
[data],
|
[data, isSeerrMovieOrTvResult],
|
||||||
);
|
);
|
||||||
|
|
||||||
const backdrops = useMemo(
|
const backdrops = useMemo(
|
||||||
() =>
|
() =>
|
||||||
jellyseerrApi
|
seerrApi
|
||||||
? flatData.map((r) =>
|
? flatData.map((r) =>
|
||||||
jellyseerrApi.imageProxy(
|
seerrApi.imageProxy(
|
||||||
(r as TvResult | MovieResult).backdropPath,
|
(r as TvResult | MovieResult).backdropPath,
|
||||||
"w1920_and_h800_multi_faces",
|
"w1920_and_h800_multi_faces",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
[jellyseerrApi, flatData],
|
[seerrApi, flatData],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -92,7 +91,7 @@ export default function page() {
|
|||||||
key={companyId}
|
key={companyId}
|
||||||
className='bottom-1 w-1/2'
|
className='bottom-1 w-1/2'
|
||||||
source={{
|
source={{
|
||||||
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
|
uri: seerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
|
||||||
}}
|
}}
|
||||||
cachePolicy={"memory-disk"}
|
cachePolicy={"memory-disk"}
|
||||||
contentFit='contain'
|
contentFit='contain'
|
||||||
@@ -101,7 +100,7 @@ export default function page() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
|
renderItem={(item, _index) => <SeerrPoster item={item} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,15 +3,15 @@ import { useLocalSearchParams } from "expo-router";
|
|||||||
import { uniqBy } from "lodash";
|
import { uniqBy } from "lodash";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
|
import SeerrPoster from "@/components/posters/SeerrPoster";
|
||||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard";
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
|
||||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
import { Endpoints, useSeerr } from "@/hooks/useSeerr";
|
||||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
|
|
||||||
export default function page() {
|
export default function GenrePage() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
|
||||||
|
|
||||||
const { genreId, name, type } = local as unknown as {
|
const { genreId, name, type } = local as unknown as {
|
||||||
genreId: string;
|
genreId: string;
|
||||||
@@ -20,21 +20,21 @@ export default function page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
queryKey: ["jellyseerr", "company", type, genreId],
|
queryKey: ["seerr", "genre", type, genreId],
|
||||||
queryFn: async ({ pageParam }) => {
|
queryFn: async ({ pageParam }) => {
|
||||||
const params: any = {
|
const params: any = {
|
||||||
page: Number(pageParam),
|
page: Number(pageParam),
|
||||||
genre: genreId,
|
genre: genreId,
|
||||||
};
|
};
|
||||||
|
|
||||||
return jellyseerrApi?.discover(
|
return seerrApi?.discover(
|
||||||
type === DiscoverSliderType.MOVIE_GENRES
|
type === DiscoverSliderType.MOVIE_GENRES
|
||||||
? Endpoints.DISCOVER_MOVIES
|
? Endpoints.DISCOVER_MOVIES
|
||||||
: Endpoints.DISCOVER_TV,
|
: Endpoints.DISCOVER_TV,
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
enabled: !!jellyseerrApi && !!genreId,
|
enabled: !!seerrApi && !!genreId,
|
||||||
initialPageParam: 1,
|
initialPageParam: 1,
|
||||||
getNextPageParam: (lastPage, pages) =>
|
getNextPageParam: (lastPage, pages) =>
|
||||||
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
||||||
@@ -48,8 +48,7 @@ export default function page() {
|
|||||||
data?.pages
|
data?.pages
|
||||||
?.filter((p) => p?.results.length)
|
?.filter((p) => p?.results.length)
|
||||||
.flatMap(
|
.flatMap(
|
||||||
(p) =>
|
(p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r)) ?? [],
|
||||||
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
|
|
||||||
),
|
),
|
||||||
"id",
|
"id",
|
||||||
) ?? [],
|
) ?? [],
|
||||||
@@ -58,15 +57,12 @@ export default function page() {
|
|||||||
|
|
||||||
const backdrops = useMemo(
|
const backdrops = useMemo(
|
||||||
() =>
|
() =>
|
||||||
jellyseerrApi
|
seerrApi
|
||||||
? flatData.map((r) =>
|
? flatData.map((r) =>
|
||||||
jellyseerrApi.imageProxy(
|
seerrApi.imageProxy(r.backdropPath, "w1920_and_h800_multi_faces"),
|
||||||
r.backdropPath,
|
|
||||||
"w1920_and_h800_multi_faces",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
[jellyseerrApi, flatData],
|
[seerrApi, flatData],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -91,7 +87,7 @@ export default function page() {
|
|||||||
{name}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
|
renderItem={(item, _index) => <SeerrPoster item={item} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -18,17 +18,18 @@ import { toast } from "sonner-native";
|
|||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { GenreTags } from "@/components/GenreTags";
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
import Cast from "@/components/jellyseerr/Cast";
|
|
||||||
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
|
||||||
import RequestModal from "@/components/jellyseerr/RequestModal";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { JellyserrRatings } from "@/components/Ratings";
|
import { SeerrRatings } from "@/components/Ratings";
|
||||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
import Cast from "@/components/seerr/Cast";
|
||||||
|
import DetailFacts from "@/components/seerr/DetailFacts";
|
||||||
|
import RequestModal from "@/components/seerr/RequestModal";
|
||||||
|
import SeerrSeasons from "@/components/series/SeerrSeasons";
|
||||||
import { ItemActions } from "@/components/series/SeriesActions";
|
import { ItemActions } from "@/components/series/SeriesActions";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
import { useSeerr } from "@/hooks/useSeerr";
|
||||||
|
import { useSeerrCanRequest } from "@/utils/_seerr/useSeerrCanRequest";
|
||||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||||
import {
|
import {
|
||||||
type IssueType,
|
type IssueType,
|
||||||
@@ -67,7 +68,7 @@ const Page: React.FC = () => {
|
|||||||
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
const { seerrApi, seerrUser, requestMedia } = useSeerr();
|
||||||
|
|
||||||
const [issueType, setIssueType] = useState<IssueType>();
|
const [issueType, setIssueType] = useState<IssueType>();
|
||||||
const [issueMessage, setIssueMessage] = useState<string>();
|
const [issueMessage, setIssueMessage] = useState<string>();
|
||||||
@@ -82,8 +83,8 @@ const Page: React.FC = () => {
|
|||||||
isLoading,
|
isLoading,
|
||||||
refetch,
|
refetch,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
enabled: !!jellyseerrApi && !!result && !!result.id,
|
enabled: !!seerrApi && !!result && !!result.id,
|
||||||
queryKey: ["jellyseerr", "detail", mediaType, result.id],
|
queryKey: ["seerr", "detail", mediaType, result.id],
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
@@ -92,21 +93,18 @@ const Page: React.FC = () => {
|
|||||||
refetchInterval: 0,
|
refetchInterval: 0,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return mediaType === MediaType.MOVIE
|
return mediaType === MediaType.MOVIE
|
||||||
? jellyseerrApi?.movieDetails(result.id!)
|
? seerrApi?.movieDetails(result.id!)
|
||||||
: jellyseerrApi?.tvDetails(result.id!);
|
: seerrApi?.tvDetails(result.id!);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [canRequest, hasAdvancedRequestPermission] =
|
const [canRequest, hasAdvancedRequestPermission] =
|
||||||
useJellyseerrCanRequest(details);
|
useSeerrCanRequest(details);
|
||||||
|
|
||||||
const canManageRequests = useMemo(() => {
|
const canManageRequests = useMemo(() => {
|
||||||
if (!jellyseerrUser) return false;
|
if (!seerrUser) return false;
|
||||||
return hasPermission(
|
return hasPermission(Permission.MANAGE_REQUESTS, seerrUser.permissions);
|
||||||
Permission.MANAGE_REQUESTS,
|
}, [seerrUser]);
|
||||||
jellyseerrUser.permissions,
|
|
||||||
);
|
|
||||||
}, [jellyseerrUser]);
|
|
||||||
|
|
||||||
const pendingRequest = useMemo(() => {
|
const pendingRequest = useMemo(() => {
|
||||||
return details?.mediaInfo?.requests?.find(
|
return details?.mediaInfo?.requests?.find(
|
||||||
@@ -118,27 +116,27 @@ const Page: React.FC = () => {
|
|||||||
if (!pendingRequest?.id) return;
|
if (!pendingRequest?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await jellyseerrApi?.approveRequest(pendingRequest.id);
|
await seerrApi?.approveRequest(pendingRequest.id);
|
||||||
toast.success(t("jellyseerr.toasts.request_approved"));
|
toast.success(t("seerr.toasts.request_approved"));
|
||||||
refetch();
|
refetch();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
|
toast.error(t("seerr.toasts.failed_to_approve_request"));
|
||||||
console.error("Failed to approve request:", error);
|
console.error("Failed to approve request:", error);
|
||||||
}
|
}
|
||||||
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
}, [seerrApi, pendingRequest, refetch, t]);
|
||||||
|
|
||||||
const handleDeclineRequest = useCallback(async () => {
|
const handleDeclineRequest = useCallback(async () => {
|
||||||
if (!pendingRequest?.id) return;
|
if (!pendingRequest?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await jellyseerrApi?.declineRequest(pendingRequest.id);
|
await seerrApi?.declineRequest(pendingRequest.id);
|
||||||
toast.success(t("jellyseerr.toasts.request_declined"));
|
toast.success(t("seerr.toasts.request_declined"));
|
||||||
refetch();
|
refetch();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
|
toast.error(t("seerr.toasts.failed_to_decline_request"));
|
||||||
console.error("Failed to decline request:", error);
|
console.error("Failed to decline request:", error);
|
||||||
}
|
}
|
||||||
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
}, [seerrApi, pendingRequest, refetch, t]);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
@@ -153,7 +151,7 @@ const Page: React.FC = () => {
|
|||||||
|
|
||||||
const submitIssue = useCallback(() => {
|
const submitIssue = useCallback(() => {
|
||||||
if (result.id && issueType && issueMessage && details) {
|
if (result.id && issueType && issueMessage && details) {
|
||||||
jellyseerrApi
|
seerrApi
|
||||||
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
|
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setIssueType(undefined);
|
setIssueType(undefined);
|
||||||
@@ -161,7 +159,7 @@ const Page: React.FC = () => {
|
|||||||
bottomSheetModalRef?.current?.close();
|
bottomSheetModalRef?.current?.close();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
}, [seerrApi, details, result, issueType, issueMessage]);
|
||||||
|
|
||||||
const handleIssueModalDismiss = useCallback(() => {
|
const handleIssueModalDismiss = useCallback(() => {
|
||||||
setIssueTypeDropdownOpen(false);
|
setIssueTypeDropdownOpen(false);
|
||||||
@@ -213,7 +211,7 @@ const Page: React.FC = () => {
|
|||||||
const issueTypeOptionGroups = useMemo(
|
const issueTypeOptionGroups = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
title: t("jellyseerr.types"),
|
title: t("seerr.types"),
|
||||||
options: Object.entries(IssueTypeName)
|
options: Object.entries(IssueTypeName)
|
||||||
.reverse()
|
.reverse()
|
||||||
.map(([key, value]) => ({
|
.map(([key, value]) => ({
|
||||||
@@ -264,7 +262,7 @@ const Page: React.FC = () => {
|
|||||||
height: "100%",
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
source={{
|
source={{
|
||||||
uri: jellyseerrApi?.imageProxy(
|
uri: seerrApi?.imageProxy(
|
||||||
result.backdropPath,
|
result.backdropPath,
|
||||||
"w1920_and_h800_multi_faces",
|
"w1920_and_h800_multi_faces",
|
||||||
),
|
),
|
||||||
@@ -294,7 +292,7 @@ const Page: React.FC = () => {
|
|||||||
<View className='px-4'>
|
<View className='px-4'>
|
||||||
<View className='flex flex-row justify-between w-full'>
|
<View className='flex flex-row justify-between w-full'>
|
||||||
<View className='flex flex-col w-56'>
|
<View className='flex flex-col w-56'>
|
||||||
<JellyserrRatings
|
<SeerrRatings
|
||||||
result={
|
result={
|
||||||
result as
|
result as
|
||||||
| MovieResult
|
| MovieResult
|
||||||
@@ -329,7 +327,7 @@ const Page: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
) : canRequest ? (
|
) : canRequest ? (
|
||||||
<Button color='purple' onPress={request} className='mt-4'>
|
<Button color='purple' onPress={request} className='mt-4'>
|
||||||
{t("jellyseerr.request_button")}
|
{t("seerr.request_button")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
details?.mediaInfo?.jellyfinMediaId && (
|
details?.mediaInfo?.jellyfinMediaId && (
|
||||||
@@ -352,7 +350,7 @@ const Page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-sm'>
|
<Text className='text-sm'>
|
||||||
{t("jellyseerr.report_issue_button")}
|
{t("seerr.report_issue_button")}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -388,12 +386,12 @@ const Page: React.FC = () => {
|
|||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
<Ionicons name='person-outline' size={16} color='#9CA3AF' />
|
<Ionicons name='person-outline' size={16} color='#9CA3AF' />
|
||||||
<Text className='text-sm text-neutral-400'>
|
<Text className='text-sm text-neutral-400'>
|
||||||
{t("jellyseerr.requested_by", {
|
{t("seerr.requested_by", {
|
||||||
user:
|
user:
|
||||||
pendingRequest.requestedBy?.displayName ||
|
pendingRequest.requestedBy?.displayName ||
|
||||||
pendingRequest.requestedBy?.username ||
|
pendingRequest.requestedBy?.username ||
|
||||||
pendingRequest.requestedBy?.jellyfinUsername ||
|
pendingRequest.requestedBy?.jellyfinUsername ||
|
||||||
t("jellyseerr.unknown_user"),
|
t("seerr.unknown_user"),
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -414,7 +412,7 @@ const Page: React.FC = () => {
|
|||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-sm'>{t("jellyseerr.approve")}</Text>
|
<Text className='text-sm'>{t("seerr.approve")}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
|
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
|
||||||
@@ -432,7 +430,7 @@ const Page: React.FC = () => {
|
|||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-sm'>{t("jellyseerr.decline")}</Text>
|
<Text className='text-sm'>{t("seerr.decline")}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -441,7 +439,7 @@ const Page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{mediaType === MediaType.TV && (
|
{mediaType === MediaType.TV && (
|
||||||
<JellyseerrSeasons
|
<SeerrSeasons
|
||||||
isLoading={isLoading || isFetching}
|
isLoading={isLoading || isFetching}
|
||||||
details={details as TvDetails}
|
details={details as TvDetails}
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
@@ -490,13 +488,13 @@ const Page: React.FC = () => {
|
|||||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
<View>
|
<View>
|
||||||
<Text className='font-bold text-2xl text-neutral-100'>
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
{t("jellyseerr.whats_wrong")}
|
{t("seerr.whats_wrong")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-col space-y-2 items-start'>
|
<View className='flex flex-col space-y-2 items-start'>
|
||||||
<View className='flex flex-col w-full'>
|
<View className='flex flex-col w-full'>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
{t("jellyseerr.issue_type")}
|
{t("seerr.issue_type")}
|
||||||
</Text>
|
</Text>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={issueTypeOptionGroups}
|
groups={issueTypeOptionGroups}
|
||||||
@@ -505,11 +503,11 @@ const Page: React.FC = () => {
|
|||||||
<Text numberOfLines={1}>
|
<Text numberOfLines={1}>
|
||||||
{issueType
|
{issueType
|
||||||
? IssueTypeName[issueType]
|
? IssueTypeName[issueType]
|
||||||
: t("jellyseerr.select_an_issue")}
|
: t("seerr.select_an_issue")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
title={t("jellyseerr.types")}
|
title={t("seerr.types")}
|
||||||
open={issueTypeDropdownOpen}
|
open={issueTypeDropdownOpen}
|
||||||
onOpenChange={setIssueTypeDropdownOpen}
|
onOpenChange={setIssueTypeDropdownOpen}
|
||||||
/>
|
/>
|
||||||
@@ -521,7 +519,7 @@ const Page: React.FC = () => {
|
|||||||
maxLength={254}
|
maxLength={254}
|
||||||
style={{ color: "white" }}
|
style={{ color: "white" }}
|
||||||
clearButtonMode='always'
|
clearButtonMode='always'
|
||||||
placeholder={t("jellyseerr.describe_the_issue")}
|
placeholder={t("seerr.describe_the_issue")}
|
||||||
placeholderTextColor='#9CA3AF'
|
placeholderTextColor='#9CA3AF'
|
||||||
// Issue with multiline + Textinput inside a portal
|
// Issue with multiline + Textinput inside a portal
|
||||||
// https://github.com/callstack/react-native-paper/issues/1668
|
// https://github.com/callstack/react-native-paper/issues/1668
|
||||||
@@ -531,7 +529,7 @@ const Page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Button className='mt-auto' onPress={submitIssue} color='purple'>
|
<Button className='mt-auto' onPress={submitIssue} color='purple'>
|
||||||
{t("jellyseerr.submit_button")}
|
{t("seerr.submit_button")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
</BottomSheetView>
|
||||||
@@ -5,31 +5,27 @@ import { orderBy, uniqBy } from "lodash";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import SeerrPoster from "@/components/posters/SeerrPoster";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
|
||||||
|
import { useSeerr } from "@/hooks/useSeerr";
|
||||||
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||||
|
|
||||||
export default function page() {
|
export default function PersonPage() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const {
|
const { seerrApi, seerrLocale: locale } = useSeerr();
|
||||||
jellyseerrApi,
|
|
||||||
jellyseerrRegion: region,
|
|
||||||
jellyseerrLocale: locale,
|
|
||||||
} = useJellyseerr();
|
|
||||||
|
|
||||||
const { personId } = local as { personId: string };
|
const { personId } = local as { personId: string };
|
||||||
|
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ["jellyseerr", "person", personId],
|
queryKey: ["seerr", "person", personId],
|
||||||
queryFn: async () => ({
|
queryFn: async () => ({
|
||||||
details: await jellyseerrApi?.personDetails(personId),
|
details: await seerrApi?.personDetails(personId),
|
||||||
combinedCredits: await jellyseerrApi?.personCombinedCredits(personId),
|
combinedCredits: await seerrApi?.personCombinedCredits(personId),
|
||||||
}),
|
}),
|
||||||
enabled: !!jellyseerrApi && !!personId,
|
enabled: !!seerrApi && !!personId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const castedRoles: PersonCreditCast[] = useMemo(
|
const castedRoles: PersonCreditCast[] = useMemo(
|
||||||
@@ -46,22 +42,19 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
const backdrops = useMemo(
|
const backdrops = useMemo(
|
||||||
() =>
|
() =>
|
||||||
jellyseerrApi
|
seerrApi
|
||||||
? castedRoles.map((c) =>
|
? castedRoles.map((c) =>
|
||||||
jellyseerrApi.imageProxy(
|
seerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"),
|
||||||
c.backdropPath,
|
|
||||||
"w1920_and_h800_multi_faces",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
[jellyseerrApi, data?.combinedCredits],
|
[seerrApi, data?.combinedCredits],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ParallaxSlideShow
|
<ParallaxSlideShow
|
||||||
data={castedRoles}
|
data={castedRoles}
|
||||||
images={backdrops}
|
images={backdrops}
|
||||||
listHeader={t("jellyseerr.appearances")}
|
listHeader={t("seerr.appearances")}
|
||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
logo={
|
logo={
|
||||||
<Image
|
<Image
|
||||||
@@ -69,7 +62,7 @@ export default function page() {
|
|||||||
id={data?.details?.id.toString()}
|
id={data?.details?.id.toString()}
|
||||||
className='rounded-full bottom-1'
|
className='rounded-full bottom-1'
|
||||||
source={{
|
source={{
|
||||||
uri: jellyseerrApi?.imageProxy(
|
uri: seerrApi?.imageProxy(
|
||||||
data?.details?.profilePath,
|
data?.details?.profilePath,
|
||||||
"w600_and_h600_bestv2",
|
"w600_and_h600_bestv2",
|
||||||
),
|
),
|
||||||
@@ -86,16 +79,13 @@ export default function page() {
|
|||||||
<>
|
<>
|
||||||
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
|
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
|
||||||
<Text className='opacity-50'>
|
<Text className='opacity-50'>
|
||||||
{t("jellyseerr.born")}{" "}
|
{t("seerr.born")}{" "}
|
||||||
{data?.details?.birthday &&
|
{data?.details?.birthday &&
|
||||||
new Date(data.details.birthday).toLocaleDateString(
|
new Date(data.details.birthday).toLocaleDateString(locale, {
|
||||||
`${locale}-${region}`,
|
year: "numeric",
|
||||||
{
|
month: "long",
|
||||||
year: "numeric",
|
day: "numeric",
|
||||||
month: "long",
|
})}{" "}
|
||||||
day: "numeric",
|
|
||||||
},
|
|
||||||
)}{" "}
|
|
||||||
| {data?.details?.placeOfBirth}
|
| {data?.details?.placeOfBirth}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
@@ -103,7 +93,7 @@ export default function page() {
|
|||||||
MainContent={() => (
|
MainContent={() => (
|
||||||
<OverviewText text={data?.details?.biography} className='mt-4' />
|
<OverviewText text={data?.details?.biography} className='mt-4' />
|
||||||
)}
|
)}
|
||||||
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
|
renderItem={(item, _index) => <SeerrPoster item={item} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -14,86 +14,124 @@ import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|||||||
import { NextUp } from "@/components/series/NextUp";
|
import { NextUp } from "@/components/series/NextUp";
|
||||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||||
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
import {
|
||||||
|
buildOfflineSeriesFromEpisodes,
|
||||||
|
getDownloadedEpisodesForSeries,
|
||||||
|
} from "@/utils/downloads/offline-series";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const { id: seriesId, seasonIndex } = params as {
|
const {
|
||||||
|
id: seriesId,
|
||||||
|
seasonIndex,
|
||||||
|
offline: offlineParam,
|
||||||
|
} = params as {
|
||||||
id: string;
|
id: string;
|
||||||
seasonIndex: string;
|
seasonIndex: string;
|
||||||
|
offline?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isOffline = offlineParam === "true";
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
const { getDownloadedItems, downloadedItems } = useDownload();
|
||||||
|
|
||||||
|
// For offline mode, construct series data from downloaded episodes
|
||||||
|
// Include downloadedItems.length so query refetches when items are deleted
|
||||||
const { data: item } = useQuery({
|
const { data: item } = useQuery({
|
||||||
queryKey: ["series", seriesId],
|
queryKey: ["series", seriesId, isOffline, downloadedItems.length],
|
||||||
queryFn: async () =>
|
queryFn: async () => {
|
||||||
await getUserItemData({
|
if (isOffline) {
|
||||||
|
return buildOfflineSeriesFromEpisodes(getDownloadedItems(), seriesId);
|
||||||
|
}
|
||||||
|
return await getUserItemData({
|
||||||
api,
|
api,
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
itemId: seriesId,
|
itemId: seriesId,
|
||||||
}),
|
});
|
||||||
staleTime: 60 * 1000,
|
},
|
||||||
|
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||||
|
enabled: isOffline || (!!api && !!user?.Id),
|
||||||
});
|
});
|
||||||
|
|
||||||
const backdropUrl = useMemo(
|
// For offline mode, use stored base64 image
|
||||||
() =>
|
const base64Image = useMemo(() => {
|
||||||
getBackdropUrl({
|
if (isOffline) {
|
||||||
api,
|
return storage.getString(seriesId);
|
||||||
item,
|
}
|
||||||
quality: 90,
|
return null;
|
||||||
width: 1000,
|
}, [isOffline, seriesId]);
|
||||||
}),
|
|
||||||
[item],
|
|
||||||
);
|
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
const backdropUrl = useMemo(() => {
|
||||||
() =>
|
if (isOffline && base64Image) {
|
||||||
getLogoImageUrlById({
|
return `data:image/jpeg;base64,${base64Image}`;
|
||||||
api,
|
}
|
||||||
item,
|
return getBackdropUrl({
|
||||||
}),
|
api,
|
||||||
[item],
|
item,
|
||||||
);
|
quality: 90,
|
||||||
|
width: 1000,
|
||||||
|
});
|
||||||
|
}, [isOffline, base64Image, api, item]);
|
||||||
|
|
||||||
|
const logoUrl = useMemo(() => {
|
||||||
|
if (isOffline) {
|
||||||
|
return null; // No logo in offline mode
|
||||||
|
}
|
||||||
|
return getLogoImageUrlById({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
});
|
||||||
|
}, [isOffline, api, item]);
|
||||||
|
|
||||||
const { data: allEpisodes, isLoading } = useQuery({
|
const { data: allEpisodes, isLoading } = useQuery({
|
||||||
queryKey: ["AllEpisodes", item?.Id],
|
queryKey: ["AllEpisodes", seriesId, isOffline, downloadedItems.length],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id || !item?.Id) return [];
|
if (isOffline) {
|
||||||
|
return getDownloadedEpisodesForSeries(getDownloadedItems(), seriesId);
|
||||||
|
}
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
const res = await getTvShowsApi(api).getEpisodes({
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
seriesId: item.Id,
|
seriesId: seriesId,
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
enableUserData: true,
|
enableUserData: true,
|
||||||
// Note: Including trick play is necessary to enable trick play downloads
|
|
||||||
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||||
});
|
});
|
||||||
return res?.data.Items || [];
|
return res?.data.Items || [];
|
||||||
},
|
},
|
||||||
select: (data) =>
|
select: (data) =>
|
||||||
// This needs to be sorted by parent index number and then index number, that way we can download the episodes in the correct order.
|
|
||||||
[...(data || [])].sort(
|
[...(data || [])].sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
||||||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
||||||
),
|
),
|
||||||
staleTime: 60,
|
staleTime: isOffline ? Infinity : 60,
|
||||||
enabled: !!api && !!user?.Id && !!item?.Id,
|
enabled: isOffline || (!!api && !!user?.Id),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Don't show header buttons in offline mode
|
||||||
|
if (isOffline) {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
!isLoading &&
|
!isLoading && item && allEpisodes && allEpisodes.length > 0 ? (
|
||||||
item &&
|
|
||||||
allEpisodes &&
|
|
||||||
allEpisodes.length > 0 && (
|
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
<AddToFavorites item={item} />
|
<AddToFavorites item={item} />
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
@@ -114,49 +152,64 @@ const page: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
),
|
) : null,
|
||||||
});
|
});
|
||||||
}, [allEpisodes, isLoading, item]);
|
}, [allEpisodes, isLoading, item, isOffline]);
|
||||||
|
|
||||||
if (!item || !backdropUrl) return null;
|
// For offline mode, we can show the page even without backdropUrl
|
||||||
|
if (!item || (!isOffline && !backdropUrl)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ParallaxScrollView
|
<OfflineModeProvider isOffline={isOffline}>
|
||||||
headerHeight={400}
|
<ParallaxScrollView
|
||||||
headerImage={
|
headerHeight={400}
|
||||||
<Image
|
headerImage={
|
||||||
source={{
|
backdropUrl ? (
|
||||||
uri: backdropUrl,
|
<Image
|
||||||
}}
|
source={{
|
||||||
style={{
|
uri: backdropUrl,
|
||||||
width: "100%",
|
}}
|
||||||
height: "100%",
|
style={{
|
||||||
}}
|
width: "100%",
|
||||||
/>
|
height: "100%",
|
||||||
}
|
}}
|
||||||
logo={
|
/>
|
||||||
logoUrl ? (
|
) : (
|
||||||
<Image
|
<View
|
||||||
source={{
|
style={{
|
||||||
uri: logoUrl,
|
width: "100%",
|
||||||
}}
|
height: "100%",
|
||||||
style={{
|
backgroundColor: "#1a1a1a",
|
||||||
height: 130,
|
}}
|
||||||
width: "100%",
|
/>
|
||||||
}}
|
)
|
||||||
contentFit='contain'
|
}
|
||||||
/>
|
logo={
|
||||||
) : undefined
|
logoUrl ? (
|
||||||
}
|
<Image
|
||||||
>
|
source={{
|
||||||
<View className='flex flex-col pt-4'>
|
uri: logoUrl,
|
||||||
<SeriesHeader item={item} />
|
}}
|
||||||
<View className='mb-4'>
|
style={{
|
||||||
<NextUp seriesId={seriesId} />
|
height: 130,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
contentFit='contain'
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className='flex flex-col pt-4'>
|
||||||
|
<SeriesHeader item={item} />
|
||||||
|
{!isOffline && (
|
||||||
|
<View className='mb-4'>
|
||||||
|
<NextUp seriesId={seriesId} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
|
||||||
</View>
|
</View>
|
||||||
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
|
</ParallaxScrollView>
|
||||||
</View>
|
</OfflineModeProvider>
|
||||||
</ParallaxScrollView>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -50,8 +50,13 @@ import {
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams() as {
|
||||||
const { libraryId } = searchParams as { libraryId: string };
|
libraryId: string;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: string;
|
||||||
|
filterBy?: string;
|
||||||
|
};
|
||||||
|
const { libraryId } = searchParams;
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
@@ -76,23 +81,33 @@ const Page = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
// Check for URL params first (from "See All" navigation)
|
||||||
if (sop) {
|
const urlSortBy = searchParams.sortBy as SortByOption | undefined;
|
||||||
_setSortOrder([sop]);
|
const urlSortOrder = searchParams.sortOrder as SortOrderOption | undefined;
|
||||||
|
const urlFilterBy = searchParams.filterBy as FilterByOption | undefined;
|
||||||
|
|
||||||
|
// Apply sortOrder: URL param > saved preference > default
|
||||||
|
if (urlSortOrder && Object.values(SortOrderOption).includes(urlSortOrder)) {
|
||||||
|
_setSortOrder([urlSortOrder]);
|
||||||
} else {
|
} else {
|
||||||
_setSortOrder([SortOrderOption.Ascending]);
|
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||||
|
_setSortOrder([sop || SortOrderOption.Ascending]);
|
||||||
}
|
}
|
||||||
const obp = getSortByPreference(libraryId, sortByPreference);
|
|
||||||
if (obp) {
|
// Apply sortBy: URL param > saved preference > default
|
||||||
_setSortBy([obp]);
|
if (urlSortBy && Object.values(SortByOption).includes(urlSortBy)) {
|
||||||
|
_setSortBy([urlSortBy]);
|
||||||
} else {
|
} else {
|
||||||
_setSortBy([SortByOption.SortName]);
|
const obp = getSortByPreference(libraryId, sortByPreference);
|
||||||
|
_setSortBy([obp || SortByOption.SortName]);
|
||||||
}
|
}
|
||||||
const fp = getFilterByPreference(libraryId, filterByPreference);
|
|
||||||
if (fp) {
|
// Apply filterBy: URL param > saved preference > default
|
||||||
_setFilterBy([fp]);
|
if (urlFilterBy && Object.values(FilterByOption).includes(urlFilterBy)) {
|
||||||
|
_setFilterBy([urlFilterBy]);
|
||||||
} else {
|
} else {
|
||||||
_setFilterBy([]);
|
const fp = getFilterByPreference(libraryId, filterByPreference);
|
||||||
|
_setFilterBy(fp ? [fp] : []);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
libraryId,
|
libraryId,
|
||||||
@@ -102,6 +117,9 @@ const Page = () => {
|
|||||||
_setSortBy,
|
_setSortBy,
|
||||||
filterByPreference,
|
filterByPreference,
|
||||||
_setFilterBy,
|
_setFilterBy,
|
||||||
|
searchParams.sortBy,
|
||||||
|
searchParams.sortOrder,
|
||||||
|
searchParams.filterBy,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const setSortBy = useCallback(
|
const setSortBy = useCallback(
|
||||||
@@ -191,6 +209,10 @@ const Page = () => {
|
|||||||
itemType = "Series";
|
itemType = "Series";
|
||||||
} else if (library.CollectionType === "boxsets") {
|
} else if (library.CollectionType === "boxsets") {
|
||||||
itemType = "BoxSet";
|
itemType = "BoxSet";
|
||||||
|
} else if (library.CollectionType === "homevideos") {
|
||||||
|
itemType = "Video";
|
||||||
|
} else if (library.CollectionType === "musicvideos") {
|
||||||
|
itemType = "MusicVideo";
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
const { Navigator } = createMaterialTopTabNavigator();
|
const { Navigator } = createMaterialTopTabNavigator();
|
||||||
|
|
||||||
const TAB_LABEL_FONT_SIZE = 13;
|
const TAB_LABEL_FONT_SIZE = 13;
|
||||||
const TAB_ITEM_HORIZONTAL_PADDING = 18;
|
const TAB_ITEM_HORIZONTAL_PADDING = 12;
|
||||||
const TAB_ITEM_MIN_WIDTH = 110;
|
|
||||||
|
|
||||||
export const Tab = withLayoutContext<
|
export const Tab = withLayoutContext<
|
||||||
MaterialTopTabNavigationOptions,
|
MaterialTopTabNavigationOptions,
|
||||||
@@ -48,7 +47,6 @@ const Layout = () => {
|
|||||||
},
|
},
|
||||||
tabBarItemStyle: {
|
tabBarItemStyle: {
|
||||||
width: "auto",
|
width: "auto",
|
||||||
minWidth: TAB_ITEM_MIN_WIDTH,
|
|
||||||
paddingHorizontal: TAB_ITEM_HORIZONTAL_PADDING,
|
paddingHorizontal: TAB_ITEM_HORIZONTAL_PADDING,
|
||||||
},
|
},
|
||||||
tabBarStyle: { backgroundColor: "black" },
|
tabBarStyle: { backgroundColor: "black" },
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import { useLocalSearchParams } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Dimensions, RefreshControl, View } from "react-native";
|
import { RefreshControl, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
|
import { MusicAlbumRowCard } from "@/components/music/MusicAlbumRowCard";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 40;
|
const ITEMS_PER_PAGE = 40;
|
||||||
@@ -65,13 +65,6 @@ export default function AlbumsScreen() {
|
|||||||
return data?.pages.flatMap((page) => page.items) || [];
|
return data?.pages.flatMap((page) => page.items) || [];
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const numColumns = 2;
|
|
||||||
const screenWidth = Dimensions.get("window").width;
|
|
||||||
const gap = 12;
|
|
||||||
const padding = 16;
|
|
||||||
const itemWidth =
|
|
||||||
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
|
|
||||||
|
|
||||||
const handleEndReached = useCallback(() => {
|
const handleEndReached = useCallback(() => {
|
||||||
if (hasNextPage && !isFetchingNextPage) {
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
@@ -98,11 +91,10 @@ export default function AlbumsScreen() {
|
|||||||
<View className='flex-1 bg-black'>
|
<View className='flex-1 bg-black'>
|
||||||
<FlashList
|
<FlashList
|
||||||
data={albums}
|
data={albums}
|
||||||
numColumns={numColumns}
|
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingBottom: insets.bottom + 100,
|
paddingBottom: insets.bottom + 100,
|
||||||
paddingTop: 16,
|
paddingTop: 8,
|
||||||
paddingHorizontal: padding,
|
paddingHorizontal: 16,
|
||||||
}}
|
}}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
@@ -113,17 +105,7 @@ export default function AlbumsScreen() {
|
|||||||
}
|
}
|
||||||
onEndReached={handleEndReached}
|
onEndReached={handleEndReached}
|
||||||
onEndReachedThreshold={0.5}
|
onEndReachedThreshold={0.5}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item }) => <MusicAlbumRowCard album={item} />}
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: itemWidth,
|
|
||||||
marginRight: index % numColumns === 0 ? gap : 0,
|
|
||||||
marginBottom: gap,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MusicAlbumCard album={item} width={itemWidth} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
keyExtractor={(item) => item.Id!}
|
keyExtractor={(item) => item.Id!}
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
isFetchingNextPage ? (
|
isFetchingNextPage ? (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useLocalSearchParams } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Dimensions, RefreshControl, View } from "react-native";
|
import { RefreshControl, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -71,13 +71,6 @@ export default function ArtistsScreen() {
|
|||||||
return data?.pages.flatMap((page) => page.items) || [];
|
return data?.pages.flatMap((page) => page.items) || [];
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const numColumns = 3;
|
|
||||||
const screenWidth = Dimensions.get("window").width;
|
|
||||||
const gap = 12;
|
|
||||||
const padding = 16;
|
|
||||||
const itemWidth =
|
|
||||||
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
|
|
||||||
|
|
||||||
const handleEndReached = useCallback(() => {
|
const handleEndReached = useCallback(() => {
|
||||||
if (hasNextPage && !isFetchingNextPage) {
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
@@ -135,11 +128,10 @@ export default function ArtistsScreen() {
|
|||||||
<View className='flex-1 bg-black'>
|
<View className='flex-1 bg-black'>
|
||||||
<FlashList
|
<FlashList
|
||||||
data={artists}
|
data={artists}
|
||||||
numColumns={numColumns}
|
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingBottom: insets.bottom + 100,
|
paddingBottom: insets.bottom + 100,
|
||||||
paddingTop: 16,
|
paddingTop: 8,
|
||||||
paddingHorizontal: padding,
|
paddingHorizontal: 16,
|
||||||
}}
|
}}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
@@ -150,17 +142,7 @@ export default function ArtistsScreen() {
|
|||||||
}
|
}
|
||||||
onEndReached={handleEndReached}
|
onEndReached={handleEndReached}
|
||||||
onEndReachedThreshold={0.5}
|
onEndReachedThreshold={0.5}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item }) => <MusicArtistCard artist={item} />}
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: itemWidth,
|
|
||||||
marginRight: index % numColumns !== numColumns - 1 ? gap : 0,
|
|
||||||
marginBottom: gap,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MusicArtistCard artist={item} size={itemWidth} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
keyExtractor={(item) => item.Id!}
|
keyExtractor={(item) => item.Id!}
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
isFetchingNextPage ? (
|
isFetchingNextPage ? (
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ import { useLocalSearchParams } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
|
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { RefreshControl, TouchableOpacity, View } from "react-native";
|
||||||
Dimensions,
|
|
||||||
RefreshControl,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||||
import { MusicPlaylistCard } from "@/components/music/MusicPlaylistCard";
|
import { MusicPlaylistCard } from "@/components/music/MusicPlaylistCard";
|
||||||
|
import {
|
||||||
|
type PlaylistSortOption,
|
||||||
|
type PlaylistSortOrder,
|
||||||
|
PlaylistSortSheet,
|
||||||
|
} from "@/components/music/PlaylistSortSheet";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 40;
|
const ITEMS_PER_PAGE = 40;
|
||||||
@@ -36,9 +36,20 @@ export default function PlaylistsScreen() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
const [sortSheetOpen, setSortSheetOpen] = useState(false);
|
||||||
|
const [sortBy, setSortBy] = useState<PlaylistSortOption>("SortName");
|
||||||
|
const [sortOrder, setSortOrder] = useState<PlaylistSortOrder>("Ascending");
|
||||||
|
|
||||||
const isReady = Boolean(api && user?.Id && libraryId);
|
const isReady = Boolean(api && user?.Id && libraryId);
|
||||||
|
|
||||||
|
const handleSortChange = useCallback(
|
||||||
|
(newSortBy: PlaylistSortOption, newSortOrder: PlaylistSortOrder) => {
|
||||||
|
setSortBy(newSortBy);
|
||||||
|
setSortOrder(newSortOrder);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
@@ -63,13 +74,13 @@ export default function PlaylistsScreen() {
|
|||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
refetch,
|
refetch,
|
||||||
} = useInfiniteQuery({
|
} = useInfiniteQuery({
|
||||||
queryKey: ["music-playlists", libraryId, user?.Id],
|
queryKey: ["music-playlists", libraryId, user?.Id, sortBy, sortOrder],
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
const response = await getItemsApi(api!).getItems({
|
const response = await getItemsApi(api!).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
includeItemTypes: ["Playlist"],
|
includeItemTypes: ["Playlist"],
|
||||||
sortBy: ["SortName"],
|
sortBy: [sortBy],
|
||||||
sortOrder: ["Ascending"],
|
sortOrder: [sortOrder],
|
||||||
limit: ITEMS_PER_PAGE,
|
limit: ITEMS_PER_PAGE,
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
@@ -93,13 +104,6 @@ export default function PlaylistsScreen() {
|
|||||||
return data?.pages.flatMap((page) => page.items) || [];
|
return data?.pages.flatMap((page) => page.items) || [];
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const numColumns = 2;
|
|
||||||
const screenWidth = Dimensions.get("window").width;
|
|
||||||
const gap = 12;
|
|
||||||
const padding = 16;
|
|
||||||
const itemWidth =
|
|
||||||
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
|
|
||||||
|
|
||||||
const handleEndReached = useCallback(() => {
|
const handleEndReached = useCallback(() => {
|
||||||
if (hasNextPage && !isFetchingNextPage) {
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
@@ -171,11 +175,10 @@ export default function PlaylistsScreen() {
|
|||||||
<View className='flex-1 bg-black'>
|
<View className='flex-1 bg-black'>
|
||||||
<FlashList
|
<FlashList
|
||||||
data={playlists}
|
data={playlists}
|
||||||
numColumns={numColumns}
|
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingBottom: insets.bottom + 100,
|
paddingBottom: insets.bottom + 100,
|
||||||
paddingTop: 16,
|
paddingTop: 8,
|
||||||
paddingHorizontal: padding,
|
paddingHorizontal: 16,
|
||||||
}}
|
}}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
@@ -186,17 +189,26 @@ export default function PlaylistsScreen() {
|
|||||||
}
|
}
|
||||||
onEndReached={handleEndReached}
|
onEndReached={handleEndReached}
|
||||||
onEndReachedThreshold={0.5}
|
onEndReachedThreshold={0.5}
|
||||||
renderItem={({ item, index }) => (
|
ListHeaderComponent={
|
||||||
<View
|
<TouchableOpacity
|
||||||
style={{
|
onPress={() => setSortSheetOpen(true)}
|
||||||
width: itemWidth,
|
className='flex-row items-center mb-2 py-1'
|
||||||
marginRight: index % numColumns === 0 ? gap : 0,
|
|
||||||
marginBottom: gap,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<MusicPlaylistCard playlist={item} width={itemWidth} />
|
<Ionicons name='swap-vertical' size={18} color='#9334E9' />
|
||||||
</View>
|
<Text className='text-purple-500 text-sm ml-1.5'>
|
||||||
)}
|
{t(
|
||||||
|
`music.sort.${sortBy === "SortName" ? "alphabetical" : "date_created"}`,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name={sortOrder === "Ascending" ? "arrow-up" : "arrow-down"}
|
||||||
|
size={14}
|
||||||
|
color='#9334E9'
|
||||||
|
style={{ marginLeft: 4 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => <MusicPlaylistCard playlist={item} />}
|
||||||
keyExtractor={(item) => item.Id!}
|
keyExtractor={(item) => item.Id!}
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
isFetchingNextPage ? (
|
isFetchingNextPage ? (
|
||||||
@@ -210,6 +222,13 @@ export default function PlaylistsScreen() {
|
|||||||
open={createModalOpen}
|
open={createModalOpen}
|
||||||
setOpen={setCreateModalOpen}
|
setOpen={setCreateModalOpen}
|
||||||
/>
|
/>
|
||||||
|
<PlaylistSortSheet
|
||||||
|
open={sortSheetOpen}
|
||||||
|
setOpen={setSortSheetOpen}
|
||||||
|
sortBy={sortBy}
|
||||||
|
sortOrder={sortOrder}
|
||||||
|
onSortChange={handleSortChange}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ export default function SuggestionsScreen() {
|
|||||||
{section.type === "albums" ? (
|
{section.type === "albums" ? (
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={section.data}
|
data={section.data}
|
||||||
height={200}
|
height={178}
|
||||||
keyExtractor={(item) => item.Id!}
|
keyExtractor={(item) => item.Id!}
|
||||||
renderItem={(item) => <MusicAlbumCard album={item} />}
|
renderItem={(item) => <MusicAlbumCard album={item} />}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -33,17 +33,17 @@ export default function SearchLayout() {
|
|||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name='jellyseerr/page' options={commonScreenOptions} />
|
<Stack.Screen name='seerr/page' options={commonScreenOptions} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='jellyseerr/person/[personId]'
|
name='seerr/person/[personId]'
|
||||||
options={commonScreenOptions}
|
options={commonScreenOptions}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='jellyseerr/company/[companyId]'
|
name='seerr/company/[companyId]'
|
||||||
options={commonScreenOptions}
|
options={commonScreenOptions}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='jellyseerr/genre/[genreId]'
|
name='seerr/genre/[genreId]'
|
||||||
options={commonScreenOptions}
|
options={commonScreenOptions}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import type {
|
|||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useAsyncDebouncer } from "@tanstack/react-pacer";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -19,26 +21,27 @@ import {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useDebounce } from "use-debounce";
|
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import {
|
|
||||||
JellyseerrSearchSort,
|
|
||||||
JellyserrIndexPage,
|
|
||||||
} from "@/components/jellyseerr/JellyseerrIndexPage";
|
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||||
import { DiscoverFilters } from "@/components/search/DiscoverFilters";
|
import { DiscoverFilters } from "@/components/search/DiscoverFilters";
|
||||||
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||||
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||||
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import {
|
||||||
|
SeerrIndexPage,
|
||||||
|
SeerrSearchSort,
|
||||||
|
} from "@/components/seerr/SeerrIndexPage";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useSeerr } from "@/hooks/useSeerr";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { createStreamystatsApi } from "@/utils/streamystats";
|
import { createStreamystatsApi } from "@/utils/streamystats";
|
||||||
|
|
||||||
type SearchType = "Library" | "Discover";
|
type SearchType = "Library" | "Discover";
|
||||||
@@ -52,9 +55,10 @@ const exampleSearches = [
|
|||||||
"The Mandalorian",
|
"The Mandalorian",
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function search() {
|
export default function SearchPage() {
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
@@ -68,21 +72,32 @@ export default function search() {
|
|||||||
const [searchType, setSearchType] = useState<SearchType>("Library");
|
const [searchType, setSearchType] = useState<SearchType>("Library");
|
||||||
const [search, setSearch] = useState<string>("");
|
const [search, setSearch] = useState<string>("");
|
||||||
|
|
||||||
const [debouncedSearch] = useDebounce(search, 500);
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const searchDebouncer = useAsyncDebouncer(
|
||||||
|
async (query: string) => {
|
||||||
|
// Cancel previous in-flight requests
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
setDebouncedSearch(query);
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
{ wait: 200 },
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
searchDebouncer.maybeExecute(search);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { seerrApi } = useSeerr();
|
||||||
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
const [seerrOrderBy, setSeerrOrderBy] = useState<SeerrSearchSort>(
|
||||||
useState<JellyseerrSearchSort>(
|
SeerrSearchSort[SeerrSearchSort.DEFAULT] as unknown as SeerrSearchSort,
|
||||||
JellyseerrSearchSort[
|
);
|
||||||
JellyseerrSearchSort.DEFAULT
|
const [seerrSortOrder, setSeerrSortOrder] = useState<"asc" | "desc">("desc");
|
||||||
] as unknown as JellyseerrSearchSort,
|
|
||||||
);
|
|
||||||
const [jellyseerrSortOrder, setJellyseerrSortOrder] = useState<
|
|
||||||
"asc" | "desc"
|
|
||||||
>("desc");
|
|
||||||
|
|
||||||
const searchEngine = useMemo(() => {
|
const searchEngine = useMemo(() => {
|
||||||
return settings?.searchEngine || "Jellyfin";
|
return settings?.searchEngine || "Jellyfin";
|
||||||
@@ -98,9 +113,11 @@ export default function search() {
|
|||||||
async ({
|
async ({
|
||||||
types,
|
types,
|
||||||
query,
|
query,
|
||||||
|
signal,
|
||||||
}: {
|
}: {
|
||||||
types: BaseItemKind[];
|
types: BaseItemKind[];
|
||||||
query: string;
|
query: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
}): Promise<BaseItemDto[]> => {
|
}): Promise<BaseItemDto[]> => {
|
||||||
if (!api || !query) {
|
if (!api || !query) {
|
||||||
return [];
|
return [];
|
||||||
@@ -108,13 +125,16 @@ export default function search() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (searchEngine === "Jellyfin") {
|
if (searchEngine === "Jellyfin") {
|
||||||
const searchApi = await getItemsApi(api).getItems({
|
const searchApi = await getItemsApi(api).getItems(
|
||||||
searchTerm: query,
|
{
|
||||||
limit: 10,
|
searchTerm: query,
|
||||||
includeItemTypes: types,
|
limit: 10,
|
||||||
recursive: true,
|
includeItemTypes: types,
|
||||||
userId: user?.Id,
|
recursive: true,
|
||||||
});
|
userId: user?.Id,
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
return (searchApi.data.Items as BaseItemDto[]) || [];
|
return (searchApi.data.Items as BaseItemDto[]) || [];
|
||||||
}
|
}
|
||||||
@@ -143,6 +163,7 @@ export default function search() {
|
|||||||
query,
|
query,
|
||||||
searchType as "movies" | "series" | "episodes" | "actors" | "media",
|
searchType as "movies" | "series" | "episodes" | "actors" | "media",
|
||||||
10,
|
10,
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
|
|
||||||
const allIds: string[] = [
|
const allIds: string[] = [
|
||||||
@@ -157,10 +178,13 @@ export default function search() {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemsResponse = await getItemsApi(api).getItems({
|
const itemsResponse = await getItemsApi(api).getItems(
|
||||||
ids: allIds,
|
{
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
ids: allIds,
|
||||||
});
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
return (itemsResponse.data.Items as BaseItemDto[]) || [];
|
return (itemsResponse.data.Items as BaseItemDto[]) || [];
|
||||||
}
|
}
|
||||||
@@ -176,7 +200,7 @@ export default function search() {
|
|||||||
.map((type) => encodeURIComponent(type))
|
.map((type) => encodeURIComponent(type))
|
||||||
.join("&includeItemTypes=")}`;
|
.join("&includeItemTypes=")}`;
|
||||||
|
|
||||||
const response1 = await axios.get(url);
|
const response1 = await axios.get(url, { signal });
|
||||||
|
|
||||||
const ids = response1.data.ids;
|
const ids = response1.data.ids;
|
||||||
|
|
||||||
@@ -184,19 +208,65 @@ export default function search() {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const response2 = await getItemsApi(api).getItems({
|
const response2 = await getItemsApi(api).getItems(
|
||||||
ids,
|
{
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
ids,
|
||||||
});
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
return (response2.data.Items as BaseItemDto[]) || [];
|
return (response2.data.Items as BaseItemDto[]) || [];
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
|
// Silently handle aborted requests
|
||||||
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, searchEngine, settings, user?.Id],
|
[api, searchEngine, settings, user?.Id],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Separate search function for music types - always uses Jellyfin since Streamystats doesn't support music
|
||||||
|
const jellyfinSearchFn = useCallback(
|
||||||
|
async ({
|
||||||
|
types,
|
||||||
|
query,
|
||||||
|
signal,
|
||||||
|
}: {
|
||||||
|
types: BaseItemKind[];
|
||||||
|
query: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}): Promise<BaseItemDto[]> => {
|
||||||
|
if (!api || !query) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const searchApi = await getItemsApi(api).getItems(
|
||||||
|
{
|
||||||
|
searchTerm: query,
|
||||||
|
limit: 10,
|
||||||
|
includeItemTypes: types,
|
||||||
|
recursive: true,
|
||||||
|
userId: user?.Id,
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (searchApi.data.Items as BaseItemDto[]) || [];
|
||||||
|
} catch (error) {
|
||||||
|
// Silently handle aborted requests
|
||||||
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, user?.Id],
|
||||||
|
);
|
||||||
|
|
||||||
type HeaderSearchBarRef = {
|
type HeaderSearchBarRef = {
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
blur: () => void;
|
blur: () => void;
|
||||||
@@ -243,6 +313,7 @@ export default function search() {
|
|||||||
searchFn({
|
searchFn({
|
||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["Movie"],
|
types: ["Movie"],
|
||||||
|
signal: abortControllerRef.current?.signal,
|
||||||
}),
|
}),
|
||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
@@ -253,6 +324,7 @@ export default function search() {
|
|||||||
searchFn({
|
searchFn({
|
||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["Series"],
|
types: ["Series"],
|
||||||
|
signal: abortControllerRef.current?.signal,
|
||||||
}),
|
}),
|
||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
@@ -263,6 +335,7 @@ export default function search() {
|
|||||||
searchFn({
|
searchFn({
|
||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["Episode"],
|
types: ["Episode"],
|
||||||
|
signal: abortControllerRef.current?.signal,
|
||||||
}),
|
}),
|
||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
@@ -273,6 +346,7 @@ export default function search() {
|
|||||||
searchFn({
|
searchFn({
|
||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["BoxSet"],
|
types: ["BoxSet"],
|
||||||
|
signal: abortControllerRef.current?.signal,
|
||||||
}),
|
}),
|
||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
@@ -283,6 +357,52 @@ export default function search() {
|
|||||||
searchFn({
|
searchFn({
|
||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["Person"],
|
types: ["Person"],
|
||||||
|
signal: abortControllerRef.current?.signal,
|
||||||
|
}),
|
||||||
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Music search queries - always use Jellyfin since Streamystats doesn't support music
|
||||||
|
const { data: artists, isFetching: l9 } = useQuery({
|
||||||
|
queryKey: ["search", "artists", debouncedSearch],
|
||||||
|
queryFn: () =>
|
||||||
|
jellyfinSearchFn({
|
||||||
|
query: debouncedSearch,
|
||||||
|
types: ["MusicArtist"],
|
||||||
|
signal: abortControllerRef.current?.signal,
|
||||||
|
}),
|
||||||
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: albums, isFetching: l10 } = useQuery({
|
||||||
|
queryKey: ["search", "albums", debouncedSearch],
|
||||||
|
queryFn: () =>
|
||||||
|
jellyfinSearchFn({
|
||||||
|
query: debouncedSearch,
|
||||||
|
types: ["MusicAlbum"],
|
||||||
|
signal: abortControllerRef.current?.signal,
|
||||||
|
}),
|
||||||
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: songs, isFetching: l11 } = useQuery({
|
||||||
|
queryKey: ["search", "songs", debouncedSearch],
|
||||||
|
queryFn: () =>
|
||||||
|
jellyfinSearchFn({
|
||||||
|
query: debouncedSearch,
|
||||||
|
types: ["Audio"],
|
||||||
|
signal: abortControllerRef.current?.signal,
|
||||||
|
}),
|
||||||
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: playlists, isFetching: l12 } = useQuery({
|
||||||
|
queryKey: ["search", "playlists", debouncedSearch],
|
||||||
|
queryFn: () =>
|
||||||
|
jellyfinSearchFn({
|
||||||
|
query: debouncedSearch,
|
||||||
|
types: ["Playlist"],
|
||||||
|
signal: abortControllerRef.current?.signal,
|
||||||
}),
|
}),
|
||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
@@ -293,13 +413,27 @@ export default function search() {
|
|||||||
episodes?.length ||
|
episodes?.length ||
|
||||||
series?.length ||
|
series?.length ||
|
||||||
collections?.length ||
|
collections?.length ||
|
||||||
actors?.length
|
actors?.length ||
|
||||||
|
artists?.length ||
|
||||||
|
albums?.length ||
|
||||||
|
songs?.length ||
|
||||||
|
playlists?.length
|
||||||
);
|
);
|
||||||
}, [episodes, movies, series, collections, actors]);
|
}, [
|
||||||
|
episodes,
|
||||||
|
movies,
|
||||||
|
series,
|
||||||
|
collections,
|
||||||
|
actors,
|
||||||
|
artists,
|
||||||
|
albums,
|
||||||
|
songs,
|
||||||
|
playlists,
|
||||||
|
]);
|
||||||
|
|
||||||
const loading = useMemo(() => {
|
const loading = useMemo(() => {
|
||||||
return l1 || l2 || l3 || l7 || l8;
|
return l1 || l2 || l3 || l7 || l8 || l9 || l10 || l11 || l12;
|
||||||
}, [l1, l2, l3, l7, l8]);
|
}, [l1, l2, l3, l7, l8, l9, l10, l11, l12]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -308,6 +442,7 @@ export default function search() {
|
|||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 60,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* <View
|
{/* <View
|
||||||
@@ -334,7 +469,7 @@ export default function search() {
|
|||||||
className='flex flex-col'
|
className='flex flex-col'
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
{jellyseerrApi && (
|
{seerrApi && (
|
||||||
<View className='pl-4 pr-4 flex flex-row'>
|
<View className='pl-4 pr-4 flex flex-row'>
|
||||||
<SearchTabButtons
|
<SearchTabButtons
|
||||||
searchType={searchType}
|
searchType={searchType}
|
||||||
@@ -348,10 +483,10 @@ export default function search() {
|
|||||||
<DiscoverFilters
|
<DiscoverFilters
|
||||||
searchFilterId={searchFilterId}
|
searchFilterId={searchFilterId}
|
||||||
orderFilterId={orderFilterId}
|
orderFilterId={orderFilterId}
|
||||||
jellyseerrOrderBy={jellyseerrOrderBy}
|
seerrOrderBy={seerrOrderBy}
|
||||||
setJellyseerrOrderBy={setJellyseerrOrderBy}
|
setSeerrOrderBy={setSeerrOrderBy}
|
||||||
jellyseerrSortOrder={jellyseerrSortOrder}
|
seerrSortOrder={seerrSortOrder}
|
||||||
setJellyseerrSortOrder={setJellyseerrSortOrder}
|
setSeerrSortOrder={setSeerrSortOrder}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -446,12 +581,178 @@ export default function search() {
|
|||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{/* Music search results */}
|
||||||
|
<SearchItemWrapper
|
||||||
|
items={artists}
|
||||||
|
header={t("search.artists")}
|
||||||
|
renderItem={(item: BaseItemDto) => {
|
||||||
|
const imageUrl = getPrimaryImageUrl({ api, item });
|
||||||
|
return (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
key={item.Id}
|
||||||
|
className='flex flex-col w-24 mr-2 items-center'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||||
|
<Text className='text-xl'>👤</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text numberOfLines={2} className='mt-2 text-center'>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
items={albums}
|
||||||
|
header={t("search.albums")}
|
||||||
|
renderItem={(item: BaseItemDto) => {
|
||||||
|
const imageUrl = getPrimaryImageUrl({ api, item });
|
||||||
|
return (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
key={item.Id}
|
||||||
|
className='flex flex-col w-28 mr-2'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 112,
|
||||||
|
height: 112,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||||
|
<Text className='text-4xl'>🎵</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text numberOfLines={2} className='mt-2'>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='opacity-50 text-xs' numberOfLines={1}>
|
||||||
|
{item.AlbumArtist || item.Artists?.join(", ")}
|
||||||
|
</Text>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
items={songs}
|
||||||
|
header={t("search.songs")}
|
||||||
|
renderItem={(item: BaseItemDto) => {
|
||||||
|
const imageUrl = getPrimaryImageUrl({ api, item });
|
||||||
|
return (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
key={item.Id}
|
||||||
|
className='flex flex-col w-28 mr-2'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 112,
|
||||||
|
height: 112,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||||
|
<Text className='text-4xl'>🎵</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text numberOfLines={2} className='mt-2'>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='opacity-50 text-xs' numberOfLines={1}>
|
||||||
|
{item.Artists?.join(", ") || item.AlbumArtist}
|
||||||
|
</Text>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
items={playlists}
|
||||||
|
header={t("search.playlists")}
|
||||||
|
renderItem={(item: BaseItemDto) => {
|
||||||
|
const imageUrl = getPrimaryImageUrl({ api, item });
|
||||||
|
return (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
key={item.Id}
|
||||||
|
className='flex flex-col w-28 mr-2'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 112,
|
||||||
|
height: 112,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||||
|
<Text className='text-4xl'>🎶</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text numberOfLines={2} className='mt-2'>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='opacity-50 text-xs'>
|
||||||
|
{item.ChildCount} tracks
|
||||||
|
</Text>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<JellyserrIndexPage
|
<SeerrIndexPage
|
||||||
searchQuery={debouncedSearch}
|
searchQuery={debouncedSearch}
|
||||||
sortType={jellyseerrOrderBy}
|
sortType={seerrOrderBy}
|
||||||
order={jellyseerrSortOrder}
|
order={seerrSortOrder}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -19,6 +19,7 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import {
|
import {
|
||||||
useDeleteWatchlist,
|
useDeleteWatchlist,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
|
import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
|
||||||
|
|
||||||
export default function WatchlistsLayout() {
|
export default function WatchlistsLayout() {
|
||||||
@@ -22,14 +24,14 @@ export default function WatchlistsLayout() {
|
|||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: streamystatsEnabled
|
headerRight: streamystatsEnabled
|
||||||
? () => (
|
? () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
router.push("/(auth)/(tabs)/(watchlists)/create")
|
router.push("/(auth)/(tabs)/(watchlists)/create")
|
||||||
}
|
}
|
||||||
className='p-1.5'
|
className='p-1.5'
|
||||||
>
|
>
|
||||||
<Ionicons name='add' size={24} color='white' />
|
<Ionicons name='add' size={24} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
)
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +14,7 @@ import {
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useCreateWatchlist } from "@/hooks/useWatchlistMutations";
|
import { useCreateWatchlist } from "@/hooks/useWatchlistMutations";
|
||||||
import type {
|
import type {
|
||||||
StreamystatsWatchlistAllowedItemType,
|
StreamystatsWatchlistAllowedItemType,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useUpdateWatchlist } from "@/hooks/useWatchlistMutations";
|
import { useUpdateWatchlist } from "@/hooks/useWatchlistMutations";
|
||||||
import { useWatchlistDetailQuery } from "@/hooks/useWatchlists";
|
import { useWatchlistDetailQuery } from "@/hooks/useWatchlists";
|
||||||
import type {
|
import type {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -8,6 +7,7 @@ import { Platform, RefreshControl, TouchableOpacity, View } from "react-native";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import {
|
import {
|
||||||
useStreamystatsEnabled,
|
useStreamystatsEnabled,
|
||||||
useWatchlistsQuery,
|
useWatchlistsQuery,
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import type {
|
|||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "@react-navigation/native";
|
} from "@react-navigation/native";
|
||||||
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
import { withLayoutContext } from "expo-router";
|
||||||
import { useCallback } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
@@ -17,7 +16,6 @@ import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
|
|||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
|
|
||||||
const { Navigator } = createNativeBottomTabNavigator();
|
const { Navigator } = createNativeBottomTabNavigator();
|
||||||
|
|
||||||
@@ -31,22 +29,6 @@ export const NativeTabs = withLayoutContext<
|
|||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
|
||||||
if (!hasShownIntro) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
router.push("/intro/page");
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, []),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
|
import { ExpoAvRoutePickerView } from "@douglowder/expo-av-route-picker-view";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
@@ -20,11 +27,20 @@ import DraggableFlatList, {
|
|||||||
type RenderItemParams,
|
type RenderItemParams,
|
||||||
ScaleDecorator,
|
ScaleDecorator,
|
||||||
} from "react-native-draggable-flatlist";
|
} from "react-native-draggable-flatlist";
|
||||||
|
import { CastButton, CastState } from "react-native-google-cast";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import TextTicker from "react-native-text-ticker";
|
||||||
|
import type { VolumeResult } from "react-native-volume-manager";
|
||||||
import { Badge } from "@/components/Badge";
|
import { Badge } from "@/components/Badge";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||||
|
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||||
|
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
|
import { useMusicCast } from "@/hooks/useMusicCast";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
type RepeatMode,
|
type RepeatMode,
|
||||||
useMusicPlayer,
|
useMusicPlayer,
|
||||||
@@ -32,6 +48,11 @@ import {
|
|||||||
import { formatBitrate } from "@/utils/bitrate";
|
import { formatBitrate } from "@/utils/bitrate";
|
||||||
import { formatDuration } from "@/utils/time";
|
import { formatDuration } from "@/utils/time";
|
||||||
|
|
||||||
|
// Conditionally require VolumeManager (not available on TV)
|
||||||
|
const VolumeManager = Platform.isTV
|
||||||
|
? null
|
||||||
|
: require("react-native-volume-manager");
|
||||||
|
|
||||||
const formatFileSize = (bytes?: number | null) => {
|
const formatFileSize = (bytes?: number | null) => {
|
||||||
if (!bytes) return null;
|
if (!bytes) return null;
|
||||||
const sizes = ["B", "KB", "MB", "GB"];
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
@@ -52,9 +73,22 @@ type ViewMode = "player" | "queue";
|
|||||||
|
|
||||||
export default function NowPlayingScreen() {
|
export default function NowPlayingScreen() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("player");
|
const [viewMode, setViewMode] = useState<ViewMode>("player");
|
||||||
|
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
||||||
|
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||||
|
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isConnected: isCastConnected,
|
||||||
|
castQueue,
|
||||||
|
castState,
|
||||||
|
} = useMusicCast({
|
||||||
|
api,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
currentTrack,
|
currentTrack,
|
||||||
@@ -78,8 +112,13 @@ export default function NowPlayingScreen() {
|
|||||||
removeFromQueue,
|
removeFromQueue,
|
||||||
reorderQueue,
|
reorderQueue,
|
||||||
stop,
|
stop,
|
||||||
|
pause,
|
||||||
} = useMusicPlayer();
|
} = useMusicPlayer();
|
||||||
|
|
||||||
|
const { isFavorite, toggleFavorite } = useFavorite(
|
||||||
|
currentTrack ?? ({ Id: "" } as BaseItemDto),
|
||||||
|
);
|
||||||
|
|
||||||
const sliderProgress = useSharedValue(0);
|
const sliderProgress = useSharedValue(0);
|
||||||
const sliderMin = useSharedValue(0);
|
const sliderMin = useSharedValue(0);
|
||||||
const sliderMax = useSharedValue(1);
|
const sliderMax = useSharedValue(1);
|
||||||
@@ -92,6 +131,21 @@ export default function NowPlayingScreen() {
|
|||||||
sliderMax.value = duration > 0 ? duration : 1;
|
sliderMax.value = duration > 0 ? duration : 1;
|
||||||
}, [duration, sliderMax]);
|
}, [duration, sliderMax]);
|
||||||
|
|
||||||
|
// Auto-cast queue when Chromecast becomes connected and pause local playback
|
||||||
|
const prevCastState = useRef<CastState | null | undefined>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
castState === CastState.CONNECTED &&
|
||||||
|
prevCastState.current !== CastState.CONNECTED &&
|
||||||
|
queue.length > 0
|
||||||
|
) {
|
||||||
|
// Just connected - pause local playback and cast the queue
|
||||||
|
pause();
|
||||||
|
castQueue({ queue, startIndex: queueIndex });
|
||||||
|
}
|
||||||
|
prevCastState.current = castState;
|
||||||
|
}, [castState, queue, queueIndex, castQueue, pause]);
|
||||||
|
|
||||||
const imageUrl = useMemo(() => {
|
const imageUrl = useMemo(() => {
|
||||||
if (!api || !currentTrack) return null;
|
if (!api || !currentTrack) return null;
|
||||||
const albumId = currentTrack.AlbumId || currentTrack.ParentId;
|
const albumId = currentTrack.AlbumId || currentTrack.ParentId;
|
||||||
@@ -106,11 +160,17 @@ export default function NowPlayingScreen() {
|
|||||||
return formatDuration(progressTicks);
|
return formatDuration(progressTicks);
|
||||||
}, [progress]);
|
}, [progress]);
|
||||||
|
|
||||||
const durationText = useMemo(() => {
|
const _durationText = useMemo(() => {
|
||||||
const durationTicks = duration * 10000000;
|
const durationTicks = duration * 10000000;
|
||||||
return formatDuration(durationTicks);
|
return formatDuration(durationTicks);
|
||||||
}, [duration]);
|
}, [duration]);
|
||||||
|
|
||||||
|
const remainingText = useMemo(() => {
|
||||||
|
const remaining = Math.max(0, duration - progress);
|
||||||
|
const remainingTicks = remaining * 10000000;
|
||||||
|
return `-${formatDuration(remainingTicks)}`;
|
||||||
|
}, [duration, progress]);
|
||||||
|
|
||||||
const handleSliderComplete = useCallback(
|
const handleSliderComplete = useCallback(
|
||||||
(value: number) => {
|
(value: number) => {
|
||||||
seek(value);
|
seek(value);
|
||||||
@@ -134,6 +194,18 @@ export default function NowPlayingScreen() {
|
|||||||
setRepeatMode(nextMode);
|
setRepeatMode(nextMode);
|
||||||
}, [repeatMode, setRepeatMode]);
|
}, [repeatMode, setRepeatMode]);
|
||||||
|
|
||||||
|
const handleOptionsPress = useCallback(() => {
|
||||||
|
setTrackOptionsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddToPlaylist = useCallback(() => {
|
||||||
|
setPlaylistPickerOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateNewPlaylist = useCallback(() => {
|
||||||
|
setCreatePlaylistOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const getRepeatIcon = (): string => {
|
const getRepeatIcon = (): string => {
|
||||||
switch (repeatMode) {
|
switch (repeatMode) {
|
||||||
case "one":
|
case "one":
|
||||||
@@ -150,108 +222,133 @@ export default function NowPlayingScreen() {
|
|||||||
|
|
||||||
if (!currentTrack) {
|
if (!currentTrack) {
|
||||||
return (
|
return (
|
||||||
|
<BottomSheetModalProvider>
|
||||||
|
<View
|
||||||
|
className='flex-1 bg-[#121212] items-center justify-center'
|
||||||
|
style={{
|
||||||
|
paddingTop: Platform.OS === "android" ? insets.top : 0,
|
||||||
|
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-neutral-500'>No track playing</Text>
|
||||||
|
</View>
|
||||||
|
</BottomSheetModalProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModalProvider>
|
||||||
<View
|
<View
|
||||||
className='flex-1 bg-[#121212] items-center justify-center'
|
className='flex-1 bg-[#121212]'
|
||||||
style={{
|
style={{
|
||||||
paddingTop: Platform.OS === "android" ? insets.top : 0,
|
paddingTop: Platform.OS === "android" ? insets.top : 0,
|
||||||
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-neutral-500'>No track playing</Text>
|
{/* Header */}
|
||||||
</View>
|
<View className='flex-row items-center justify-between px-4 pt-3 pb-2'>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className='flex-1 bg-[#121212]'
|
|
||||||
style={{
|
|
||||||
paddingTop: Platform.OS === "android" ? insets.top : 0,
|
|
||||||
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<View className='flex-row items-center justify-between px-4 pt-3 pb-2'>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleClose}
|
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
||||||
className='p-2'
|
|
||||||
>
|
|
||||||
<Ionicons name='chevron-down' size={28} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<View className='flex-row'>
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => setViewMode("player")}
|
onPress={handleClose}
|
||||||
className='px-3 py-1'
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
className='p-2'
|
||||||
>
|
>
|
||||||
<Text
|
<Ionicons name='chevron-down' size={28} color='white' />
|
||||||
className={
|
|
||||||
viewMode === "player"
|
|
||||||
? "text-white font-semibold"
|
|
||||||
: "text-neutral-500"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Now Playing
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setViewMode("queue")}
|
<View className='flex-row'>
|
||||||
className='px-3 py-1'
|
<TouchableOpacity
|
||||||
>
|
onPress={() => setViewMode("player")}
|
||||||
<Text
|
className='px-3 py-1'
|
||||||
className={
|
|
||||||
viewMode === "queue"
|
|
||||||
? "text-white font-semibold"
|
|
||||||
: "text-neutral-500"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Queue ({queue.length})
|
<Text
|
||||||
</Text>
|
className={
|
||||||
</TouchableOpacity>
|
viewMode === "player"
|
||||||
|
? "text-white font-semibold"
|
||||||
|
: "text-neutral-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Now Playing
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setViewMode("queue")}
|
||||||
|
className='px-3 py-1'
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
viewMode === "queue"
|
||||||
|
? "text-white font-semibold"
|
||||||
|
: "text-neutral-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Queue ({queue.length})
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{/* Empty placeholder to balance header layout */}
|
||||||
|
<View className='p-2' style={{ width: 44 }} />
|
||||||
</View>
|
</View>
|
||||||
<View style={{ width: 16 }} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{viewMode === "player" ? (
|
{viewMode === "player" ? (
|
||||||
<PlayerView
|
<PlayerView
|
||||||
api={api}
|
api={api}
|
||||||
currentTrack={currentTrack}
|
currentTrack={currentTrack}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
sliderProgress={sliderProgress}
|
sliderProgress={sliderProgress}
|
||||||
sliderMin={sliderMin}
|
sliderMin={sliderMin}
|
||||||
sliderMax={sliderMax}
|
sliderMax={sliderMax}
|
||||||
progressText={progressText}
|
progressText={progressText}
|
||||||
durationText={durationText}
|
remainingText={remainingText}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
repeatMode={repeatMode}
|
repeatMode={repeatMode}
|
||||||
shuffleEnabled={shuffleEnabled}
|
shuffleEnabled={shuffleEnabled}
|
||||||
canGoNext={canGoNext}
|
canGoNext={canGoNext}
|
||||||
canGoPrevious={canGoPrevious}
|
canGoPrevious={canGoPrevious}
|
||||||
onSliderComplete={handleSliderComplete}
|
onSliderComplete={handleSliderComplete}
|
||||||
onTogglePlayPause={togglePlayPause}
|
onTogglePlayPause={togglePlayPause}
|
||||||
onNext={next}
|
onNext={next}
|
||||||
onPrevious={previous}
|
onPrevious={previous}
|
||||||
onCycleRepeat={cycleRepeatMode}
|
onCycleRepeat={cycleRepeatMode}
|
||||||
onToggleShuffle={toggleShuffle}
|
onToggleShuffle={toggleShuffle}
|
||||||
getRepeatIcon={getRepeatIcon}
|
getRepeatIcon={getRepeatIcon}
|
||||||
queue={queue}
|
mediaSource={mediaSource}
|
||||||
queueIndex={queueIndex}
|
isTranscoding={isTranscoding}
|
||||||
mediaSource={mediaSource}
|
isFavorite={isFavorite}
|
||||||
isTranscoding={isTranscoding}
|
onToggleFavorite={toggleFavorite}
|
||||||
|
onOptionsPress={handleOptionsPress}
|
||||||
|
isCastConnected={isCastConnected}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<QueueView
|
||||||
|
api={api}
|
||||||
|
queue={queue}
|
||||||
|
queueIndex={queueIndex}
|
||||||
|
onJumpToIndex={jumpToIndex}
|
||||||
|
onRemoveFromQueue={removeFromQueue}
|
||||||
|
onReorderQueue={reorderQueue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TrackOptionsSheet
|
||||||
|
open={trackOptionsOpen}
|
||||||
|
setOpen={setTrackOptionsOpen}
|
||||||
|
track={currentTrack}
|
||||||
|
onAddToPlaylist={handleAddToPlaylist}
|
||||||
/>
|
/>
|
||||||
) : (
|
<PlaylistPickerSheet
|
||||||
<QueueView
|
open={playlistPickerOpen}
|
||||||
api={api}
|
setOpen={setPlaylistPickerOpen}
|
||||||
queue={queue}
|
trackToAdd={currentTrack}
|
||||||
queueIndex={queueIndex}
|
onCreateNew={handleCreateNewPlaylist}
|
||||||
onJumpToIndex={jumpToIndex}
|
|
||||||
onRemoveFromQueue={removeFromQueue}
|
|
||||||
onReorderQueue={reorderQueue}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<CreatePlaylistModal
|
||||||
</View>
|
open={createPlaylistOpen}
|
||||||
|
setOpen={setCreatePlaylistOpen}
|
||||||
|
initialTrackId={currentTrack?.Id}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</BottomSheetModalProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +360,7 @@ interface PlayerViewProps {
|
|||||||
sliderMin: any;
|
sliderMin: any;
|
||||||
sliderMax: any;
|
sliderMax: any;
|
||||||
progressText: string;
|
progressText: string;
|
||||||
durationText: string;
|
remainingText: string;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
repeatMode: RepeatMode;
|
repeatMode: RepeatMode;
|
||||||
@@ -277,10 +374,12 @@ interface PlayerViewProps {
|
|||||||
onCycleRepeat: () => void;
|
onCycleRepeat: () => void;
|
||||||
onToggleShuffle: () => void;
|
onToggleShuffle: () => void;
|
||||||
getRepeatIcon: () => string;
|
getRepeatIcon: () => string;
|
||||||
queue: BaseItemDto[];
|
|
||||||
queueIndex: number;
|
|
||||||
mediaSource: MediaSourceInfo | null;
|
mediaSource: MediaSourceInfo | null;
|
||||||
isTranscoding: boolean;
|
isTranscoding: boolean;
|
||||||
|
isFavorite: boolean | undefined;
|
||||||
|
onToggleFavorite: () => void;
|
||||||
|
onOptionsPress: () => void;
|
||||||
|
isCastConnected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlayerView: React.FC<PlayerViewProps> = ({
|
const PlayerView: React.FC<PlayerViewProps> = ({
|
||||||
@@ -290,7 +389,7 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
|||||||
sliderMin,
|
sliderMin,
|
||||||
sliderMax,
|
sliderMax,
|
||||||
progressText,
|
progressText,
|
||||||
durationText,
|
remainingText,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
isLoading,
|
isLoading,
|
||||||
repeatMode,
|
repeatMode,
|
||||||
@@ -304,15 +403,42 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
|||||||
onCycleRepeat,
|
onCycleRepeat,
|
||||||
onToggleShuffle,
|
onToggleShuffle,
|
||||||
getRepeatIcon,
|
getRepeatIcon,
|
||||||
queue,
|
|
||||||
queueIndex,
|
|
||||||
mediaSource,
|
mediaSource,
|
||||||
isTranscoding,
|
isTranscoding,
|
||||||
|
isFavorite,
|
||||||
|
onToggleFavorite,
|
||||||
|
onOptionsPress,
|
||||||
|
isCastConnected,
|
||||||
}) => {
|
}) => {
|
||||||
const audioStream = useMemo(() => {
|
const audioStream = useMemo(() => {
|
||||||
return mediaSource?.MediaStreams?.find((stream) => stream.Type === "Audio");
|
return mediaSource?.MediaStreams?.find((stream) => stream.Type === "Audio");
|
||||||
}, [mediaSource]);
|
}, [mediaSource]);
|
||||||
|
|
||||||
|
// Volume slider state
|
||||||
|
const volumeProgress = useSharedValue(0);
|
||||||
|
const volumeMin = useSharedValue(0);
|
||||||
|
const volumeMax = useSharedValue(1);
|
||||||
|
const isTv = Platform.isTV;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isTv || !VolumeManager) return;
|
||||||
|
// Get initial volume
|
||||||
|
VolumeManager.getVolume().then(({ volume }: { volume: number }) => {
|
||||||
|
volumeProgress.value = volume;
|
||||||
|
});
|
||||||
|
// Listen to volume changes
|
||||||
|
const listener = VolumeManager.addVolumeListener((result: VolumeResult) => {
|
||||||
|
volumeProgress.value = result.volume;
|
||||||
|
});
|
||||||
|
return () => listener.remove();
|
||||||
|
}, [isTv, volumeProgress]);
|
||||||
|
|
||||||
|
const handleVolumeChange = useCallback((value: number) => {
|
||||||
|
if (VolumeManager) {
|
||||||
|
VolumeManager.setVolume(value);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fileSize = formatFileSize(mediaSource?.Size);
|
const fileSize = formatFileSize(mediaSource?.Size);
|
||||||
const codec = audioStream?.Codec?.toUpperCase();
|
const codec = audioStream?.Codec?.toUpperCase();
|
||||||
const bitrate = formatBitrate(audioStream?.BitRate);
|
const bitrate = formatBitrate(audioStream?.BitRate);
|
||||||
@@ -353,19 +479,60 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Track info */}
|
{/* Track info with actions */}
|
||||||
<View className='mb-6'>
|
<View className='mb-6'>
|
||||||
<Text numberOfLines={1} className='text-white text-2xl font-bold'>
|
<View className='flex-row items-start justify-between'>
|
||||||
{currentTrack.Name}
|
<View className='flex-1 mr-4'>
|
||||||
</Text>
|
<TextTicker
|
||||||
<Text numberOfLines={1} className='text-purple-400 text-lg mt-1'>
|
style={{ color: "white", fontSize: 24, fontWeight: "bold" }}
|
||||||
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
|
duration={Math.max(4000, (currentTrack.Name?.length || 0) * 250)}
|
||||||
</Text>
|
loop
|
||||||
{currentTrack.Album && (
|
bounce={false}
|
||||||
<Text numberOfLines={1} className='text-neutral-500 text-sm mt-1'>
|
repeatSpacer={80}
|
||||||
{currentTrack.Album}
|
marqueeDelay={1500}
|
||||||
</Text>
|
scroll={false}
|
||||||
)}
|
animationType='scroll'
|
||||||
|
easing={(t) => t}
|
||||||
|
>
|
||||||
|
{currentTrack.Name}
|
||||||
|
</TextTicker>
|
||||||
|
<TextTicker
|
||||||
|
style={{ color: "#a3a3a3", fontSize: 18 }}
|
||||||
|
duration={Math.max(
|
||||||
|
4000,
|
||||||
|
(
|
||||||
|
currentTrack.Artists?.join(", ") ||
|
||||||
|
currentTrack.AlbumArtist ||
|
||||||
|
""
|
||||||
|
).length * 250,
|
||||||
|
)}
|
||||||
|
loop
|
||||||
|
bounce={false}
|
||||||
|
repeatSpacer={80}
|
||||||
|
marqueeDelay={2000}
|
||||||
|
scroll={false}
|
||||||
|
animationType='scroll'
|
||||||
|
easing={(t) => t}
|
||||||
|
>
|
||||||
|
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
|
||||||
|
</TextTicker>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onToggleFavorite}
|
||||||
|
className='p-2'
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={isFavorite ? "heart" : "heart-outline"}
|
||||||
|
size={24}
|
||||||
|
color={isFavorite ? "#ec4899" : "white"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={onOptionsPress} className='p-2'>
|
||||||
|
<Ionicons name='ellipsis-horizontal' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Audio Stats */}
|
{/* Audio Stats */}
|
||||||
{hasAudioStats && (
|
{hasAudioStats && (
|
||||||
@@ -395,28 +562,36 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
|||||||
<View className='mb-4'>
|
<View className='mb-4'>
|
||||||
<Slider
|
<Slider
|
||||||
theme={{
|
theme={{
|
||||||
maximumTrackTintColor: "#333",
|
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||||
minimumTrackTintColor: "#9334E9",
|
minimumTrackTintColor: "#fff",
|
||||||
bubbleBackgroundColor: "#9334E9",
|
bubbleBackgroundColor: "#fff",
|
||||||
bubbleTextColor: "#fff",
|
bubbleTextColor: "#666",
|
||||||
}}
|
}}
|
||||||
progress={sliderProgress}
|
progress={sliderProgress}
|
||||||
minimumValue={sliderMin}
|
minimumValue={sliderMin}
|
||||||
maximumValue={sliderMax}
|
maximumValue={sliderMax}
|
||||||
onSlidingComplete={onSliderComplete}
|
onSlidingComplete={onSliderComplete}
|
||||||
thumbWidth={16}
|
renderThumb={() => null}
|
||||||
sliderHeight={6}
|
sliderHeight={8}
|
||||||
containerStyle={{ borderRadius: 10 }}
|
containerStyle={{ borderRadius: 100 }}
|
||||||
renderBubble={() => null}
|
renderBubble={() => null}
|
||||||
/>
|
/>
|
||||||
<View className='flex flex-row justify-between px-1 mt-2'>
|
<View className='flex flex-row justify-between mt-2'>
|
||||||
<Text className='text-neutral-500 text-xs'>{progressText}</Text>
|
<Text className='text-neutral-500 text-xs'>{progressText}</Text>
|
||||||
<Text className='text-neutral-500 text-xs'>{durationText}</Text>
|
<Text className='text-neutral-500 text-xs'>{remainingText}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Main Controls */}
|
{/* Main Controls with Shuffle & Repeat */}
|
||||||
<View className='flex flex-row items-center justify-center mb-2'>
|
<View className='flex flex-row items-center justify-center mb-6'>
|
||||||
|
<TouchableOpacity onPress={onToggleShuffle} className='p-3'>
|
||||||
|
<Ionicons
|
||||||
|
name='shuffle'
|
||||||
|
size={24}
|
||||||
|
color={shuffleEnabled ? "#9334E9" : "#666"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onPrevious}
|
onPress={onPrevious}
|
||||||
disabled={!canGoPrevious || isLoading}
|
disabled={!canGoPrevious || isLoading}
|
||||||
@@ -429,7 +604,7 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onTogglePlayPause}
|
onPress={onTogglePlayPause}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className='mx-8 bg-white rounded-full p-4'
|
className='mx-4 bg-white rounded-full p-4'
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<ActivityIndicator size={36} color='#121212' />
|
<ActivityIndicator size={36} color='#121212' />
|
||||||
@@ -451,38 +626,75 @@ const PlayerView: React.FC<PlayerViewProps> = ({
|
|||||||
>
|
>
|
||||||
<Ionicons name='play-skip-forward' size={32} color='white' />
|
<Ionicons name='play-skip-forward' size={32} color='white' />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Shuffle & Repeat Controls */}
|
<TouchableOpacity onPress={onCycleRepeat} className='p-3 relative'>
|
||||||
<View className='flex flex-row items-center justify-center mb-2'>
|
|
||||||
<TouchableOpacity onPress={onToggleShuffle} className='p-3 mx-4'>
|
|
||||||
<Ionicons
|
|
||||||
name='shuffle'
|
|
||||||
size={24}
|
|
||||||
color={shuffleEnabled ? "#9334E9" : "#666"}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity onPress={onCycleRepeat} className='p-3 mx-4 relative'>
|
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={getRepeatIcon() as any}
|
name={getRepeatIcon() as any}
|
||||||
size={24}
|
size={24}
|
||||||
color={repeatMode !== "off" ? "#9334E9" : "#666"}
|
color={repeatMode !== "off" ? "#9334E9" : "#666"}
|
||||||
/>
|
/>
|
||||||
{repeatMode === "one" && (
|
{repeatMode === "one" && (
|
||||||
<View className='absolute right-0 bg-purple-600 rounded-full w-4 h-4 items-center justify-center'>
|
<View className='absolute right-0 top-1 bg-purple-600 rounded-full w-4 h-4 items-center justify-center'>
|
||||||
<Text className='text-white text-[10px] font-bold'>1</Text>
|
<Text className='text-white text-[10px] font-bold'>1</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Queue info */}
|
{/* Volume Slider */}
|
||||||
{queue.length > 1 && (
|
{!isTv && VolumeManager && (
|
||||||
<View className='items-center mb-4'>
|
<View className='flex-row items-center mb-6'>
|
||||||
<Text className='text-neutral-500 text-sm'>
|
<Ionicons name='volume-low' size={20} color='#666' />
|
||||||
{queueIndex + 1} of {queue.length}
|
<View className='flex-1 mx-3'>
|
||||||
</Text>
|
<Slider
|
||||||
|
theme={{
|
||||||
|
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||||
|
minimumTrackTintColor: "#fff",
|
||||||
|
}}
|
||||||
|
progress={volumeProgress}
|
||||||
|
minimumValue={volumeMin}
|
||||||
|
maximumValue={volumeMax}
|
||||||
|
onSlidingComplete={handleVolumeChange}
|
||||||
|
renderThumb={() => null}
|
||||||
|
sliderHeight={8}
|
||||||
|
containerStyle={{ borderRadius: 100 }}
|
||||||
|
renderBubble={() => null}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Ionicons name='volume-high' size={20} color='#666' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AirPlay & Chromecast Buttons */}
|
||||||
|
{!isTv && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 32,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* AirPlay (iOS only) */}
|
||||||
|
{Platform.OS === "ios" && (
|
||||||
|
<View style={{ transform: [{ scale: 2.8 }] }}>
|
||||||
|
<ExpoAvRoutePickerView
|
||||||
|
style={{ width: 24, height: 24 }}
|
||||||
|
tintColor='#666666'
|
||||||
|
activeTintColor='#9334E9'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{/* Chromecast */}
|
||||||
|
<CastButton
|
||||||
|
style={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
tintColor: isCastConnected ? "#9334E9" : "#666",
|
||||||
|
transform: [{ translateY: 1 }],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
type MediaSourceInfo,
|
type MediaSourceInfo,
|
||||||
PlaybackOrder,
|
PlaybackOrder,
|
||||||
PlaybackProgressInfo,
|
PlaybackProgressInfo,
|
||||||
PlaybackStartInfo,
|
|
||||||
RepeatMode,
|
RepeatMode,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import {
|
import {
|
||||||
@@ -11,13 +10,12 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||||
import { router, useGlobalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, View } from "react-native";
|
import { Alert, Platform, useWindowDimensions, View } from "react-native";
|
||||||
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
|
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
|
||||||
|
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -28,6 +26,7 @@ import {
|
|||||||
PlaybackSpeedScope,
|
PlaybackSpeedScope,
|
||||||
updatePlaybackSpeedSettings,
|
updatePlaybackSpeedSettings,
|
||||||
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
@@ -35,24 +34,20 @@ import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
|
|||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
import {
|
import {
|
||||||
type PlaybackStatePayload,
|
type MpvOnErrorEventPayload,
|
||||||
type ProgressUpdatePayload,
|
type MpvOnPlaybackStateChangePayload,
|
||||||
type SfOnErrorEventPayload,
|
type MpvOnProgressEventPayload,
|
||||||
type SfOnPictureInPictureChangePayload,
|
MpvPlayerView,
|
||||||
type SfOnPlaybackStateChangePayload,
|
type MpvPlayerViewRef,
|
||||||
type SfOnProgressEventPayload,
|
type MpvVideoSource,
|
||||||
SfPlayerView,
|
|
||||||
type SfPlayerViewRef,
|
|
||||||
type SfVideoSource,
|
|
||||||
setHardwareDecode,
|
|
||||||
type VlcPlayerSource,
|
|
||||||
VlcPlayerView,
|
|
||||||
type VlcPlayerViewRef,
|
|
||||||
} from "@/modules";
|
} from "@/modules";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings, VideoPlayerIOS } from "@/utils/atoms/settings";
|
|
||||||
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import {
|
import {
|
||||||
getMpvAudioId,
|
getMpvAudioId,
|
||||||
@@ -63,29 +58,19 @@ import { generateDeviceProfile } from "@/utils/profiles/native";
|
|||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const videoRef = useRef<SfPlayerViewRef | VlcPlayerViewRef>(null);
|
const videoRef = useRef<MpvPlayerViewRef>(null);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
const router = useRouter();
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
|
|
||||||
// Determine which player to use:
|
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
|
||||||
// - Android always uses VLC
|
|
||||||
// - iOS uses user setting (KSPlayer by default, VLC optional)
|
|
||||||
const useVlcPlayer =
|
|
||||||
Platform.OS === "android" ||
|
|
||||||
(Platform.OS === "ios" && settings.videoPlayerIOS === VideoPlayerIOS.VLC);
|
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
const [isPipMode, setIsPipMode] = useState(false);
|
const [isPipMode, setIsPipMode] = useState(false);
|
||||||
const [aspectRatio, setAspectRatio] = useState<
|
|
||||||
"default" | "16:9" | "4:3" | "1:1" | "21:9"
|
|
||||||
>("default");
|
|
||||||
const [scaleFactor, setScaleFactor] = useState<
|
|
||||||
0 | 0.25 | 0.5 | 0.75 | 1.0 | 1.25 | 1.5 | 2.0
|
|
||||||
>(0);
|
|
||||||
const [isZoomedToFill, setIsZoomedToFill] = useState(false);
|
const [isZoomedToFill, setIsZoomedToFill] = useState(false);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
@@ -94,6 +79,7 @@ export default function page() {
|
|||||||
const [tracksReady, setTracksReady] = useState(false);
|
const [tracksReady, setTracksReady] = useState(false);
|
||||||
const [hasPlaybackStarted, setHasPlaybackStarted] = useState(false);
|
const [hasPlaybackStarted, setHasPlaybackStarted] = useState(false);
|
||||||
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0);
|
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0);
|
||||||
|
const [showTechnicalInfo, setShowTechnicalInfo] = useState(false);
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
const isSeeking = useSharedValue(false);
|
const isSeeking = useSharedValue(false);
|
||||||
@@ -103,10 +89,9 @@ export default function page() {
|
|||||||
: require("react-native-volume-manager");
|
: require("react-native-volume-manager");
|
||||||
|
|
||||||
const downloadUtils = useDownload();
|
const downloadUtils = useDownload();
|
||||||
const downloadedFiles = useMemo(
|
// Call directly instead of useMemo - the function reference doesn't change
|
||||||
() => downloadUtils.getDownloadedItems(),
|
// when data updates, only when the provider initializes
|
||||||
[downloadUtils.getDownloadedItems],
|
const downloadedFiles = downloadUtils.getDownloadedItems();
|
||||||
);
|
|
||||||
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
@@ -125,7 +110,7 @@ export default function page() {
|
|||||||
bitrateValue: bitrateValueStr,
|
bitrateValue: bitrateValueStr,
|
||||||
offline: offlineStr,
|
offline: offlineStr,
|
||||||
playbackPosition: playbackPositionFromUrl,
|
playbackPosition: playbackPositionFromUrl,
|
||||||
} = useGlobalSearchParams<{
|
} = useLocalSearchParams<{
|
||||||
itemId: string;
|
itemId: string;
|
||||||
audioIndex: string;
|
audioIndex: string;
|
||||||
subtitleIndex: string;
|
subtitleIndex: string;
|
||||||
@@ -140,7 +125,9 @@ export default function page() {
|
|||||||
const offline = offlineStr === "true";
|
const offline = offlineStr === "true";
|
||||||
const playbackManager = usePlaybackManager({ isOffline: offline });
|
const playbackManager = usePlaybackManager({ isOffline: offline });
|
||||||
|
|
||||||
const audioIndex = audioIndexStr
|
// Audio index: use URL param if provided, otherwise use stored index for offline playback
|
||||||
|
// This is computed after downloadedItem is available, see audioIndexResolved below
|
||||||
|
const audioIndexFromUrl = audioIndexStr
|
||||||
? Number.parseInt(audioIndexStr, 10)
|
? Number.parseInt(audioIndexStr, 10)
|
||||||
: undefined;
|
: undefined;
|
||||||
const subtitleIndex = subtitleIndexStr
|
const subtitleIndex = subtitleIndexStr
|
||||||
@@ -159,6 +146,17 @@ export default function page() {
|
|||||||
isError: false,
|
isError: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Resolve audio index: use URL param if provided, otherwise use stored index for offline playback
|
||||||
|
const audioIndex = useMemo(() => {
|
||||||
|
if (audioIndexFromUrl !== undefined) {
|
||||||
|
return audioIndexFromUrl;
|
||||||
|
}
|
||||||
|
if (offline && downloadedItem?.userData?.audioStreamIndex !== undefined) {
|
||||||
|
return downloadedItem.userData.audioStreamIndex;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
|
||||||
|
|
||||||
// Get the playback speed for this item based on settings
|
// Get the playback speed for this item based on settings
|
||||||
const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed(
|
const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed(
|
||||||
item,
|
item,
|
||||||
@@ -177,15 +175,11 @@ export default function page() {
|
|||||||
updateSettings,
|
updateSettings,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Apply speed to the current player
|
// Apply speed to the current player (MPV)
|
||||||
setCurrentPlaybackSpeed(speed);
|
setCurrentPlaybackSpeed(speed);
|
||||||
if (useVlcPlayer) {
|
await videoRef.current?.setSpeed?.(speed);
|
||||||
await (videoRef.current as VlcPlayerViewRef)?.setRate?.(speed);
|
|
||||||
} else {
|
|
||||||
await (videoRef.current as SfPlayerViewRef)?.setSpeed?.(speed);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[item, settings, updateSettings, useVlcPlayer],
|
[item, settings, updateSettings],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Gets the initial playback position from the URL. */
|
/** Gets the initial playback position from the URL. */
|
||||||
@@ -333,9 +327,12 @@ export default function page() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!stream || !api || offline) return;
|
if (!stream || !api || offline) return;
|
||||||
const reportPlaybackStart = async () => {
|
const reportPlaybackStart = async () => {
|
||||||
await getPlaystateApi(api).reportPlaybackStart({
|
const progressInfo = currentPlayStateInfo();
|
||||||
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
|
if (progressInfo) {
|
||||||
});
|
await getPlaystateApi(api).reportPlaybackStart({
|
||||||
|
playbackStartInfo: progressInfo,
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
reportPlaybackStart();
|
reportPlaybackStart();
|
||||||
}, [stream, api, offline]);
|
}, [stream, api, offline]);
|
||||||
@@ -345,14 +342,16 @@ export default function page() {
|
|||||||
setIsPlaying(!isPlaying);
|
setIsPlaying(!isPlaying);
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
playbackManager.reportPlaybackProgress(
|
const progressInfo = currentPlayStateInfo();
|
||||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
if (progressInfo) {
|
||||||
);
|
playbackManager.reportPlaybackProgress(progressInfo);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
videoRef.current?.play();
|
videoRef.current?.play();
|
||||||
|
const progressInfo = currentPlayStateInfo();
|
||||||
if (!offline && api) {
|
if (!offline && api) {
|
||||||
await getPlaystateApi(api).reportPlaybackStart({
|
await getPlaystateApi(api).reportPlaybackStart({
|
||||||
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
|
playbackStartInfo: progressInfo,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,7 +384,6 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
reportPlaybackStopped();
|
reportPlaybackStopped();
|
||||||
setIsPlaybackStopped(true);
|
setIsPlaybackStopped(true);
|
||||||
// KSPlayer doesn't have a stop method, use pause instead
|
|
||||||
videoRef.current?.pause();
|
videoRef.current?.pause();
|
||||||
revalidateProgressCache();
|
revalidateProgressCache();
|
||||||
}, [videoRef, reportPlaybackStopped, progress]);
|
}, [videoRef, reportPlaybackStopped, progress]);
|
||||||
@@ -397,22 +395,24 @@ export default function page() {
|
|||||||
};
|
};
|
||||||
}, [navigation, stop]);
|
}, [navigation, stop]);
|
||||||
|
|
||||||
const currentPlayStateInfo = useCallback(() => {
|
const currentPlayStateInfo = useCallback(():
|
||||||
|
| PlaybackProgressInfo
|
||||||
|
| undefined => {
|
||||||
if (!stream || !item?.Id) return;
|
if (!stream || !item?.Id) return;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
itemId: item.Id,
|
ItemId: item.Id,
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
AudioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
SubtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
mediaSourceId: mediaSourceId,
|
MediaSourceId: mediaSourceId,
|
||||||
positionTicks: msToTicks(progress.get()),
|
PositionTicks: msToTicks(progress.get()),
|
||||||
isPaused: !isPlaying,
|
IsPaused: !isPlaying,
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
PlayMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
playSessionId: stream.sessionId,
|
PlaySessionId: stream.sessionId,
|
||||||
isMuted: isMuted,
|
IsMuted: isMuted,
|
||||||
canSeek: true,
|
CanSeek: true,
|
||||||
repeatMode: RepeatMode.RepeatNone,
|
RepeatMode: RepeatMode.RepeatNone,
|
||||||
playbackOrder: PlaybackOrder.Default,
|
PlaybackOrder: PlaybackOrder.Default,
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
stream,
|
stream,
|
||||||
@@ -441,13 +441,13 @@ export default function page() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Progress handler for iOS (SfPlayer) - position in seconds */
|
/** Progress handler for MPV - position in seconds */
|
||||||
const onProgressSf = useCallback(
|
const onProgress = useCallback(
|
||||||
async (data: { nativeEvent: SfOnProgressEventPayload }) => {
|
async (data: { nativeEvent: MpvOnProgressEventPayload }) => {
|
||||||
if (isSeeking.get() || isPlaybackStopped) return;
|
if (isSeeking.get() || isPlaybackStopped) return;
|
||||||
|
|
||||||
const { position } = data.nativeEvent;
|
const { position } = data.nativeEvent;
|
||||||
// KSPlayer reports position in seconds, convert to ms
|
// MPV reports position in seconds, convert to ms
|
||||||
const currentTime = position * 1000;
|
const currentTime = position * 1000;
|
||||||
|
|
||||||
if (isBuffering) {
|
if (isBuffering) {
|
||||||
@@ -490,68 +490,21 @@ export default function page() {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Progress handler for Android (VLC) - currentTime in milliseconds */
|
|
||||||
const onProgressVlc = useCallback(
|
|
||||||
async (data: ProgressUpdatePayload) => {
|
|
||||||
if (isSeeking.get() || isPlaybackStopped) return;
|
|
||||||
|
|
||||||
const { currentTime } = data.nativeEvent;
|
|
||||||
// VLC reports currentTime in milliseconds
|
|
||||||
|
|
||||||
if (isBuffering) {
|
|
||||||
setIsBuffering(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
progress.set(currentTime);
|
|
||||||
|
|
||||||
// Update URL immediately after seeking, or every 30 seconds during normal playback
|
|
||||||
const now = Date.now();
|
|
||||||
const shouldUpdateUrl = wasJustSeeking.get();
|
|
||||||
wasJustSeeking.value = false;
|
|
||||||
|
|
||||||
if (
|
|
||||||
shouldUpdateUrl ||
|
|
||||||
now - lastUrlUpdateTime.get() > URL_UPDATE_INTERVAL
|
|
||||||
) {
|
|
||||||
router.setParams({
|
|
||||||
playbackPosition: msToTicks(currentTime).toString(),
|
|
||||||
});
|
|
||||||
lastUrlUpdateTime.value = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item?.Id) return;
|
|
||||||
|
|
||||||
playbackManager.reportPlaybackProgress(
|
|
||||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
item?.Id,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
isPlaying,
|
|
||||||
stream,
|
|
||||||
isSeeking,
|
|
||||||
isPlaybackStopped,
|
|
||||||
isBuffering,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Gets the initial playback position in seconds. */
|
/** Gets the initial playback position in seconds. */
|
||||||
const startPosition = useMemo(() => {
|
const _startPosition = useMemo(() => {
|
||||||
return ticksToSeconds(getInitialPlaybackTicks());
|
return ticksToSeconds(getInitialPlaybackTicks());
|
||||||
}, [getInitialPlaybackTicks]);
|
}, [getInitialPlaybackTicks]);
|
||||||
|
|
||||||
/** Build video source config for iOS (SfPlayer/KSPlayer) */
|
/** Build video source config for MPV */
|
||||||
const sfVideoSource = useMemo<SfVideoSource | undefined>(() => {
|
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
|
||||||
if (!stream?.url || useVlcPlayer) return undefined;
|
if (!stream?.url) return undefined;
|
||||||
|
|
||||||
const mediaSource = stream.mediaSource;
|
const mediaSource = stream.mediaSource;
|
||||||
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||||
|
|
||||||
// For offline playback, subtitles are embedded in the downloaded file
|
// Get external subtitle URLs
|
||||||
// For online playback, get external subtitle URLs from server
|
// - Online: prepend API base path to server URLs
|
||||||
|
// - Offline: use local file paths (stored in DeliveryUrl during download)
|
||||||
let externalSubs: string[] | undefined;
|
let externalSubs: string[] | undefined;
|
||||||
if (!offline && api?.basePath) {
|
if (!offline && api?.basePath) {
|
||||||
externalSubs = mediaSource?.MediaStreams?.filter(
|
externalSubs = mediaSource?.MediaStreams?.filter(
|
||||||
@@ -560,6 +513,13 @@ export default function page() {
|
|||||||
s.DeliveryMethod === "External" &&
|
s.DeliveryMethod === "External" &&
|
||||||
s.DeliveryUrl,
|
s.DeliveryUrl,
|
||||||
).map((s) => `${api.basePath}${s.DeliveryUrl}`);
|
).map((s) => `${api.basePath}${s.DeliveryUrl}`);
|
||||||
|
} else if (offline) {
|
||||||
|
externalSubs = mediaSource?.MediaStreams?.filter(
|
||||||
|
(s) =>
|
||||||
|
s.Type === "Subtitle" &&
|
||||||
|
s.DeliveryMethod === "External" &&
|
||||||
|
s.DeliveryUrl,
|
||||||
|
).map((s) => s.DeliveryUrl!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate track IDs for initial selection
|
// Calculate track IDs for initial selection
|
||||||
@@ -576,15 +536,10 @@ export default function page() {
|
|||||||
: (item?.UserData?.PlaybackPositionTicks ?? 0);
|
: (item?.UserData?.PlaybackPositionTicks ?? 0);
|
||||||
const startPos = ticksToSeconds(startTicks);
|
const startPos = ticksToSeconds(startTicks);
|
||||||
|
|
||||||
// For transcoded streams, the server already handles seeking via startTimeTicks,
|
|
||||||
// so we should NOT also tell the player to seek (would cause double-seeking).
|
|
||||||
// For direct play/stream, the player needs to seek itself.
|
|
||||||
const playerStartPos = isTranscoding ? 0 : startPos;
|
|
||||||
|
|
||||||
// Build source config - headers only needed for online streaming
|
// Build source config - headers only needed for online streaming
|
||||||
const source: SfVideoSource = {
|
const source: MpvVideoSource = {
|
||||||
url: stream.url,
|
url: stream.url,
|
||||||
startPosition: playerStartPos,
|
startPosition: startPos,
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
initialSubtitleId,
|
initialSubtitleId,
|
||||||
initialAudioId,
|
initialAudioId,
|
||||||
@@ -613,96 +568,6 @@ export default function page() {
|
|||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
offline,
|
offline,
|
||||||
useVlcPlayer,
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** Build video source config for Android (VLC) */
|
|
||||||
const vlcVideoSource = useMemo<VlcPlayerSource | undefined>(() => {
|
|
||||||
if (!stream?.url || !useVlcPlayer) return undefined;
|
|
||||||
|
|
||||||
const mediaSource = stream.mediaSource;
|
|
||||||
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
|
||||||
|
|
||||||
// For VLC, external subtitles need name and DeliveryUrl
|
|
||||||
let externalSubs: { name: string; DeliveryUrl: string }[] | undefined;
|
|
||||||
if (!offline && api?.basePath) {
|
|
||||||
externalSubs = mediaSource?.MediaStreams?.filter(
|
|
||||||
(s) =>
|
|
||||||
s.Type === "Subtitle" &&
|
|
||||||
s.DeliveryMethod === "External" &&
|
|
||||||
s.DeliveryUrl,
|
|
||||||
).map((s) => ({
|
|
||||||
name: s.DisplayTitle || s.Title || `Subtitle ${s.Index}`,
|
|
||||||
DeliveryUrl: `${api.basePath}${s.DeliveryUrl}`,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build VLC init options (required for VLC to work properly)
|
|
||||||
const initOptions: string[] = [""];
|
|
||||||
|
|
||||||
// Get all subtitle and audio streams
|
|
||||||
const allSubs =
|
|
||||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") ?? [];
|
|
||||||
const textSubs = allSubs.filter((s) => s.IsTextSubtitleStream);
|
|
||||||
const allAudio =
|
|
||||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? [];
|
|
||||||
|
|
||||||
// Find chosen tracks
|
|
||||||
const chosenSubtitleTrack = allSubs.find((s) => s.Index === subtitleIndex);
|
|
||||||
const chosenAudioTrack = allAudio.find((a) => a.Index === audioIndex);
|
|
||||||
|
|
||||||
// Set subtitle track
|
|
||||||
if (
|
|
||||||
chosenSubtitleTrack &&
|
|
||||||
(!isTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
|
||||||
) {
|
|
||||||
const finalIndex = !isTranscoding
|
|
||||||
? allSubs.indexOf(chosenSubtitleTrack)
|
|
||||||
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
|
|
||||||
if (finalIndex >= 0) {
|
|
||||||
initOptions.push(`--sub-track=${finalIndex}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set audio track
|
|
||||||
if (!isTranscoding && chosenAudioTrack) {
|
|
||||||
const audioTrackIndex = allAudio.indexOf(chosenAudioTrack);
|
|
||||||
if (audioTrackIndex >= 0) {
|
|
||||||
initOptions.push(`--audio-track=${audioTrackIndex}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add subtitle styling
|
|
||||||
if (settings.subtitleSize) {
|
|
||||||
initOptions.push(`--sub-text-scale=${settings.subtitleSize}`);
|
|
||||||
}
|
|
||||||
initOptions.push("--sub-margin=40");
|
|
||||||
|
|
||||||
// For transcoded streams, the server already handles seeking via startTimeTicks,
|
|
||||||
// so we should NOT also tell the player to seek (would cause double-seeking).
|
|
||||||
// For direct play/stream, the player needs to seek itself.
|
|
||||||
const playerStartPos = isTranscoding ? 0 : startPosition;
|
|
||||||
|
|
||||||
const source: VlcPlayerSource = {
|
|
||||||
uri: stream.url,
|
|
||||||
startPosition: playerStartPos,
|
|
||||||
autoplay: true,
|
|
||||||
isNetwork: !offline,
|
|
||||||
externalSubtitles: externalSubs,
|
|
||||||
initOptions,
|
|
||||||
};
|
|
||||||
|
|
||||||
return source;
|
|
||||||
}, [
|
|
||||||
stream?.url,
|
|
||||||
stream?.mediaSource,
|
|
||||||
startPosition,
|
|
||||||
useVlcPlayer,
|
|
||||||
api?.basePath,
|
|
||||||
offline,
|
|
||||||
subtitleIndex,
|
|
||||||
audioIndex,
|
|
||||||
settings.subtitleSize,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const volumeUpCb = useCallback(async () => {
|
const volumeUpCb = useCallback(async () => {
|
||||||
@@ -784,9 +649,9 @@ export default function page() {
|
|||||||
setVolume: setVolumeCb,
|
setVolume: setVolumeCb,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Playback state handler for iOS (SfPlayer) */
|
/** Playback state handler for MPV */
|
||||||
const onPlaybackStateChangedSf = useCallback(
|
const onPlaybackStateChanged = useCallback(
|
||||||
async (e: { nativeEvent: SfOnPlaybackStateChangePayload }) => {
|
async (e: { nativeEvent: MpvOnPlaybackStateChangePayload }) => {
|
||||||
const { isPaused, isPlaying: playing, isLoading } = e.nativeEvent;
|
const { isPaused, isPlaying: playing, isLoading } = e.nativeEvent;
|
||||||
|
|
||||||
if (playing) {
|
if (playing) {
|
||||||
@@ -813,56 +678,16 @@ export default function page() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading !== undefined) {
|
||||||
setIsBuffering(true);
|
setIsBuffering(isLoading);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[playbackManager, item?.Id, progress],
|
[playbackManager, item?.Id, progress],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Playback state handler for Android (VLC) */
|
/** PiP handler for MPV */
|
||||||
const onPlaybackStateChangedVlc = useCallback(
|
const _onPictureInPictureChange = useCallback(
|
||||||
async (e: PlaybackStatePayload) => {
|
(e: { nativeEvent: { isActive: boolean } }) => {
|
||||||
const {
|
|
||||||
state,
|
|
||||||
isBuffering: buffering,
|
|
||||||
isPlaying: playing,
|
|
||||||
} = e.nativeEvent;
|
|
||||||
|
|
||||||
if (state === "Playing" || playing) {
|
|
||||||
setIsPlaying(true);
|
|
||||||
setIsBuffering(false);
|
|
||||||
setHasPlaybackStarted(true);
|
|
||||||
if (item?.Id) {
|
|
||||||
playbackManager.reportPlaybackProgress(
|
|
||||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!Platform.isTV) await activateKeepAwakeAsync();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === "Paused") {
|
|
||||||
setIsPlaying(false);
|
|
||||||
if (item?.Id) {
|
|
||||||
playbackManager.reportPlaybackProgress(
|
|
||||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!Platform.isTV) await deactivateKeepAwake();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === "Buffering" || buffering) {
|
|
||||||
setIsBuffering(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[playbackManager, item?.Id, progress],
|
|
||||||
);
|
|
||||||
|
|
||||||
/** PiP handler for iOS (SfPlayer) */
|
|
||||||
const onPictureInPictureChangeSf = useCallback(
|
|
||||||
(e: { nativeEvent: SfOnPictureInPictureChangePayload }) => {
|
|
||||||
const { isActive } = e.nativeEvent;
|
const { isActive } = e.nativeEvent;
|
||||||
setIsPipMode(isActive);
|
setIsPipMode(isActive);
|
||||||
// Hide controls when entering PiP
|
// Hide controls when entering PiP
|
||||||
@@ -873,19 +698,6 @@ export default function page() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** PiP handler for Android (VLC) */
|
|
||||||
const onPipStartedVlc = useCallback(
|
|
||||||
(e: { nativeEvent: { pipStarted: boolean } }) => {
|
|
||||||
const { pipStarted } = e.nativeEvent;
|
|
||||||
setIsPipMode(pipStarted);
|
|
||||||
// Hide controls when entering PiP
|
|
||||||
if (pipStarted) {
|
|
||||||
_setShowControls(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
// Add useEffect to handle mounting
|
// Add useEffect to handle mounting
|
||||||
@@ -907,96 +719,132 @@ export default function page() {
|
|||||||
videoRef.current?.pause?.();
|
videoRef.current?.pause?.();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const seek = useCallback(
|
const seek = useCallback((position: number) => {
|
||||||
(position: number) => {
|
// MPV expects seconds, convert from ms
|
||||||
if (useVlcPlayer) {
|
videoRef.current?.seekTo?.(position / 1000);
|
||||||
// VLC expects milliseconds
|
}, []);
|
||||||
videoRef.current?.seekTo?.(position);
|
|
||||||
} else {
|
// Technical info toggle handler
|
||||||
// KSPlayer expects seconds, convert from ms
|
const handleToggleTechnicalInfo = useCallback(() => {
|
||||||
videoRef.current?.seekTo?.(position / 1000);
|
setShowTechnicalInfo((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get technical info from the player
|
||||||
|
const getTechnicalInfo = useCallback(async () => {
|
||||||
|
return (await videoRef.current?.getTechnicalInfo?.()) ?? {};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Determine play method based on stream URL and media source
|
||||||
|
const playMethod = useMemo<
|
||||||
|
"DirectPlay" | "DirectStream" | "Transcode" | undefined
|
||||||
|
>(() => {
|
||||||
|
if (!stream?.url) return undefined;
|
||||||
|
|
||||||
|
// Check if transcoding (m3u8 playlist or TranscodingUrl present)
|
||||||
|
if (stream.url.includes("m3u8") || stream.mediaSource?.TranscodingUrl) {
|
||||||
|
return "Transcode";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if direct play (no container remuxing needed)
|
||||||
|
// Direct play means the file is being served as-is
|
||||||
|
if (stream.url.includes("/Videos/") && stream.url.includes("/stream")) {
|
||||||
|
return "DirectStream";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to direct play if we're not transcoding
|
||||||
|
return "DirectPlay";
|
||||||
|
}, [stream?.url, stream?.mediaSource?.TranscodingUrl]);
|
||||||
|
|
||||||
|
// Extract transcode reasons from the TranscodingUrl
|
||||||
|
const transcodeReasons = useMemo<string[]>(() => {
|
||||||
|
const transcodingUrl = stream?.mediaSource?.TranscodingUrl;
|
||||||
|
if (!transcodingUrl) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse the TranscodeReasons parameter from the URL
|
||||||
|
const url = new URL(transcodingUrl, "http://localhost");
|
||||||
|
const reasons = url.searchParams.get("TranscodeReasons");
|
||||||
|
if (reasons) {
|
||||||
|
return reasons.split(",").filter(Boolean);
|
||||||
}
|
}
|
||||||
},
|
} catch {
|
||||||
[useVlcPlayer],
|
// If URL parsing fails, try regex fallback
|
||||||
);
|
const match = transcodingUrl.match(/TranscodeReasons=([^&]+)/);
|
||||||
|
if (match) {
|
||||||
|
return match[1].split(",").filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [stream?.mediaSource?.TranscodingUrl]);
|
||||||
|
|
||||||
const handleZoomToggle = useCallback(async () => {
|
const handleZoomToggle = useCallback(async () => {
|
||||||
// Zoom toggle only supported when using SfPlayer (KSPlayer)
|
|
||||||
if (useVlcPlayer) return;
|
|
||||||
const newZoomState = !isZoomedToFill;
|
const newZoomState = !isZoomedToFill;
|
||||||
|
await videoRef.current?.setZoomedToFill?.(newZoomState);
|
||||||
setIsZoomedToFill(newZoomState);
|
setIsZoomedToFill(newZoomState);
|
||||||
await (videoRef.current as SfPlayerViewRef)?.setVideoZoomToFill?.(
|
|
||||||
newZoomState,
|
|
||||||
);
|
|
||||||
}, [isZoomedToFill, useVlcPlayer]);
|
|
||||||
|
|
||||||
// VLC-specific handlers for aspect ratio and scale factor
|
// Adjust subtitle position to compensate for video cropping when zoomed
|
||||||
const handleSetVideoAspectRatio = useCallback(
|
if (newZoomState) {
|
||||||
async (newAspectRatio: string | null) => {
|
// Get video dimensions from mediaSource
|
||||||
if (!useVlcPlayer) return;
|
const videoStream = stream?.mediaSource?.MediaStreams?.find(
|
||||||
const ratio = (newAspectRatio ?? "default") as
|
(s) => s.Type === "Video",
|
||||||
| "default"
|
|
||||||
| "16:9"
|
|
||||||
| "4:3"
|
|
||||||
| "1:1"
|
|
||||||
| "21:9";
|
|
||||||
setAspectRatio(ratio);
|
|
||||||
await (videoRef.current as VlcPlayerViewRef)?.setVideoAspectRatio?.(
|
|
||||||
newAspectRatio,
|
|
||||||
);
|
);
|
||||||
},
|
const videoWidth = videoStream?.Width ?? 1920;
|
||||||
[useVlcPlayer],
|
const videoHeight = videoStream?.Height ?? 1080;
|
||||||
);
|
|
||||||
|
|
||||||
const handleSetVideoScaleFactor = useCallback(
|
const videoAR = videoWidth / videoHeight;
|
||||||
async (newScaleFactor: number) => {
|
const screenAR = screenWidth / screenHeight;
|
||||||
if (!useVlcPlayer) return;
|
|
||||||
setScaleFactor(
|
|
||||||
newScaleFactor as 0 | 0.25 | 0.5 | 0.75 | 1.0 | 1.25 | 1.5 | 2.0,
|
|
||||||
);
|
|
||||||
await (videoRef.current as VlcPlayerViewRef)?.setVideoScaleFactor?.(
|
|
||||||
newScaleFactor,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[useVlcPlayer],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Apply KSPlayer global settings before video loads (only when using KSPlayer)
|
if (screenAR > videoAR) {
|
||||||
useEffect(() => {
|
// Screen is wider than video - video height extends beyond screen
|
||||||
if (Platform.OS === "ios" && !useVlcPlayer) {
|
// Calculate how much of the video is cropped at the bottom (as % of video height)
|
||||||
setHardwareDecode(settings.ksHardwareDecode);
|
const bottomCropPercent = 50 * (1 - videoAR / screenAR);
|
||||||
|
// Only adjust by 70% of the crop to keep a comfortable margin from the edge
|
||||||
|
// (subtitles already have some built-in padding from the bottom)
|
||||||
|
const adjustmentFactor = 0.7;
|
||||||
|
const newSubPos = Math.round(
|
||||||
|
100 - bottomCropPercent * adjustmentFactor,
|
||||||
|
);
|
||||||
|
await videoRef.current?.setSubtitlePosition?.(newSubPos);
|
||||||
|
}
|
||||||
|
// If videoAR >= screenAR, sides are cropped but bottom is visible, no adjustment needed
|
||||||
|
} else {
|
||||||
|
// Restore to default position (bottom of video frame)
|
||||||
|
await videoRef.current?.setSubtitlePosition?.(100);
|
||||||
}
|
}
|
||||||
}, [settings.ksHardwareDecode, useVlcPlayer]);
|
}, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]);
|
||||||
|
|
||||||
// Apply subtitle settings when video loads (SfPlayer-specific)
|
// Apply subtitle settings when video loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (useVlcPlayer || !isVideoLoaded || !videoRef.current) return;
|
if (!isVideoLoaded || !videoRef.current) return;
|
||||||
|
|
||||||
const sfRef = videoRef.current as SfPlayerViewRef;
|
|
||||||
const applySubtitleSettings = async () => {
|
const applySubtitleSettings = async () => {
|
||||||
if (settings.mpvSubtitleScale !== undefined) {
|
if (settings.mpvSubtitleScale !== undefined) {
|
||||||
await sfRef?.setSubtitleScale?.(settings.mpvSubtitleScale);
|
await videoRef.current?.setSubtitleScale?.(settings.mpvSubtitleScale);
|
||||||
}
|
}
|
||||||
if (settings.mpvSubtitleMarginY !== undefined) {
|
if (settings.mpvSubtitleMarginY !== undefined) {
|
||||||
await sfRef?.setSubtitleMarginY?.(settings.mpvSubtitleMarginY);
|
await videoRef.current?.setSubtitleMarginY?.(
|
||||||
|
settings.mpvSubtitleMarginY,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (settings.mpvSubtitleAlignX !== undefined) {
|
if (settings.mpvSubtitleAlignX !== undefined) {
|
||||||
await sfRef?.setSubtitleAlignX?.(settings.mpvSubtitleAlignX);
|
await videoRef.current?.setSubtitleAlignX?.(settings.mpvSubtitleAlignX);
|
||||||
}
|
}
|
||||||
if (settings.mpvSubtitleAlignY !== undefined) {
|
if (settings.mpvSubtitleAlignY !== undefined) {
|
||||||
await sfRef?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY);
|
await videoRef.current?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY);
|
||||||
}
|
}
|
||||||
if (settings.mpvSubtitleFontSize !== undefined) {
|
if (settings.mpvSubtitleFontSize !== undefined) {
|
||||||
await sfRef?.setSubtitleFontSize?.(settings.mpvSubtitleFontSize);
|
await videoRef.current?.setSubtitleFontSize?.(
|
||||||
|
settings.mpvSubtitleFontSize,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Apply subtitle size from general settings
|
// Apply subtitle size from general settings
|
||||||
if (settings.subtitleSize) {
|
if (settings.subtitleSize) {
|
||||||
await sfRef?.setSubtitleFontSize?.(settings.subtitleSize);
|
await videoRef.current?.setSubtitleFontSize?.(settings.subtitleSize);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
applySubtitleSettings();
|
applySubtitleSettings();
|
||||||
}, [isVideoLoaded, settings, useVlcPlayer]);
|
}, [isVideoLoaded, settings]);
|
||||||
|
|
||||||
// Apply initial playback speed when video loads
|
// Apply initial playback speed when video loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1005,20 +853,12 @@ export default function page() {
|
|||||||
const applyInitialPlaybackSpeed = async () => {
|
const applyInitialPlaybackSpeed = async () => {
|
||||||
if (initialPlaybackSpeed !== 1.0) {
|
if (initialPlaybackSpeed !== 1.0) {
|
||||||
setCurrentPlaybackSpeed(initialPlaybackSpeed);
|
setCurrentPlaybackSpeed(initialPlaybackSpeed);
|
||||||
if (useVlcPlayer) {
|
await videoRef.current?.setSpeed?.(initialPlaybackSpeed);
|
||||||
await (videoRef.current as VlcPlayerViewRef)?.setRate?.(
|
|
||||||
initialPlaybackSpeed,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await (videoRef.current as SfPlayerViewRef)?.setSpeed?.(
|
|
||||||
initialPlaybackSpeed,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
applyInitialPlaybackSpeed();
|
applyInitialPlaybackSpeed();
|
||||||
}, [isVideoLoaded, initialPlaybackSpeed, useVlcPlayer]);
|
}, [isVideoLoaded, initialPlaybackSpeed]);
|
||||||
|
|
||||||
// Show error UI first, before checking loading/missing‐data
|
// Show error UI first, before checking loading/missing‐data
|
||||||
if (itemStatus.isError || streamStatus.isError) {
|
if (itemStatus.isError || streamStatus.isError) {
|
||||||
@@ -1047,61 +887,42 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlayerProvider
|
<OfflineModeProvider isOffline={offline}>
|
||||||
playerRef={videoRef}
|
<PlayerProvider
|
||||||
item={item}
|
playerRef={videoRef}
|
||||||
mediaSource={stream?.mediaSource}
|
item={item}
|
||||||
isVideoLoaded={isVideoLoaded}
|
mediaSource={stream?.mediaSource}
|
||||||
tracksReady={tracksReady}
|
isVideoLoaded={isVideoLoaded}
|
||||||
>
|
tracksReady={tracksReady}
|
||||||
<VideoProvider>
|
downloadedItem={downloadedItem}
|
||||||
<View
|
>
|
||||||
style={{
|
<VideoProvider>
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "black",
|
|
||||||
height: "100%",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
flex: 1,
|
||||||
width: "100%",
|
backgroundColor: "black",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
position: "relative",
|
width: "100%",
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{useVlcPlayer ? (
|
<View
|
||||||
<VlcPlayerView
|
style={{
|
||||||
ref={videoRef as React.RefObject<VlcPlayerViewRef>}
|
display: "flex",
|
||||||
source={vlcVideoSource!}
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
position: "relative",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MpvPlayerView
|
||||||
|
ref={videoRef}
|
||||||
|
source={videoSource}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
onVideoProgress={onProgressVlc}
|
onProgress={onProgress}
|
||||||
onVideoStateChange={onPlaybackStateChangedVlc}
|
onPlaybackStateChange={onPlaybackStateChanged}
|
||||||
onPipStarted={onPipStartedVlc}
|
|
||||||
onVideoLoadEnd={() => setIsVideoLoaded(true)}
|
|
||||||
onVideoError={(e: PlaybackStatePayload) => {
|
|
||||||
console.error("Video Error:", e.nativeEvent);
|
|
||||||
Alert.alert(
|
|
||||||
t("player.error"),
|
|
||||||
t("player.an_error_occured_while_playing_the_video"),
|
|
||||||
);
|
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
|
||||||
}}
|
|
||||||
progressUpdateInterval={1000}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<SfPlayerView
|
|
||||||
ref={videoRef as React.RefObject<SfPlayerViewRef>}
|
|
||||||
source={sfVideoSource}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
onProgress={onProgressSf}
|
|
||||||
onPlaybackStateChange={onPlaybackStateChangedSf}
|
|
||||||
onPictureInPictureChange={onPictureInPictureChangeSf}
|
|
||||||
onLoad={() => setIsVideoLoaded(true)}
|
onLoad={() => setIsVideoLoaded(true)}
|
||||||
onError={(e: { nativeEvent: SfOnErrorEventPayload }) => {
|
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
|
||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.error"),
|
t("player.error"),
|
||||||
@@ -1113,57 +934,56 @@ export default function page() {
|
|||||||
setTracksReady(true);
|
setTracksReady(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
{!hasPlaybackStarted && (
|
||||||
{!hasPlaybackStarted && (
|
<View
|
||||||
<View
|
style={{
|
||||||
style={{
|
position: "absolute",
|
||||||
position: "absolute",
|
top: 0,
|
||||||
top: 0,
|
left: 0,
|
||||||
left: 0,
|
right: 0,
|
||||||
right: 0,
|
bottom: 0,
|
||||||
bottom: 0,
|
backgroundColor: "black",
|
||||||
backgroundColor: "black",
|
justifyContent: "center",
|
||||||
justifyContent: "center",
|
alignItems: "center",
|
||||||
alignItems: "center",
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Loader />
|
||||||
<Loader />
|
</View>
|
||||||
</View>
|
)}
|
||||||
|
</View>
|
||||||
|
{isMounted === true && item && !isPipMode && (
|
||||||
|
<Controls
|
||||||
|
mediaSource={stream?.mediaSource}
|
||||||
|
item={item}
|
||||||
|
togglePlay={togglePlay}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
isSeeking={isSeeking}
|
||||||
|
progress={progress}
|
||||||
|
cacheProgress={cacheProgress}
|
||||||
|
isBuffering={isBuffering}
|
||||||
|
showControls={showControls}
|
||||||
|
setShowControls={setShowControls}
|
||||||
|
startPictureInPicture={startPictureInPicture}
|
||||||
|
play={play}
|
||||||
|
pause={pause}
|
||||||
|
seek={seek}
|
||||||
|
enableTrickplay={true}
|
||||||
|
isZoomedToFill={isZoomedToFill}
|
||||||
|
onZoomToggle={handleZoomToggle}
|
||||||
|
api={api}
|
||||||
|
downloadedFiles={downloadedFiles}
|
||||||
|
playbackSpeed={currentPlaybackSpeed}
|
||||||
|
setPlaybackSpeed={handleSetPlaybackSpeed}
|
||||||
|
showTechnicalInfo={showTechnicalInfo}
|
||||||
|
onToggleTechnicalInfo={handleToggleTechnicalInfo}
|
||||||
|
getTechnicalInfo={getTechnicalInfo}
|
||||||
|
playMethod={playMethod}
|
||||||
|
transcodeReasons={transcodeReasons}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
{isMounted === true && item && !isPipMode && (
|
</VideoProvider>
|
||||||
<Controls
|
</PlayerProvider>
|
||||||
mediaSource={stream?.mediaSource}
|
</OfflineModeProvider>
|
||||||
item={item}
|
|
||||||
togglePlay={togglePlay}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
isSeeking={isSeeking}
|
|
||||||
progress={progress}
|
|
||||||
cacheProgress={cacheProgress}
|
|
||||||
isBuffering={isBuffering}
|
|
||||||
showControls={showControls}
|
|
||||||
setShowControls={setShowControls}
|
|
||||||
startPictureInPicture={startPictureInPicture}
|
|
||||||
play={play}
|
|
||||||
pause={pause}
|
|
||||||
seek={seek}
|
|
||||||
enableTrickplay={true}
|
|
||||||
offline={offline}
|
|
||||||
useVlcPlayer={useVlcPlayer}
|
|
||||||
aspectRatio={aspectRatio}
|
|
||||||
setVideoAspectRatio={handleSetVideoAspectRatio}
|
|
||||||
scaleFactor={scaleFactor}
|
|
||||||
setVideoScaleFactor={handleSetVideoScaleFactor}
|
|
||||||
isZoomedToFill={isZoomedToFill}
|
|
||||||
onZoomToggle={handleZoomToggle}
|
|
||||||
api={api}
|
|
||||||
downloadedFiles={downloadedFiles}
|
|
||||||
playbackSpeed={currentPlaybackSpeed}
|
|
||||||
setPlaybackSpeed={handleSetPlaybackSpeed}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</VideoProvider>
|
|
||||||
</PlayerProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
192
app/_layout.tsx
192
app/_layout.tsx
@@ -1,9 +1,10 @@
|
|||||||
import "@/augmentations";
|
import "@/augmentations";
|
||||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
import { onlineManager, QueryClient } from "@tanstack/react-query";
|
||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import * as BackgroundTask from "expo-background-task";
|
import * as BackgroundTask from "expo-background-task";
|
||||||
import * as Device from "expo-device";
|
import * as Device from "expo-device";
|
||||||
@@ -12,6 +13,7 @@ import { GlobalModal } from "@/components/GlobalModal";
|
|||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
|
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
|
||||||
|
import { IntroSheetProvider } from "@/providers/IntroSheetProvider";
|
||||||
import {
|
import {
|
||||||
apiAtom,
|
apiAtom,
|
||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
@@ -20,6 +22,7 @@ import {
|
|||||||
import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
|
import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
|
||||||
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
|
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
@@ -45,7 +48,7 @@ import type {
|
|||||||
NotificationResponse,
|
NotificationResponse,
|
||||||
} from "expo-notifications/build/Notifications.types";
|
} from "expo-notifications/build/Notifications.types";
|
||||||
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
||||||
import { router, Stack, useSegments } from "expo-router";
|
import { Stack, useSegments } from "expo-router";
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
import * as TaskManager from "expo-task-manager";
|
import * as TaskManager from "expo-task-manager";
|
||||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||||
@@ -54,6 +57,7 @@ import { I18nextProvider } from "react-i18next";
|
|||||||
import { Appearance } from "react-native";
|
import { Appearance } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { store } from "@/utils/store";
|
import { store } from "@/utils/store";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
@@ -78,14 +82,9 @@ SplashScreen.setOptions({
|
|||||||
fade: true,
|
fade: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
function redirect(notification: typeof Notifications.Notification) {
|
|
||||||
const url = notification.request.content.data?.url;
|
|
||||||
if (url) {
|
|
||||||
router.push(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function useNotificationObserver() {
|
function useNotificationObserver() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
@@ -96,14 +95,17 @@ function useNotificationObserver() {
|
|||||||
if (!isMounted || !response?.notification) {
|
if (!isMounted || !response?.notification) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
redirect(response?.notification);
|
const url = response?.notification.request.content.data?.url;
|
||||||
|
if (url) {
|
||||||
|
router.push(url);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [router]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
@@ -187,11 +189,29 @@ export default function RootLayout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set up online manager for network-aware query behavior
|
||||||
|
onlineManager.setEventListener((setOnline) => {
|
||||||
|
return NetInfo.addEventListener((state) => {
|
||||||
|
setOnline(!!state.isConnected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30000, // 30 seconds - data is fresh
|
staleTime: 0, // Always stale - triggers background refetch on mount
|
||||||
gcTime: 1000 * 60 * 60 * 24, // 24 hours - keep in cache for persistence
|
gcTime: 1000 * 60 * 60 * 24, // 24 hours - keep in cache for offline
|
||||||
|
networkMode: "offlineFirst", // Return cache first, refetch if online
|
||||||
|
refetchOnMount: true, // Refetch when component mounts
|
||||||
|
refetchOnReconnect: true, // Refetch when network reconnects
|
||||||
|
refetchOnWindowFocus: false, // Not needed for mobile
|
||||||
|
retry: (failureCount) => {
|
||||||
|
if (!onlineManager.isOnline()) return false;
|
||||||
|
return failureCount < 3;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
networkMode: "online", // Only run mutations when online
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -210,6 +230,7 @@ function Layout() {
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const _segments = useSegments();
|
const _segments = useSegments();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
i18n.changeLanguage(
|
i18n.changeLanguage(
|
||||||
@@ -302,9 +323,6 @@ function Layout() {
|
|||||||
responseListener.current =
|
responseListener.current =
|
||||||
Notifications?.addNotificationResponseReceivedListener(
|
Notifications?.addNotificationResponseReceivedListener(
|
||||||
(response: NotificationResponse) => {
|
(response: NotificationResponse) => {
|
||||||
// redirect if internal notification
|
|
||||||
redirect(response?.notification);
|
|
||||||
|
|
||||||
// Currently the notifications supported by the plugin will send data for deep links.
|
// Currently the notifications supported by the plugin will send data for deep links.
|
||||||
const { title, data } = response.notification.request.content;
|
const { title, data } = response.notification.request.content;
|
||||||
writeInfoLog(`Notification ${title} opened`, data);
|
writeInfoLog(`Notification ${title} opened`, data);
|
||||||
@@ -364,75 +382,79 @@ function Layout() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<JellyfinProvider>
|
<JellyfinProvider>
|
||||||
<NetworkStatusProvider>
|
<ServerUrlProvider>
|
||||||
<PlaySettingsProvider>
|
<NetworkStatusProvider>
|
||||||
<LogProvider>
|
<PlaySettingsProvider>
|
||||||
<WebSocketProvider>
|
<LogProvider>
|
||||||
<DownloadProvider>
|
<WebSocketProvider>
|
||||||
<MusicPlayerProvider>
|
<DownloadProvider>
|
||||||
<GlobalModalProvider>
|
<MusicPlayerProvider>
|
||||||
<BottomSheetModalProvider>
|
<GlobalModalProvider>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<BottomSheetModalProvider>
|
||||||
<SystemBars style='light' hidden={false} />
|
<IntroSheetProvider>
|
||||||
<Stack initialRouteName='(auth)/(tabs)'>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack.Screen
|
<SystemBars style='light' hidden={false} />
|
||||||
name='(auth)/(tabs)'
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
options={{
|
<Stack.Screen
|
||||||
headerShown: false,
|
name='(auth)/(tabs)'
|
||||||
title: "",
|
options={{
|
||||||
header: () => null,
|
headerShown: false,
|
||||||
}}
|
title: "",
|
||||||
/>
|
header: () => null,
|
||||||
<Stack.Screen
|
}}
|
||||||
name='(auth)/player'
|
/>
|
||||||
options={{
|
<Stack.Screen
|
||||||
headerShown: false,
|
name='(auth)/player'
|
||||||
title: "",
|
options={{
|
||||||
header: () => null,
|
headerShown: false,
|
||||||
}}
|
title: "",
|
||||||
/>
|
header: () => null,
|
||||||
<Stack.Screen
|
}}
|
||||||
name='(auth)/now-playing'
|
/>
|
||||||
options={{
|
<Stack.Screen
|
||||||
headerShown: false,
|
name='(auth)/now-playing'
|
||||||
presentation: "modal",
|
options={{
|
||||||
gestureEnabled: true,
|
headerShown: false,
|
||||||
}}
|
presentation: "modal",
|
||||||
/>
|
gestureEnabled: true,
|
||||||
<Stack.Screen
|
}}
|
||||||
name='login'
|
/>
|
||||||
options={{
|
<Stack.Screen
|
||||||
headerShown: true,
|
name='login'
|
||||||
title: "",
|
options={{
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerShown: true,
|
||||||
}}
|
title: "",
|
||||||
/>
|
headerTransparent: Platform.OS === "ios",
|
||||||
<Stack.Screen name='+not-found' />
|
}}
|
||||||
</Stack>
|
/>
|
||||||
<Toaster
|
<Stack.Screen name='+not-found' />
|
||||||
duration={4000}
|
</Stack>
|
||||||
toastOptions={{
|
<Toaster
|
||||||
style: {
|
duration={4000}
|
||||||
backgroundColor: "#262626",
|
toastOptions={{
|
||||||
borderColor: "#363639",
|
style: {
|
||||||
borderWidth: 1,
|
backgroundColor: "#262626",
|
||||||
},
|
borderColor: "#363639",
|
||||||
titleStyle: {
|
borderWidth: 1,
|
||||||
color: "white",
|
},
|
||||||
},
|
titleStyle: {
|
||||||
}}
|
color: "white",
|
||||||
closeButton
|
},
|
||||||
/>
|
}}
|
||||||
<GlobalModal />
|
closeButton
|
||||||
</ThemeProvider>
|
/>
|
||||||
</BottomSheetModalProvider>
|
<GlobalModal />
|
||||||
</GlobalModalProvider>
|
</ThemeProvider>
|
||||||
</MusicPlayerProvider>
|
</IntroSheetProvider>
|
||||||
</DownloadProvider>
|
</BottomSheetModalProvider>
|
||||||
</WebSocketProvider>
|
</GlobalModalProvider>
|
||||||
</LogProvider>
|
</MusicPlayerProvider>
|
||||||
</PlaySettingsProvider>
|
</DownloadProvider>
|
||||||
</NetworkStatusProvider>
|
</WebSocketProvider>
|
||||||
|
</LogProvider>
|
||||||
|
</PlaySettingsProvider>
|
||||||
|
</NetworkStatusProvider>
|
||||||
|
</ServerUrlProvider>
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
</PersistQueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
134
app/login.tsx
134
app/login.tsx
@@ -10,6 +10,7 @@ import {
|
|||||||
Keyboard,
|
Keyboard,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
|
Switch,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
@@ -20,8 +21,13 @@ import { Input } from "@/components/common/Input";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
|
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
|
import type {
|
||||||
|
AccountSecurityType,
|
||||||
|
SavedServer,
|
||||||
|
} from "@/utils/secureCredentials";
|
||||||
|
|
||||||
const CredentialsSchema = z.object({
|
const CredentialsSchema = z.object({
|
||||||
username: z.string().min(1, t("login.username_required")),
|
username: z.string().min(1, t("login.username_required")),
|
||||||
@@ -31,8 +37,14 @@ const Login: React.FC = () => {
|
|||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const { setServer, login, removeServer, initiateQuickConnect } =
|
const {
|
||||||
useJellyfin();
|
setServer,
|
||||||
|
login,
|
||||||
|
removeServer,
|
||||||
|
initiateQuickConnect,
|
||||||
|
loginWithSavedCredential,
|
||||||
|
loginWithPassword,
|
||||||
|
} = useJellyfin();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
apiUrl: _apiUrl,
|
apiUrl: _apiUrl,
|
||||||
@@ -52,6 +64,14 @@ const Login: React.FC = () => {
|
|||||||
password: _password || "",
|
password: _password || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Save account state
|
||||||
|
const [saveAccount, setSaveAccount] = useState(false);
|
||||||
|
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||||
|
const [pendingLogin, setPendingLogin] = useState<{
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A way to auto login based on a link
|
* A way to auto login based on a link
|
||||||
*/
|
*/
|
||||||
@@ -96,12 +116,34 @@ const Login: React.FC = () => {
|
|||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
Keyboard.dismiss();
|
Keyboard.dismiss();
|
||||||
|
|
||||||
|
const result = CredentialsSchema.safeParse(credentials);
|
||||||
|
if (!result.success) return;
|
||||||
|
|
||||||
|
if (saveAccount) {
|
||||||
|
// Show save account modal to choose security type
|
||||||
|
setPendingLogin({
|
||||||
|
username: credentials.username,
|
||||||
|
password: credentials.password,
|
||||||
|
});
|
||||||
|
setShowSaveModal(true);
|
||||||
|
} else {
|
||||||
|
// Login without saving
|
||||||
|
await performLogin(credentials.username, credentials.password);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const performLogin = async (
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
options?: {
|
||||||
|
saveAccount?: boolean;
|
||||||
|
securityType?: AccountSecurityType;
|
||||||
|
pinCode?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = CredentialsSchema.safeParse(credentials);
|
await login(username, password, serverName, options);
|
||||||
if (result.success) {
|
|
||||||
await login(credentials.username, credentials.password);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
Alert.alert(t("login.connection_failed"), error.message);
|
Alert.alert(t("login.connection_failed"), error.message);
|
||||||
@@ -113,6 +155,44 @@ const Login: React.FC = () => {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setPendingLogin(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAccountConfirm = async (
|
||||||
|
securityType: AccountSecurityType,
|
||||||
|
pinCode?: string,
|
||||||
|
) => {
|
||||||
|
setShowSaveModal(false);
|
||||||
|
if (pendingLogin) {
|
||||||
|
await performLogin(pendingLogin.username, pendingLogin.password, {
|
||||||
|
saveAccount: true,
|
||||||
|
securityType,
|
||||||
|
pinCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickLoginWithSavedCredential = async (
|
||||||
|
serverUrl: string,
|
||||||
|
userId: string,
|
||||||
|
) => {
|
||||||
|
await loginWithSavedCredential(serverUrl, userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordLogin = async (
|
||||||
|
serverUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
) => {
|
||||||
|
await loginWithPassword(serverUrl, username, password);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddAccount = (server: SavedServer) => {
|
||||||
|
// Server is already selected, go to credential entry
|
||||||
|
setServer({ address: server.address });
|
||||||
|
if (server.name) {
|
||||||
|
setServerName(server.name);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -274,7 +354,8 @@ const Login: React.FC = () => {
|
|||||||
keyboardType='default'
|
keyboardType='default'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
textContentType='oneTimeCode'
|
autoCorrect={false}
|
||||||
|
textContentType='username'
|
||||||
clearButtonMode='while-editing'
|
clearButtonMode='while-editing'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
extraClassName='mb-4'
|
extraClassName='mb-4'
|
||||||
@@ -380,9 +461,12 @@ const Login: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<PreviousServersList
|
<PreviousServersList
|
||||||
onServerSelect={async (s: any) => {
|
onServerSelect={async (s) => {
|
||||||
await handleConnect(s.address);
|
await handleConnect(s.address);
|
||||||
}}
|
}}
|
||||||
|
onQuickLogin={handleQuickLoginWithSavedCredential}
|
||||||
|
onPasswordLogin={handlePasswordLogin}
|
||||||
|
onAddAccount={handleAddAccount}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -430,9 +514,8 @@ const Login: React.FC = () => {
|
|||||||
keyboardType='default'
|
keyboardType='default'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
// Changed from username to oneTimeCode because it is a known issue in RN
|
autoCorrect={false}
|
||||||
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
|
textContentType='username'
|
||||||
textContentType='oneTimeCode'
|
|
||||||
clearButtonMode='while-editing'
|
clearButtonMode='while-editing'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
@@ -460,6 +543,21 @@ const Login: React.FC = () => {
|
|||||||
clearButtonMode='while-editing'
|
clearButtonMode='while-editing'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setSaveAccount(!saveAccount)}
|
||||||
|
className='flex flex-row items-center py-2'
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={saveAccount}
|
||||||
|
onValueChange={setSaveAccount}
|
||||||
|
trackColor={{ false: "#3f3f46", true: Colors.primary }}
|
||||||
|
thumbColor='white'
|
||||||
|
/>
|
||||||
|
<Text className='ml-3 text-neutral-300'>
|
||||||
|
{t("save_account.save_for_later")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
<View className='flex flex-row items-center justify-between'>
|
<View className='flex flex-row items-center justify-between'>
|
||||||
<Button
|
<Button
|
||||||
onPress={handleLogin}
|
onPress={handleLogin}
|
||||||
@@ -535,11 +633,25 @@ const Login: React.FC = () => {
|
|||||||
onServerSelect={async (s) => {
|
onServerSelect={async (s) => {
|
||||||
await handleConnect(s.address);
|
await handleConnect(s.address);
|
||||||
}}
|
}}
|
||||||
|
onQuickLogin={handleQuickLoginWithSavedCredential}
|
||||||
|
onPasswordLogin={handlePasswordLogin}
|
||||||
|
onAddAccount={handleAddAccount}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
{/* Save Account Modal */}
|
||||||
|
<SaveAccountModal
|
||||||
|
visible={showSaveModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowSaveModal(false);
|
||||||
|
setPendingLogin(null);
|
||||||
|
}}
|
||||||
|
onSave={handleSaveAccountConfirm}
|
||||||
|
username={pendingLogin?.username || credentials.username}
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
@@ -1,4 +1,3 @@
|
|||||||
export * from "./api";
|
export * from "./api";
|
||||||
export * from "./mmkv";
|
export * from "./mmkv";
|
||||||
export * from "./number";
|
export * from "./number";
|
||||||
export * from "./string";
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ declare global {
|
|||||||
bytesToReadable(decimals?: number): string;
|
bytesToReadable(decimals?: number): string;
|
||||||
secondsToMilliseconds(): number;
|
secondsToMilliseconds(): number;
|
||||||
minutesToMilliseconds(): number;
|
minutesToMilliseconds(): number;
|
||||||
hoursToMilliseconds(): number;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,8 +27,4 @@ Number.prototype.minutesToMilliseconds = function () {
|
|||||||
return this.valueOf() * (60).secondsToMilliseconds();
|
return this.valueOf() * (60).secondsToMilliseconds();
|
||||||
};
|
};
|
||||||
|
|
||||||
Number.prototype.hoursToMilliseconds = function () {
|
|
||||||
return this.valueOf() * (60).minutesToMilliseconds();
|
|
||||||
};
|
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
declare global {
|
|
||||||
interface String {
|
|
||||||
toTitle(): string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String.prototype.toTitle = function () {
|
|
||||||
return this.replaceAll("_", " ").replace(
|
|
||||||
/\w\S*/g,
|
|
||||||
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export {};
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.3.5/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"**/*",
|
"**/*",
|
||||||
|
|||||||
223
components/AccountsSheet.tsx
Normal file
223
components/AccountsSheet.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Swipeable } from "react-native-gesture-handler";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import {
|
||||||
|
deleteAccountCredential,
|
||||||
|
type SavedServer,
|
||||||
|
type SavedServerAccount,
|
||||||
|
} from "@/utils/secureCredentials";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
|
interface AccountsSheetProps {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
server: SavedServer | null;
|
||||||
|
onAccountSelect: (account: SavedServerAccount) => void;
|
||||||
|
onAddAccount: () => void;
|
||||||
|
onAccountDeleted?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccountsSheet: React.FC<AccountsSheetProps> = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
server,
|
||||||
|
onAccountSelect,
|
||||||
|
onAddAccount,
|
||||||
|
onAccountDeleted,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
|
const isAndroid = Platform.OS === "android";
|
||||||
|
const snapPoints = useMemo(
|
||||||
|
() => (isAndroid ? ["100%"] : ["50%"]),
|
||||||
|
[isAndroid],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
bottomSheetModalRef.current?.present();
|
||||||
|
} else {
|
||||||
|
bottomSheetModalRef.current?.dismiss();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleSheetChanges = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index === -1) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteAccount = async (account: SavedServerAccount) => {
|
||||||
|
if (!server) return;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
t("server.remove_saved_login"),
|
||||||
|
t("server.remove_account_description", { username: account.username }),
|
||||||
|
[
|
||||||
|
{ text: t("common.cancel"), style: "cancel" },
|
||||||
|
{
|
||||||
|
text: t("common.remove"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
await deleteAccountCredential(server.address, account.userId);
|
||||||
|
onAccountDeleted?.();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSecurityIcon = (
|
||||||
|
securityType: SavedServerAccount["securityType"],
|
||||||
|
): keyof typeof Ionicons.glyphMap => {
|
||||||
|
switch (securityType) {
|
||||||
|
case "pin":
|
||||||
|
return "keypad";
|
||||||
|
case "password":
|
||||||
|
return "lock-closed";
|
||||||
|
default:
|
||||||
|
return "key";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRightActions = (account: SavedServerAccount) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleDeleteAccount(account)}
|
||||||
|
className='bg-red-600 justify-center items-center px-5'
|
||||||
|
>
|
||||||
|
<Ionicons name='trash' size={20} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!server) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
onChange={handleSheetChanges}
|
||||||
|
handleIndicatorStyle={{ backgroundColor: "white" }}
|
||||||
|
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
>
|
||||||
|
<BottomSheetView
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
paddingBottom: Math.max(16, insets.bottom),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='flex-1'>
|
||||||
|
{/* Header */}
|
||||||
|
<View className='mb-4'>
|
||||||
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
|
{t("server.select_account")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-400 mt-1'>
|
||||||
|
{server.name || server.address}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Account List */}
|
||||||
|
<View className='bg-neutral-800 rounded-xl overflow-hidden mb-4'>
|
||||||
|
{server.accounts.map((account, index) => (
|
||||||
|
<Swipeable
|
||||||
|
key={account.userId}
|
||||||
|
renderRightActions={() => renderRightActions(account)}
|
||||||
|
overshootRight={false}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
setOpen(false);
|
||||||
|
onAccountSelect(account);
|
||||||
|
}}
|
||||||
|
className={`flex-row items-center p-4 bg-neutral-800 ${
|
||||||
|
index < server.accounts.length - 1
|
||||||
|
? "border-b border-neutral-700"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<View className='w-10 h-10 bg-neutral-700 rounded-full items-center justify-center mr-3'>
|
||||||
|
<Ionicons name='person' size={20} color='white' />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Account Info */}
|
||||||
|
<View className='flex-1'>
|
||||||
|
<Text className='text-neutral-100 font-medium'>
|
||||||
|
{account.username}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-500 text-sm'>
|
||||||
|
{account.securityType === "none"
|
||||||
|
? t("save_account.no_protection")
|
||||||
|
: account.securityType === "pin"
|
||||||
|
? t("save_account.pin_code")
|
||||||
|
: t("save_account.password")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Security Icon */}
|
||||||
|
<Ionicons
|
||||||
|
name={getSecurityIcon(account.securityType)}
|
||||||
|
size={18}
|
||||||
|
color={Colors.primary}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Swipeable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Hint */}
|
||||||
|
<Text className='text-xs text-neutral-500 mb-4 ml-1'>
|
||||||
|
{t("server.swipe_to_remove")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Add Account Button */}
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
setOpen(false);
|
||||||
|
onAddAccount();
|
||||||
|
}}
|
||||||
|
color='purple'
|
||||||
|
>
|
||||||
|
<View className='flex-row items-center justify-center'>
|
||||||
|
<Ionicons name='add' size={20} color='white' />
|
||||||
|
<Text className='text-white font-semibold ml-2'>
|
||||||
|
{t("server.add_account")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { View, type ViewProps } from "react-native";
|
import { Platform, StyleSheet, View, type ViewProps } from "react-native";
|
||||||
|
import { GlassEffectView } from "react-native-glass-effect-view";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
@@ -13,6 +14,30 @@ export const Badge: React.FC<Props> = ({
|
|||||||
variant = "purple",
|
variant = "purple",
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const content = (
|
||||||
|
<View style={styles.content}>
|
||||||
|
{iconLeft && <View style={styles.iconLeft}>{iconLeft}</View>}
|
||||||
|
<Text
|
||||||
|
className={`
|
||||||
|
text-xs
|
||||||
|
${variant === "purple" && "text-white"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
return (
|
||||||
|
<View {...props} style={[styles.container, props.style]}>
|
||||||
|
<GlassEffectView style={{ borderRadius: 100 }}>
|
||||||
|
{content}
|
||||||
|
</GlassEffectView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
{...props}
|
{...props}
|
||||||
@@ -34,3 +59,23 @@ export const Badge: React.FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
overflow: "hidden",
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
flexShrink: 1,
|
||||||
|
flexGrow: 0,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 50,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
iconLeft: {
|
||||||
|
marginRight: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { Platform, TouchableOpacity } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
import GoogleCast, {
|
import GoogleCast, {
|
||||||
CastButton,
|
CastButton,
|
||||||
CastContext,
|
CastContext,
|
||||||
@@ -44,7 +45,7 @@ export function Chromecast({
|
|||||||
|
|
||||||
if (Platform.OS === "ios") {
|
if (Platform.OS === "ios") {
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
className='mr-4'
|
className='mr-4'
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
@@ -54,7 +55,7 @@ export function Chromecast({
|
|||||||
>
|
>
|
||||||
<AndroidCastButton />
|
<AndroidCastButton />
|
||||||
<Feather name='cast' size={22} color={"white"} />
|
<Feather name='cast' size={22} color={"white"} />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import type {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { type Href, router } from "expo-router";
|
import { type Href } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Alert, Platform, Switch, View, type ViewProps } from "react-native";
|
import { Alert, Platform, Switch, View, type ViewProps } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
@@ -62,6 +63,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [queue, _setQueue] = useAtom(queueAtom);
|
const [queue, _setQueue] = useAtom(queueAtom);
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
|
const router = useRouter();
|
||||||
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
|
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
|
||||||
|
|
||||||
const { processes, startBackgroundDownload, downloadedItems } = useDownload();
|
const { processes, startBackgroundDownload, downloadedItems } = useDownload();
|
||||||
@@ -170,9 +172,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
firstItem.Type !== "Episode"
|
firstItem.Type !== "Episode"
|
||||||
? "/downloads"
|
? "/downloads"
|
||||||
: ({
|
: ({
|
||||||
pathname: `/downloads/${firstItem.SeriesId}`,
|
pathname: "/series/[id]",
|
||||||
params: {
|
params: {
|
||||||
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
id: firstItem.SeriesId!,
|
||||||
|
seasonIndex: firstItem.ParentIndexNumber?.toString(),
|
||||||
|
offline: "true",
|
||||||
},
|
},
|
||||||
} as Href),
|
} as Href),
|
||||||
);
|
);
|
||||||
@@ -209,6 +213,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
subtitleStreamIndex: subtitleIndex ?? -1,
|
subtitleStreamIndex: subtitleIndex ?? -1,
|
||||||
maxBitrate: selectedOptions?.bitrate || defaultBitrate,
|
maxBitrate: selectedOptions?.bitrate || defaultBitrate,
|
||||||
deviceId: api.deviceInfo.id,
|
deviceId: api.deviceInfo.id,
|
||||||
|
audioMode: settings?.audioTranscodeMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -236,11 +241,23 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Get the audio/subtitle indices that were used for this download
|
||||||
|
const downloadAudioIndex =
|
||||||
|
itemsNotDownloaded.length > 1
|
||||||
|
? getDefaultPlaySettings(item, settings!).audioIndex
|
||||||
|
: selectedOptions?.audioIndex;
|
||||||
|
const downloadSubtitleIndex =
|
||||||
|
itemsNotDownloaded.length > 1
|
||||||
|
? getDefaultPlaySettings(item, settings!).subtitleIndex
|
||||||
|
: selectedOptions?.subtitleIndex;
|
||||||
|
|
||||||
await startBackgroundDownload(
|
await startBackgroundDownload(
|
||||||
url,
|
url,
|
||||||
item,
|
item,
|
||||||
mediaSource,
|
mediaSource,
|
||||||
selectedOptions?.bitrate || defaultBitrate,
|
selectedOptions?.bitrate || defaultBitrate,
|
||||||
|
downloadAudioIndex,
|
||||||
|
downloadSubtitleIndex,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,203 +0,0 @@
|
|||||||
/**
|
|
||||||
* Example Usage of Global Modal
|
|
||||||
*
|
|
||||||
* This file demonstrates how to use the global modal system from anywhere in your app.
|
|
||||||
* You can delete this file after understanding how it works.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 1: Simple Content Modal
|
|
||||||
*/
|
|
||||||
export const SimpleModalExample = () => {
|
|
||||||
const { showModal } = useGlobalModal();
|
|
||||||
|
|
||||||
const handleOpenModal = () => {
|
|
||||||
showModal(
|
|
||||||
<View className='p-6'>
|
|
||||||
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
|
|
||||||
<Text className='text-white mb-4'>
|
|
||||||
This is a simple modal with just some text content.
|
|
||||||
</Text>
|
|
||||||
<Text className='text-neutral-400'>
|
|
||||||
Swipe down or tap outside to close.
|
|
||||||
</Text>
|
|
||||||
</View>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleOpenModal}
|
|
||||||
className='bg-purple-600 px-4 py-2 rounded-lg'
|
|
||||||
>
|
|
||||||
<Text className='text-white font-semibold'>Open Simple Modal</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 2: Modal with Custom Snap Points
|
|
||||||
*/
|
|
||||||
export const CustomSnapPointsExample = () => {
|
|
||||||
const { showModal } = useGlobalModal();
|
|
||||||
|
|
||||||
const handleOpenModal = () => {
|
|
||||||
showModal(
|
|
||||||
<View className='p-6' style={{ minHeight: 400 }}>
|
|
||||||
<Text className='text-2xl font-bold mb-4 text-white'>
|
|
||||||
Custom Snap Points
|
|
||||||
</Text>
|
|
||||||
<Text className='text-white mb-4'>
|
|
||||||
This modal has custom snap points (25%, 50%, 90%).
|
|
||||||
</Text>
|
|
||||||
<View className='bg-neutral-800 p-4 rounded-lg'>
|
|
||||||
<Text className='text-white'>
|
|
||||||
Try dragging the modal to different heights!
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>,
|
|
||||||
{
|
|
||||||
snapPoints: ["25%", "50%", "90%"],
|
|
||||||
enableDynamicSizing: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleOpenModal}
|
|
||||||
className='bg-blue-600 px-4 py-2 rounded-lg'
|
|
||||||
>
|
|
||||||
<Text className='text-white font-semibold'>Custom Snap Points</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 3: Complex Component in Modal
|
|
||||||
*/
|
|
||||||
const SettingsModalContent = () => {
|
|
||||||
const { hideModal } = useGlobalModal();
|
|
||||||
|
|
||||||
const settings = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: "Notifications",
|
|
||||||
icon: "notifications-outline" as const,
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: "Auto-play",
|
|
||||||
icon: "play-outline" as const,
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='p-6'>
|
|
||||||
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
|
|
||||||
|
|
||||||
{settings.map((setting, index) => (
|
|
||||||
<View
|
|
||||||
key={setting.id}
|
|
||||||
className={`flex-row items-center justify-between py-4 ${
|
|
||||||
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<View className='flex-row items-center gap-3'>
|
|
||||||
<Ionicons name={setting.icon} size={24} color='white' />
|
|
||||||
<Text className='text-white text-lg'>{setting.title}</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
className={`w-12 h-7 rounded-full ${
|
|
||||||
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
|
|
||||||
setting.enabled ? "translate-x-6" : "translate-x-1"
|
|
||||||
}`}
|
|
||||||
style={{ marginTop: 4 }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={hideModal}
|
|
||||||
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
|
|
||||||
>
|
|
||||||
<Text className='text-white font-semibold text-center'>Close</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ComplexModalExample = () => {
|
|
||||||
const { showModal } = useGlobalModal();
|
|
||||||
|
|
||||||
const handleOpenModal = () => {
|
|
||||||
showModal(<SettingsModalContent />);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleOpenModal}
|
|
||||||
className='bg-green-600 px-4 py-2 rounded-lg'
|
|
||||||
>
|
|
||||||
<Text className='text-white font-semibold'>Complex Component</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 4: Modal Triggered from Function (e.g., API response)
|
|
||||||
*/
|
|
||||||
export const useShowSuccessModal = () => {
|
|
||||||
const { showModal } = useGlobalModal();
|
|
||||||
|
|
||||||
return (message: string) => {
|
|
||||||
showModal(
|
|
||||||
<View className='p-6 items-center'>
|
|
||||||
<View className='bg-green-500 rounded-full p-4 mb-4'>
|
|
||||||
<Ionicons name='checkmark' size={48} color='white' />
|
|
||||||
</View>
|
|
||||||
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
|
|
||||||
<Text className='text-white text-center'>{message}</Text>
|
|
||||||
</View>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main Demo Component
|
|
||||||
*/
|
|
||||||
export const GlobalModalDemo = () => {
|
|
||||||
const showSuccess = useShowSuccessModal();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='p-6 gap-4'>
|
|
||||||
<Text className='text-2xl font-bold mb-4 text-white'>
|
|
||||||
Global Modal Examples
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<SimpleModalExample />
|
|
||||||
<CustomSnapPointsExample />
|
|
||||||
<ComplexModalExample />
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => showSuccess("Operation completed successfully!")}
|
|
||||||
className='bg-orange-600 px-4 py-2 rounded-lg'
|
|
||||||
>
|
|
||||||
<Text className='text-white font-semibold'>Show Success Modal</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
// GenreTags.tsx
|
// GenreTags.tsx
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import {
|
import {
|
||||||
|
Platform,
|
||||||
type StyleProp,
|
type StyleProp,
|
||||||
|
StyleSheet,
|
||||||
type TextStyle,
|
type TextStyle,
|
||||||
View,
|
View,
|
||||||
type ViewProps,
|
type ViewProps,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { GlassEffectView } from "react-native-glass-effect-view";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface TagProps {
|
interface TagProps {
|
||||||
@@ -20,6 +23,23 @@ export const Tag: React.FC<
|
|||||||
textStyle?: StyleProp<TextStyle>;
|
textStyle?: StyleProp<TextStyle>;
|
||||||
} & ViewProps
|
} & ViewProps
|
||||||
> = ({ text, textClass, textStyle, ...props }) => {
|
> = ({ text, textClass, textStyle, ...props }) => {
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<GlassEffectView style={styles.glass}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{text}</Text>
|
||||||
|
</View>
|
||||||
|
</GlassEffectView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='bg-neutral-800 rounded-full px-2 py-1' {...props}>
|
<View className='bg-neutral-800 rounded-full px-2 py-1' {...props}>
|
||||||
<Text className={textClass} style={textStyle}>
|
<Text className={textClass} style={textStyle}>
|
||||||
@@ -29,6 +49,16 @@ export const Tag: React.FC<
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
overflow: "hidden",
|
||||||
|
borderRadius: 50,
|
||||||
|
},
|
||||||
|
glass: {
|
||||||
|
borderRadius: 50,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const Tags: React.FC<
|
export const Tags: React.FC<
|
||||||
TagProps & { tagProps?: ViewProps } & ViewProps
|
TagProps & { tagProps?: ViewProps } & ViewProps
|
||||||
> = ({ tags, textClass = "text-xs", tagProps, ...props }) => {
|
> = ({ tags, textClass = "text-xs", tagProps, ...props }) => {
|
||||||
|
|||||||
203
components/IntroSheet.tsx
Normal file
203
components/IntroSheet.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetScrollView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Linking, Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
|
export interface IntroSheetRef {
|
||||||
|
present: () => void;
|
||||||
|
dismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
|
||||||
|
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
present: () => {
|
||||||
|
storage.set("hasShownIntro", true);
|
||||||
|
bottomSheetRef.current?.present();
|
||||||
|
},
|
||||||
|
dismiss: () => {
|
||||||
|
bottomSheetRef.current?.dismiss();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
bottomSheetRef.current?.dismiss();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGoToSettings = useCallback(() => {
|
||||||
|
bottomSheetRef.current?.dismiss();
|
||||||
|
router.push("/settings");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetRef}
|
||||||
|
enableDynamicSizing
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||||
|
handleIndicatorStyle={{ backgroundColor: "#737373" }}
|
||||||
|
>
|
||||||
|
<BottomSheetScrollView
|
||||||
|
style={{
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className={Platform.isTV ? "py-5 space-y-4" : "py-4 space-y-6"}>
|
||||||
|
<View>
|
||||||
|
<Text className='text-3xl font-bold text-center mb-2'>
|
||||||
|
{t("home.intro.welcome_to_streamyfin")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-center'>
|
||||||
|
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text className='text-lg font-bold'>
|
||||||
|
{t("home.intro.features_title")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-xs'>
|
||||||
|
{t("home.intro.features_description")}
|
||||||
|
</Text>
|
||||||
|
<View className='flex flex-row items-center mt-4'>
|
||||||
|
<Image
|
||||||
|
source={require("@/assets/icons/seerr-logo.svg")}
|
||||||
|
style={{
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View className='shrink ml-2'>
|
||||||
|
<Text className='font-bold mb-1'>Seerr</Text>
|
||||||
|
<Text className='shrink text-xs'>
|
||||||
|
{t("home.intro.seerr_feature_description")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{!Platform.isTV && (
|
||||||
|
<>
|
||||||
|
<View className='flex flex-row items-center mt-4'>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
}}
|
||||||
|
className='flex items-center justify-center'
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='cloud-download-outline'
|
||||||
|
size={32}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className='shrink ml-2'>
|
||||||
|
<Text className='font-bold mb-1'>
|
||||||
|
{t("home.intro.downloads_feature_title")}
|
||||||
|
</Text>
|
||||||
|
<Text className='shrink text-xs'>
|
||||||
|
{t("home.intro.downloads_feature_description")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className='flex flex-row items-center mt-4'>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
}}
|
||||||
|
className='flex items-center justify-center'
|
||||||
|
>
|
||||||
|
<Feather name='cast' size={28} color={"white"} />
|
||||||
|
</View>
|
||||||
|
<View className='shrink ml-2'>
|
||||||
|
<Text className='font-bold mb-1'>Chromecast</Text>
|
||||||
|
<Text className='shrink text-xs'>
|
||||||
|
{t("home.intro.chromecast_feature_description")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<View className='flex flex-row items-center mt-4'>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
}}
|
||||||
|
className='flex items-center justify-center'
|
||||||
|
>
|
||||||
|
<Feather name='settings' size={28} color={"white"} />
|
||||||
|
</View>
|
||||||
|
<View className='shrink ml-2'>
|
||||||
|
<Text className='font-bold mb-1'>
|
||||||
|
{t("home.intro.centralized_settings_plugin_title")}
|
||||||
|
</Text>
|
||||||
|
<View className='flex-row flex-wrap items-baseline'>
|
||||||
|
<Text className='shrink text-xs'>
|
||||||
|
{t(
|
||||||
|
"home.intro.centralized_settings_plugin_description",
|
||||||
|
)}{" "}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
Linking.openURL(
|
||||||
|
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-xs text-purple-600 underline'>
|
||||||
|
{t("home.intro.read_more")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Button onPress={handleDismiss} className='mt-4'>
|
||||||
|
{t("home.intro.done_button")}
|
||||||
|
</Button>
|
||||||
|
<TouchableOpacity onPress={handleGoToSettings} className='mt-4'>
|
||||||
|
<Text className='text-purple-600 text-center'>
|
||||||
|
{t("home.intro.go_to_settings_button")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ height: insets.bottom }} />
|
||||||
|
</View>
|
||||||
|
</BottomSheetScrollView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
IntroSheet.displayName = "IntroSheet";
|
||||||
@@ -26,6 +26,7 @@ import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
|||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { AddToFavorites } from "./AddToFavorites";
|
import { AddToFavorites } from "./AddToFavorites";
|
||||||
@@ -45,13 +46,13 @@ export type SelectedOptions = {
|
|||||||
|
|
||||||
interface ItemContentProps {
|
interface ItemContentProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
isOffline: boolean;
|
|
||||||
itemWithSources?: BaseItemDto | null;
|
itemWithSources?: BaseItemDto | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||||
({ item, isOffline, itemWithSources }) => {
|
({ item, itemWithSources }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
const isOffline = useOfflineMode();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -228,7 +229,6 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
<PlayButton
|
<PlayButton
|
||||||
selectedOptions={selectedOptions}
|
selectedOptions={selectedOptions}
|
||||||
item={item}
|
item={item}
|
||||||
isOffline={isOffline}
|
|
||||||
colors={itemColors}
|
colors={itemColors}
|
||||||
/>
|
/>
|
||||||
<View className='w-1' />
|
<View className='w-1' />
|
||||||
@@ -243,11 +243,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
<SeasonEpisodesCarousel
|
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||||
item={item}
|
|
||||||
loading={loading}
|
|
||||||
isOffline={isOffline}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOffline &&
|
{!isOffline &&
|
||||||
@@ -264,7 +260,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
<CurrentSeries item={item} className='mb-2' />
|
<CurrentSeries item={item} className='mb-2' />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ItemPeopleSections item={item} isOffline={isOffline} />
|
<ItemPeopleSections item={item} />
|
||||||
|
|
||||||
{!isOffline && <SimilarItems itemId={item.Id} />}
|
{!isOffline && <SimilarItems itemId={item.Id} />}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
|
||||||
|
const POSTER_CAROUSEL_HEIGHT = 220;
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
actorId: string;
|
actorId: string;
|
||||||
actorName?: string | null;
|
actorName?: string | null;
|
||||||
|
|||||||
231
components/PINEntryModal.tsx
Normal file
231
components/PINEntryModal.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Animated,
|
||||||
|
Keyboard,
|
||||||
|
Platform,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { verifyAccountPIN } from "@/utils/secureCredentials";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
import { PinInput } from "./inputs/PinInput";
|
||||||
|
|
||||||
|
interface PINEntryModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
onForgotPIN?: () => void;
|
||||||
|
serverUrl: string;
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PINEntryModal: React.FC<PINEntryModalProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
onForgotPIN,
|
||||||
|
serverUrl,
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
const [pinCode, setPinCode] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isVerifying, setIsVerifying] = useState(false);
|
||||||
|
const shakeAnimation = useRef(new Animated.Value(0)).current;
|
||||||
|
const errorHaptic = useHaptic("error");
|
||||||
|
const successHaptic = useHaptic("success");
|
||||||
|
|
||||||
|
const isAndroid = Platform.OS === "android";
|
||||||
|
const snapPoints = useMemo(
|
||||||
|
() => (isAndroid ? ["100%"] : ["50%"]),
|
||||||
|
[isAndroid],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
bottomSheetModalRef.current?.present();
|
||||||
|
setPinCode("");
|
||||||
|
setError(null);
|
||||||
|
} else {
|
||||||
|
bottomSheetModalRef.current?.dismiss();
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const handleSheetChanges = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index === -1) {
|
||||||
|
setPinCode("");
|
||||||
|
setError(null);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const shake = () => {
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(shakeAnimation, {
|
||||||
|
toValue: 10,
|
||||||
|
duration: 50,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(shakeAnimation, {
|
||||||
|
toValue: -10,
|
||||||
|
duration: 50,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(shakeAnimation, {
|
||||||
|
toValue: 10,
|
||||||
|
duration: 50,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(shakeAnimation, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 50,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePinChange = async (value: string) => {
|
||||||
|
setPinCode(value);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Auto-verify when 4 digits entered
|
||||||
|
if (value.length === 4) {
|
||||||
|
setIsVerifying(true);
|
||||||
|
try {
|
||||||
|
const isValid = await verifyAccountPIN(serverUrl, userId, value);
|
||||||
|
if (isValid) {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
successHaptic();
|
||||||
|
onSuccess();
|
||||||
|
setPinCode("");
|
||||||
|
} else {
|
||||||
|
errorHaptic();
|
||||||
|
setError(t("pin.invalid_pin"));
|
||||||
|
shake();
|
||||||
|
setPinCode("");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorHaptic();
|
||||||
|
setError(t("pin.invalid_pin"));
|
||||||
|
shake();
|
||||||
|
setPinCode("");
|
||||||
|
} finally {
|
||||||
|
setIsVerifying(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleForgotPIN = () => {
|
||||||
|
Alert.alert(t("pin.forgot_pin"), t("pin.forgot_pin_desc"), [
|
||||||
|
{ text: t("common.cancel"), style: "cancel" },
|
||||||
|
{
|
||||||
|
text: t("common.continue"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => {
|
||||||
|
onClose();
|
||||||
|
onForgotPIN?.();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
onChange={handleSheetChanges}
|
||||||
|
handleIndicatorStyle={{ backgroundColor: "white" }}
|
||||||
|
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
|
||||||
|
keyboardBlurBehavior='restore'
|
||||||
|
android_keyboardInputMode='adjustResize'
|
||||||
|
topInset={isAndroid ? 0 : undefined}
|
||||||
|
>
|
||||||
|
<BottomSheetView
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
paddingBottom: Math.max(16, insets.bottom),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='flex-1'>
|
||||||
|
{/* Header */}
|
||||||
|
<View className='mb-6'>
|
||||||
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
|
{t("pin.enter_pin")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-400 mt-1'>
|
||||||
|
{t("pin.enter_pin_for", { username })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* PIN Input */}
|
||||||
|
<Animated.View
|
||||||
|
style={{ transform: [{ translateX: shakeAnimation }] }}
|
||||||
|
className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 mb-4'
|
||||||
|
>
|
||||||
|
<PinInput
|
||||||
|
value={pinCode}
|
||||||
|
onChangeText={handlePinChange}
|
||||||
|
length={4}
|
||||||
|
style={{ paddingHorizontal: 16 }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<Text className='text-red-500 text-center mt-3'>{error}</Text>
|
||||||
|
)}
|
||||||
|
{isVerifying && (
|
||||||
|
<Text className='text-neutral-400 text-center mt-3'>
|
||||||
|
{t("common.verifying") || "Verifying..."}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Forgot PIN */}
|
||||||
|
<TouchableOpacity onPress={handleForgotPIN} className='mb-4'>
|
||||||
|
<Text className='text-purple-400 text-center'>
|
||||||
|
{t("pin.forgot_pin")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Cancel Button */}
|
||||||
|
<Button onPress={onClose} color='black'>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
185
components/PasswordEntryModal.tsx
Normal file
185
components/PasswordEntryModal.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetTextInput,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActivityIndicator, Platform, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
|
interface PasswordEntryModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (password: string) => Promise<void>;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
username,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const errorHaptic = useHaptic("error");
|
||||||
|
|
||||||
|
const isAndroid = Platform.OS === "android";
|
||||||
|
const snapPoints = useMemo(
|
||||||
|
() => (isAndroid ? ["100%"] : ["50%"]),
|
||||||
|
[isAndroid],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
bottomSheetModalRef.current?.present();
|
||||||
|
setPassword("");
|
||||||
|
setError(null);
|
||||||
|
} else {
|
||||||
|
bottomSheetModalRef.current?.dismiss();
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const handleSheetChanges = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index === -1) {
|
||||||
|
setPassword("");
|
||||||
|
setError(null);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!password) {
|
||||||
|
setError(t("password.enter_password"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSubmit(password);
|
||||||
|
setPassword("");
|
||||||
|
} catch {
|
||||||
|
errorHaptic();
|
||||||
|
setError(t("password.invalid_password"));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
onChange={handleSheetChanges}
|
||||||
|
handleIndicatorStyle={{ backgroundColor: "white" }}
|
||||||
|
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
|
||||||
|
keyboardBlurBehavior='restore'
|
||||||
|
android_keyboardInputMode='adjustResize'
|
||||||
|
topInset={isAndroid ? 0 : undefined}
|
||||||
|
>
|
||||||
|
<BottomSheetView
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
paddingBottom: Math.max(16, insets.bottom),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='flex-1'>
|
||||||
|
{/* Header */}
|
||||||
|
<View className='mb-6'>
|
||||||
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
|
{t("password.enter_password")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-400 mt-1'>
|
||||||
|
{t("password.enter_password_for", { username })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Password Input */}
|
||||||
|
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 mb-4'>
|
||||||
|
<Text className='text-neutral-400 text-sm mb-2'>
|
||||||
|
{t("login.password")}
|
||||||
|
</Text>
|
||||||
|
<BottomSheetTextInput
|
||||||
|
value={password}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setPassword(text);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
placeholder={t("login.password")}
|
||||||
|
placeholderTextColor='#6B7280'
|
||||||
|
secureTextEntry
|
||||||
|
autoFocus
|
||||||
|
autoCapitalize='none'
|
||||||
|
autoCorrect={false}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#1F2937",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
color: "white",
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
onSubmitEditing={handleSubmit}
|
||||||
|
returnKeyType='done'
|
||||||
|
/>
|
||||||
|
{error && <Text className='text-red-500 mt-2'>{error}</Text>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<View className='flex-row gap-3'>
|
||||||
|
<Button
|
||||||
|
onPress={onClose}
|
||||||
|
color='black'
|
||||||
|
className='flex-1'
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onPress={handleSubmit}
|
||||||
|
color='purple'
|
||||||
|
className='flex-1'
|
||||||
|
disabled={isLoading || !password}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator size='small' color='white' />
|
||||||
|
) : (
|
||||||
|
t("login.login")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -25,7 +25,14 @@ export type ToggleOption = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Option = RadioOption | ToggleOption;
|
export type ActionOption = {
|
||||||
|
type: "action";
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Option = RadioOption | ToggleOption | ActionOption;
|
||||||
|
|
||||||
// Option group structure
|
// Option group structure
|
||||||
export type OptionGroup = {
|
export type OptionGroup = {
|
||||||
@@ -64,7 +71,10 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
|||||||
isLast,
|
isLast,
|
||||||
}) => {
|
}) => {
|
||||||
const isToggle = option.type === "toggle";
|
const isToggle = option.type === "toggle";
|
||||||
const handlePress = isToggle ? option.onToggle : option.onPress;
|
const isAction = option.type === "action";
|
||||||
|
const handlePress = isToggle
|
||||||
|
? option.onToggle
|
||||||
|
: (option as RadioOption | ActionOption).onPress;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -76,7 +86,7 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
|||||||
<Text className='flex-1 text-white'>{option.label}</Text>
|
<Text className='flex-1 text-white'>{option.label}</Text>
|
||||||
{isToggle ? (
|
{isToggle ? (
|
||||||
<ToggleSwitch value={option.value} />
|
<ToggleSwitch value={option.value} />
|
||||||
) : option.selected ? (
|
) : isAction ? null : (option as RadioOption).selected ? (
|
||||||
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
|
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
|
||||||
) : (
|
) : (
|
||||||
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
|
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
|
||||||
@@ -150,6 +160,15 @@ const BottomSheetContent: React.FC<{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (option.type === "action") {
|
||||||
|
return {
|
||||||
|
...option,
|
||||||
|
onPress: () => {
|
||||||
|
option.onPress();
|
||||||
|
onClose?.();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
return option;
|
return option;
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -225,6 +244,9 @@ const PlatformDropdownComponent = ({
|
|||||||
const toggleOptions = group.options.filter(
|
const toggleOptions = group.options.filter(
|
||||||
(opt) => opt.type === "toggle",
|
(opt) => opt.type === "toggle",
|
||||||
) as ToggleOption[];
|
) as ToggleOption[];
|
||||||
|
const actionOptions = group.options.filter(
|
||||||
|
(opt) => opt.type === "action",
|
||||||
|
) as ActionOption[];
|
||||||
|
|
||||||
const items = [];
|
const items = [];
|
||||||
|
|
||||||
@@ -291,6 +313,21 @@ const PlatformDropdownComponent = ({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add Buttons for action options (no icon)
|
||||||
|
actionOptions.forEach((option, optionIndex) => {
|
||||||
|
items.push(
|
||||||
|
<Button
|
||||||
|
key={`action-${groupIndex}-${optionIndex}`}
|
||||||
|
onPress={() => {
|
||||||
|
option.onPress();
|
||||||
|
}}
|
||||||
|
disabled={option.disabled}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
})}
|
})}
|
||||||
</ContextMenu.Items>
|
</ContextMenu.Items>
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
|
|||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import { BottomSheetView } from "@gorhom/bottom-sheet";
|
import { BottomSheetView } from "@gorhom/bottom-sheet";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
CastButton,
|
CastButton,
|
||||||
|
MediaStreamType,
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
@@ -23,11 +23,13 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||||
@@ -43,7 +45,6 @@ import type { SelectedOptions } from "./ItemContent";
|
|||||||
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
selectedOptions: SelectedOptions;
|
selectedOptions: SelectedOptions;
|
||||||
isOffline?: boolean;
|
|
||||||
colors?: ThemeColors;
|
colors?: ThemeColors;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,9 +54,9 @@ const MIN_PLAYBACK_WIDTH = 15;
|
|||||||
export const PlayButton: React.FC<Props> = ({
|
export const PlayButton: React.FC<Props> = ({
|
||||||
item,
|
item,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
isOffline,
|
|
||||||
colors,
|
colors,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
|
const isOffline = useOfflineMode();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
@@ -185,11 +186,23 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate start time in seconds from playback position
|
||||||
|
const startTimeSeconds =
|
||||||
|
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
|
||||||
|
|
||||||
|
// Calculate stream duration in seconds from runtime
|
||||||
|
const streamDurationSeconds = item.RunTimeTicks
|
||||||
|
? item.RunTimeTicks / 10000000
|
||||||
|
: undefined;
|
||||||
|
|
||||||
client
|
client
|
||||||
.loadMedia({
|
.loadMedia({
|
||||||
mediaInfo: {
|
mediaInfo: {
|
||||||
|
contentId: item.Id,
|
||||||
contentUrl: data?.url,
|
contentUrl: data?.url,
|
||||||
contentType: "video/mp4",
|
contentType: "video/mp4",
|
||||||
|
streamType: MediaStreamType.BUFFERED,
|
||||||
|
streamDuration: streamDurationSeconds,
|
||||||
metadata:
|
metadata:
|
||||||
item.Type === "Episode"
|
item.Type === "Episode"
|
||||||
? {
|
? {
|
||||||
@@ -241,7 +254,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
startTime: 0,
|
startTime: startTimeSeconds,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// state is already set when reopening current media, so skip it here.
|
// state is already set when reopening current media, so skip it here.
|
||||||
@@ -287,6 +300,19 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
// Check if item is downloaded
|
// Check if item is downloaded
|
||||||
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
|
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
|
||||||
|
|
||||||
|
// If already in offline mode, play downloaded file directly
|
||||||
|
if (isOffline && downloadedItem) {
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id!,
|
||||||
|
offline: "true",
|
||||||
|
playbackPosition:
|
||||||
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
|
});
|
||||||
|
goToPlayer(queryParams.toString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If online but file is downloaded, ask user which version to play
|
||||||
if (downloadedItem) {
|
if (downloadedItem) {
|
||||||
if (Platform.OS === "android") {
|
if (Platform.OS === "android") {
|
||||||
// Show bottom sheet for Android
|
// Show bottom sheet for Android
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
@@ -14,6 +13,7 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { RoundButton } from "./RoundButton";
|
|||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
items: BaseItemDto[];
|
items: BaseItemDto[];
|
||||||
isOffline?: boolean;
|
|
||||||
size?: "default" | "large";
|
size?: "default" | "large";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,266 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { ActivityIndicator, Alert, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Swipeable } from "react-native-gesture-handler";
|
||||||
import { useMMKVString } from "react-native-mmkv";
|
import { useMMKVString } from "react-native-mmkv";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import {
|
||||||
|
deleteAccountCredential,
|
||||||
|
getPreviousServers,
|
||||||
|
removeServerFromList,
|
||||||
|
type SavedServer,
|
||||||
|
type SavedServerAccount,
|
||||||
|
} from "@/utils/secureCredentials";
|
||||||
|
import { AccountsSheet } from "./AccountsSheet";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
import { ListGroup } from "./list/ListGroup";
|
import { ListGroup } from "./list/ListGroup";
|
||||||
import { ListItem } from "./list/ListItem";
|
import { ListItem } from "./list/ListItem";
|
||||||
|
import { PasswordEntryModal } from "./PasswordEntryModal";
|
||||||
interface Server {
|
import { PINEntryModal } from "./PINEntryModal";
|
||||||
address: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PreviousServersListProps {
|
interface PreviousServersListProps {
|
||||||
onServerSelect: (server: Server) => void;
|
onServerSelect: (server: SavedServer) => void;
|
||||||
|
onQuickLogin?: (serverUrl: string, userId: string) => Promise<void>;
|
||||||
|
onPasswordLogin?: (
|
||||||
|
serverUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
onAddAccount?: (server: SavedServer) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||||
onServerSelect,
|
onServerSelect,
|
||||||
|
onQuickLogin,
|
||||||
|
onPasswordLogin,
|
||||||
|
onAddAccount,
|
||||||
}) => {
|
}) => {
|
||||||
const [_previousServers, setPreviousServers] =
|
const [_previousServers, setPreviousServers] =
|
||||||
useMMKVString("previousServers");
|
useMMKVString("previousServers");
|
||||||
|
const [loadingServer, setLoadingServer] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Modal states
|
||||||
|
const [accountsSheetOpen, setAccountsSheetOpen] = useState(false);
|
||||||
|
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [pinModalVisible, setPinModalVisible] = useState(false);
|
||||||
|
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
|
||||||
|
const [selectedAccount, setSelectedAccount] =
|
||||||
|
useState<SavedServerAccount | null>(null);
|
||||||
|
|
||||||
const previousServers = useMemo(() => {
|
const previousServers = useMemo(() => {
|
||||||
return JSON.parse(_previousServers || "[]") as Server[];
|
return JSON.parse(_previousServers || "[]") as SavedServer[];
|
||||||
}, [_previousServers]);
|
}, [_previousServers]);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const refreshServers = () => {
|
||||||
|
const servers = getPreviousServers();
|
||||||
|
setPreviousServers(JSON.stringify(servers));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAccountLogin = async (
|
||||||
|
server: SavedServer,
|
||||||
|
account: SavedServerAccount,
|
||||||
|
) => {
|
||||||
|
switch (account.securityType) {
|
||||||
|
case "none":
|
||||||
|
// Quick login without protection
|
||||||
|
if (onQuickLogin) {
|
||||||
|
setLoadingServer(server.address);
|
||||||
|
try {
|
||||||
|
await onQuickLogin(server.address, account.userId);
|
||||||
|
} catch {
|
||||||
|
Alert.alert(
|
||||||
|
t("server.session_expired"),
|
||||||
|
t("server.please_login_again"),
|
||||||
|
[{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoadingServer(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "pin":
|
||||||
|
// Show PIN entry modal
|
||||||
|
setSelectedServer(server);
|
||||||
|
setSelectedAccount(account);
|
||||||
|
setPinModalVisible(true);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "password":
|
||||||
|
// Show password entry modal
|
||||||
|
setSelectedServer(server);
|
||||||
|
setSelectedAccount(account);
|
||||||
|
setPasswordModalVisible(true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServerPress = async (server: SavedServer) => {
|
||||||
|
if (loadingServer) return; // Prevent double-tap
|
||||||
|
|
||||||
|
const accountCount = server.accounts?.length || 0;
|
||||||
|
|
||||||
|
if (accountCount === 0) {
|
||||||
|
// No saved accounts, go to manual login
|
||||||
|
onServerSelect(server);
|
||||||
|
} else {
|
||||||
|
// Has accounts, show account sheet (allows adding new account too)
|
||||||
|
setSelectedServer(server);
|
||||||
|
setAccountsSheetOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePinSuccess = async () => {
|
||||||
|
setPinModalVisible(false);
|
||||||
|
if (selectedServer && selectedAccount && onQuickLogin) {
|
||||||
|
setLoadingServer(selectedServer.address);
|
||||||
|
try {
|
||||||
|
await onQuickLogin(selectedServer.address, selectedAccount.userId);
|
||||||
|
} catch {
|
||||||
|
Alert.alert(
|
||||||
|
t("server.session_expired"),
|
||||||
|
t("server.please_login_again"),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: t("common.ok"),
|
||||||
|
onPress: () => onServerSelect(selectedServer),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoadingServer(null);
|
||||||
|
setSelectedAccount(null);
|
||||||
|
setSelectedServer(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordSubmit = async (password: string) => {
|
||||||
|
if (selectedServer && selectedAccount && onPasswordLogin) {
|
||||||
|
await onPasswordLogin(
|
||||||
|
selectedServer.address,
|
||||||
|
selectedAccount.username,
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
setPasswordModalVisible(false);
|
||||||
|
setSelectedAccount(null);
|
||||||
|
setSelectedServer(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleForgotPIN = async () => {
|
||||||
|
if (selectedServer && selectedAccount) {
|
||||||
|
await deleteAccountCredential(
|
||||||
|
selectedServer.address,
|
||||||
|
selectedAccount.userId,
|
||||||
|
);
|
||||||
|
refreshServers();
|
||||||
|
// Go to manual login
|
||||||
|
onServerSelect(selectedServer);
|
||||||
|
setSelectedAccount(null);
|
||||||
|
setSelectedServer(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFirstCredential = async (serverUrl: string) => {
|
||||||
|
const server = previousServers.find((s) => s.address === serverUrl);
|
||||||
|
if (!server || server.accounts.length === 0) return;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
t("server.remove_saved_login"),
|
||||||
|
t("server.remove_saved_login_description"),
|
||||||
|
[
|
||||||
|
{ text: t("common.cancel"), style: "cancel" },
|
||||||
|
{
|
||||||
|
text: t("common.remove"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
// Remove first account
|
||||||
|
await deleteAccountCredential(serverUrl, server.accounts[0].userId);
|
||||||
|
refreshServers();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveServer = useCallback(
|
||||||
|
async (serverUrl: string) => {
|
||||||
|
await removeServerFromList(serverUrl);
|
||||||
|
refreshServers();
|
||||||
|
},
|
||||||
|
[setPreviousServers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderRightActions = useCallback(
|
||||||
|
(serverUrl: string, swipeableRef: React.RefObject<Swipeable | null>) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
swipeableRef.current?.close();
|
||||||
|
handleRemoveServer(serverUrl);
|
||||||
|
}}
|
||||||
|
className='bg-red-600 justify-center items-center px-5'
|
||||||
|
>
|
||||||
|
<Ionicons name='trash' size={20} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
[handleRemoveServer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getServerSubtitle = (server: SavedServer): string | undefined => {
|
||||||
|
const accountCount = server.accounts?.length || 0;
|
||||||
|
|
||||||
|
if (accountCount > 1) {
|
||||||
|
return t("server.accounts_count", { count: accountCount });
|
||||||
|
}
|
||||||
|
if (accountCount === 1) {
|
||||||
|
return `${server.accounts[0].username} • ${t("server.saved")}`;
|
||||||
|
}
|
||||||
|
return server.name ? server.address : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSecurityIcon = (
|
||||||
|
server: SavedServer,
|
||||||
|
): keyof typeof Ionicons.glyphMap | null => {
|
||||||
|
const accountCount = server.accounts?.length || 0;
|
||||||
|
if (accountCount === 0) return null;
|
||||||
|
|
||||||
|
if (accountCount > 1) {
|
||||||
|
return "people";
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = server.accounts[0];
|
||||||
|
switch (account.securityType) {
|
||||||
|
case "pin":
|
||||||
|
return "keypad";
|
||||||
|
case "password":
|
||||||
|
return "lock-closed";
|
||||||
|
default:
|
||||||
|
return "key";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!previousServers.length) return null;
|
if (!previousServers.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<ListGroup title={t("server.previous_servers")} className='mt-4'>
|
<ListGroup title={t("server.previous_servers")} className='mt-4'>
|
||||||
{previousServers.map((s) => (
|
{previousServers.map((s) => (
|
||||||
<ListItem
|
<ServerItem
|
||||||
key={s.address}
|
key={s.address}
|
||||||
onPress={() => onServerSelect(s)}
|
server={s}
|
||||||
title={s.address}
|
loadingServer={loadingServer}
|
||||||
showArrow
|
onPress={() => handleServerPress(s)}
|
||||||
|
onRemoveCredential={() => handleRemoveFirstCredential(s.address)}
|
||||||
|
renderRightActions={renderRightActions}
|
||||||
|
subtitle={getServerSubtitle(s)}
|
||||||
|
securityIcon={getSecurityIcon(s)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -47,6 +271,113 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
|||||||
textColor='red'
|
textColor='red'
|
||||||
/>
|
/>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
<Text className='text-xs text-neutral-500 mt-2 ml-4'>
|
||||||
|
{t("server.swipe_to_remove")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Account Selection Sheet */}
|
||||||
|
<AccountsSheet
|
||||||
|
open={accountsSheetOpen}
|
||||||
|
setOpen={setAccountsSheetOpen}
|
||||||
|
server={selectedServer}
|
||||||
|
onAccountSelect={(account) => {
|
||||||
|
if (selectedServer) {
|
||||||
|
handleAccountLogin(selectedServer, account);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onAddAccount={() => {
|
||||||
|
if (selectedServer && onAddAccount) {
|
||||||
|
onAddAccount(selectedServer);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onAccountDeleted={refreshServers}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* PIN Entry Modal */}
|
||||||
|
<PINEntryModal
|
||||||
|
visible={pinModalVisible}
|
||||||
|
onClose={() => {
|
||||||
|
setPinModalVisible(false);
|
||||||
|
setSelectedAccount(null);
|
||||||
|
setSelectedServer(null);
|
||||||
|
}}
|
||||||
|
onSuccess={handlePinSuccess}
|
||||||
|
onForgotPIN={handleForgotPIN}
|
||||||
|
serverUrl={selectedServer?.address || ""}
|
||||||
|
userId={selectedAccount?.userId || ""}
|
||||||
|
username={selectedAccount?.username || ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Password Entry Modal */}
|
||||||
|
<PasswordEntryModal
|
||||||
|
visible={passwordModalVisible}
|
||||||
|
onClose={() => {
|
||||||
|
setPasswordModalVisible(false);
|
||||||
|
setSelectedAccount(null);
|
||||||
|
setSelectedServer(null);
|
||||||
|
}}
|
||||||
|
onSubmit={handlePasswordSubmit}
|
||||||
|
username={selectedAccount?.username || ""}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface ServerItemProps {
|
||||||
|
server: SavedServer;
|
||||||
|
loadingServer: string | null;
|
||||||
|
onPress: () => void;
|
||||||
|
onRemoveCredential: () => void;
|
||||||
|
renderRightActions: (
|
||||||
|
serverUrl: string,
|
||||||
|
swipeableRef: React.RefObject<Swipeable | null>,
|
||||||
|
) => React.ReactNode;
|
||||||
|
subtitle?: string;
|
||||||
|
securityIcon: keyof typeof Ionicons.glyphMap | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ServerItem: React.FC<ServerItemProps> = ({
|
||||||
|
server,
|
||||||
|
loadingServer,
|
||||||
|
onPress,
|
||||||
|
onRemoveCredential,
|
||||||
|
renderRightActions,
|
||||||
|
subtitle,
|
||||||
|
securityIcon,
|
||||||
|
}) => {
|
||||||
|
const swipeableRef = useRef<Swipeable>(null);
|
||||||
|
const hasAccounts = server.accounts?.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Swipeable
|
||||||
|
ref={swipeableRef}
|
||||||
|
renderRightActions={() =>
|
||||||
|
renderRightActions(server.address, swipeableRef)
|
||||||
|
}
|
||||||
|
overshootRight={false}
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
onPress={onPress}
|
||||||
|
title={server.name || server.address}
|
||||||
|
subtitle={subtitle}
|
||||||
|
showArrow={loadingServer !== server.address}
|
||||||
|
disabled={loadingServer === server.address}
|
||||||
|
>
|
||||||
|
{loadingServer === server.address ? (
|
||||||
|
<ActivityIndicator size='small' color={Colors.primary} />
|
||||||
|
) : hasAccounts && securityIcon ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemoveCredential();
|
||||||
|
}}
|
||||||
|
className='p-1'
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<Ionicons name={securityIcon} size={16} color={Colors.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
</ListItem>
|
||||||
|
</Swipeable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useSeerr } from "@/hooks/useSeerr";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
import type {
|
import type {
|
||||||
@@ -55,23 +55,23 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const JellyserrRatings: React.FC<{
|
export const SeerrRatings: React.FC<{
|
||||||
result: MovieResult | TvResult | TvDetails | MovieDetails;
|
result: MovieResult | TvResult | TvDetails | MovieDetails;
|
||||||
}> = ({ result }) => {
|
}> = ({ result }) => {
|
||||||
const { jellyseerrApi, getMediaType } = useJellyseerr();
|
const { seerrApi, getMediaType } = useSeerr();
|
||||||
|
|
||||||
const mediaType = useMemo(() => getMediaType(result), [result]);
|
const mediaType = useMemo(() => getMediaType(result), [result]);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["jellyseerr", result.id, mediaType, "ratings"],
|
queryKey: ["seerr", result.id, mediaType, "ratings"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return mediaType === MediaType.MOVIE
|
return mediaType === MediaType.MOVIE
|
||||||
? jellyseerrApi?.movieRatings(result.id)
|
? seerrApi?.movieRatings(result.id)
|
||||||
: jellyseerrApi?.tvRatings(result.id);
|
: seerrApi?.tvRatings(result.id);
|
||||||
},
|
},
|
||||||
staleTime: (5).minutesToMilliseconds(),
|
staleTime: (5).minutesToMilliseconds(),
|
||||||
retry: false,
|
retry: false,
|
||||||
enabled: !!jellyseerrApi,
|
enabled: !!seerrApi,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { Platform, TouchableOpacity, type ViewProps } from "react-native";
|
import { Platform, type ViewProps } from "react-native";
|
||||||
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
@@ -38,7 +39,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
|
|
||||||
if (Platform.OS === "ios") {
|
if (Platform.OS === "ios") {
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||||
{...(viewProps as any)}
|
{...(viewProps as any)}
|
||||||
@@ -51,13 +52,13 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{children ? children : null}
|
{children ? children : null}
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fillColor)
|
if (fillColor)
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||||
{...(viewProps as any)}
|
{...(viewProps as any)}
|
||||||
@@ -70,12 +71,12 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{children ? children : null}
|
{children ? children : null}
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (background === false)
|
if (background === false)
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||||
{...(viewProps as any)}
|
{...(viewProps as any)}
|
||||||
@@ -88,12 +89,12 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{children ? children : null}
|
{children ? children : null}
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (Platform.OS === "android")
|
if (Platform.OS === "android")
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${
|
className={`rounded-full ${buttonSize} flex items-center justify-center ${
|
||||||
fillColor ? fillColorClass : "bg-transparent"
|
fillColor ? fillColorClass : "bg-transparent"
|
||||||
@@ -108,11 +109,11 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{children ? children : null}
|
{children ? children : null}
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity onPress={handlePress} {...(viewProps as any)}>
|
<Pressable onPress={handlePress} {...(viewProps as any)}>
|
||||||
<BlurView
|
<BlurView
|
||||||
intensity={90}
|
intensity={90}
|
||||||
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||||
@@ -127,6 +128,6 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
) : null}
|
) : null}
|
||||||
{children ? children : null}
|
{children ? children : null}
|
||||||
</BlurView>
|
</BlurView>
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
252
components/SaveAccountModal.tsx
Normal file
252
components/SaveAccountModal.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import type { AccountSecurityType } from "@/utils/secureCredentials";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
import { PinInput } from "./inputs/PinInput";
|
||||||
|
|
||||||
|
interface SaveAccountModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (securityType: AccountSecurityType, pinCode?: string) => void;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecurityOption {
|
||||||
|
type: AccountSecurityType;
|
||||||
|
titleKey: string;
|
||||||
|
descriptionKey: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECURITY_OPTIONS: SecurityOption[] = [
|
||||||
|
{
|
||||||
|
type: "none",
|
||||||
|
titleKey: "save_account.no_protection",
|
||||||
|
descriptionKey: "save_account.no_protection_desc",
|
||||||
|
icon: "flash-outline",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "pin",
|
||||||
|
titleKey: "save_account.pin_code",
|
||||||
|
descriptionKey: "save_account.pin_code_desc",
|
||||||
|
icon: "keypad-outline",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "password",
|
||||||
|
titleKey: "save_account.password",
|
||||||
|
descriptionKey: "save_account.password_desc",
|
||||||
|
icon: "lock-closed-outline",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
username,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
const [selectedType, setSelectedType] = useState<AccountSecurityType>("none");
|
||||||
|
const [pinCode, setPinCode] = useState("");
|
||||||
|
const [pinError, setPinError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isAndroid = Platform.OS === "android";
|
||||||
|
const snapPoints = useMemo(
|
||||||
|
() => (isAndroid ? ["100%"] : ["70%"]),
|
||||||
|
[isAndroid],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
bottomSheetModalRef.current?.present();
|
||||||
|
} else {
|
||||||
|
bottomSheetModalRef.current?.dismiss();
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const handleSheetChanges = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index === -1) {
|
||||||
|
resetState();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetState = () => {
|
||||||
|
setSelectedType("none");
|
||||||
|
setPinCode("");
|
||||||
|
setPinError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOptionSelect = (type: AccountSecurityType) => {
|
||||||
|
setSelectedType(type);
|
||||||
|
setPinCode("");
|
||||||
|
setPinError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (selectedType === "pin") {
|
||||||
|
if (pinCode.length !== 4) {
|
||||||
|
setPinError(t("pin.enter_4_digits") || "Enter 4 digits");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave("pin", pinCode);
|
||||||
|
} else {
|
||||||
|
onSave(selectedType);
|
||||||
|
}
|
||||||
|
resetState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
resetState();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSave = () => {
|
||||||
|
if (selectedType === "pin") {
|
||||||
|
return pinCode.length === 4;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
onChange={handleSheetChanges}
|
||||||
|
handleIndicatorStyle={{ backgroundColor: "white" }}
|
||||||
|
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
|
||||||
|
keyboardBlurBehavior='restore'
|
||||||
|
android_keyboardInputMode='adjustResize'
|
||||||
|
topInset={isAndroid ? 0 : undefined}
|
||||||
|
>
|
||||||
|
<BottomSheetView
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
paddingBottom: Math.max(16, insets.bottom),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='flex-1'>
|
||||||
|
{/* Header */}
|
||||||
|
<View className='mb-4'>
|
||||||
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
|
{t("save_account.title")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-400 mt-1'>{username}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* PIN Entry Step */}
|
||||||
|
{selectedType === "pin" ? (
|
||||||
|
<View className='flex-1'>
|
||||||
|
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 mb-4'>
|
||||||
|
<Text className='text-neutral-100 text-center text-lg mb-4'>
|
||||||
|
{t("pin.setup_pin")}
|
||||||
|
</Text>
|
||||||
|
<PinInput
|
||||||
|
value={pinCode}
|
||||||
|
onChangeText={setPinCode}
|
||||||
|
length={4}
|
||||||
|
style={{ paddingHorizontal: 16 }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{pinError && (
|
||||||
|
<Text className='text-red-500 text-center mt-3'>
|
||||||
|
{pinError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
/* Security Options */
|
||||||
|
<View className='flex-1'>
|
||||||
|
<Text className='text-neutral-400 mb-3'>
|
||||||
|
{t("save_account.security_option")}
|
||||||
|
</Text>
|
||||||
|
<View className='bg-neutral-800 rounded-xl overflow-hidden'>
|
||||||
|
{SECURITY_OPTIONS.map((option, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={option.type}
|
||||||
|
onPress={() => handleOptionSelect(option.type)}
|
||||||
|
className={`flex-row items-center p-4 ${
|
||||||
|
index < SECURITY_OPTIONS.length - 1
|
||||||
|
? "border-b border-neutral-700"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className='w-10 h-10 bg-neutral-700 rounded-full items-center justify-center mr-3'>
|
||||||
|
<Ionicons name={option.icon} size={20} color='white' />
|
||||||
|
</View>
|
||||||
|
<View className='flex-1'>
|
||||||
|
<Text className='text-neutral-100 font-medium'>
|
||||||
|
{t(option.titleKey)}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-400 text-sm'>
|
||||||
|
{t(option.descriptionKey)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`w-6 h-6 rounded-full border-2 items-center justify-center ${
|
||||||
|
selectedType === option.type
|
||||||
|
? "border-purple-500 bg-purple-500"
|
||||||
|
: "border-neutral-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selectedType === option.type && (
|
||||||
|
<Ionicons name='checkmark' size={14} color='white' />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<View className='flex-row gap-3 mt-4'>
|
||||||
|
<Button onPress={handleCancel} color='black' className='flex-1'>
|
||||||
|
{t("save_account.cancel_button")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onPress={handleSave}
|
||||||
|
color='purple'
|
||||||
|
className='flex-1'
|
||||||
|
disabled={!canSave()}
|
||||||
|
>
|
||||||
|
{t("save_account.save_button")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,8 +6,11 @@ import { useMemo } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
|
||||||
|
const POSTER_CAROUSEL_HEIGHT = 220;
|
||||||
|
|
||||||
import { HorizontalScroll } from "./common/HorizontalScroll";
|
import { HorizontalScroll } from "./common/HorizontalScroll";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { TouchableItemRouter } from "./common/TouchableItemRouter";
|
import { TouchableItemRouter } from "./common/TouchableItemRouter";
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const TrackSheet: React.FC<Props> = ({
|
|||||||
|
|
||||||
const streams = useMemo(
|
const streams = useMemo(
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
||||||
[source],
|
[source, streamType],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedSteam = useMemo(
|
const selectedSteam = useMemo(
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +25,7 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
|
|||||||
@@ -1,42 +1,36 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BlurView, type BlurViewProps } from "expo-blur";
|
import { BlurView, type BlurViewProps } from "expo-blur";
|
||||||
import { useRouter } from "expo-router";
|
import { Platform } from "react-native";
|
||||||
import {
|
import { Pressable, type PressableProps } from "react-native-gesture-handler";
|
||||||
Platform,
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
TouchableOpacity,
|
|
||||||
type TouchableOpacityProps,
|
|
||||||
} from "react-native";
|
|
||||||
|
|
||||||
interface Props extends BlurViewProps {
|
interface Props extends BlurViewProps {
|
||||||
background?: "blur" | "transparent";
|
background?: "blur" | "transparent";
|
||||||
touchableOpacityProps?: TouchableOpacityProps;
|
pressableProps?: Omit<PressableProps, "onPress">;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HeaderBackButton: React.FC<Props> = ({
|
export const HeaderBackButton: React.FC<Props> = ({
|
||||||
background = "transparent",
|
background = "transparent",
|
||||||
touchableOpacityProps,
|
pressableProps,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
if (Platform.OS === "ios") {
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => router.back()}
|
onPress={() => router.back()}
|
||||||
className='flex items-center justify-center w-9 h-9'
|
className='flex items-center justify-center w-9 h-9'
|
||||||
{...touchableOpacityProps}
|
{...pressableProps}
|
||||||
>
|
>
|
||||||
<Ionicons name='arrow-back' size={24} color='white' />
|
<Ionicons name='arrow-back' size={24} color='white' />
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (background === "transparent" && Platform.OS !== "android")
|
if (background === "transparent" && Platform.OS !== "android")
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<Pressable onPress={() => router.back()} {...pressableProps}>
|
||||||
onPress={() => router.back()}
|
|
||||||
{...touchableOpacityProps}
|
|
||||||
>
|
|
||||||
<BlurView
|
<BlurView
|
||||||
{...props}
|
{...props}
|
||||||
intensity={100}
|
intensity={100}
|
||||||
@@ -49,14 +43,14 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => router.back()}
|
onPress={() => router.back()}
|
||||||
className=' rounded-full p-2'
|
className=' rounded-full p-2'
|
||||||
{...touchableOpacityProps}
|
{...pressableProps}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
className='drop-shadow-2xl'
|
className='drop-shadow-2xl'
|
||||||
@@ -64,6 +58,6 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
size={24}
|
size={24}
|
||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import { Image } from "expo-image";
|
|
||||||
import { View } from "react-native";
|
|
||||||
|
|
||||||
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
|
|
||||||
if (!url)
|
|
||||||
return (
|
|
||||||
<View className='p-4 rounded-xl overflow-hidden '>
|
|
||||||
<View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800' />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='p-4 rounded-xl overflow-hidden '>
|
|
||||||
<Image
|
|
||||||
source={{ uri: url }}
|
|
||||||
className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useRouter, useSegments } from "expo-router";
|
import { useSegments } from "expo-router";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { type PropsWithChildren } from "react";
|
import { type PropsWithChildren } from "react";
|
||||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||||
@@ -20,7 +21,7 @@ interface Props extends TouchableOpacityProps {
|
|||||||
mediaType: MediaType;
|
mediaType: MediaType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
export const TouchableSeerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||||
result,
|
result,
|
||||||
mediaTitle,
|
mediaTitle,
|
||||||
releaseYear,
|
releaseYear,
|
||||||
@@ -41,18 +42,24 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
|
|
||||||
router.push({
|
// Build URL with query params - avoids Expo Router's strict type checking
|
||||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
const params = new URLSearchParams({
|
||||||
// @ts-expect-error
|
...Object.fromEntries(
|
||||||
params: {
|
Object.entries(result).map(([key, value]) => [
|
||||||
...result,
|
key,
|
||||||
mediaTitle,
|
String(value ?? ""),
|
||||||
releaseYear,
|
]),
|
||||||
canRequest: canRequest.toString(),
|
),
|
||||||
posterSrc,
|
mediaTitle,
|
||||||
mediaType,
|
releaseYear: releaseYear.toString(),
|
||||||
},
|
canRequest: canRequest.toString(),
|
||||||
|
posterSrc,
|
||||||
|
mediaType: mediaType.toString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.push(
|
||||||
|
`/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/seerr/page?${params.toString()}`,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useRouter, useSegments } from "expo-router";
|
import { useSegments } from "expo-router";
|
||||||
import { type PropsWithChildren, useCallback } from "react";
|
import { type PropsWithChildren, useCallback } from "react";
|
||||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useFavorite } from "@/hooks/useFavorite";
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
isOffline?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const itemRouter = (item: BaseItemDto, from: string) => {
|
export const itemRouter = (item: BaseItemDto, from: string) => {
|
||||||
@@ -82,13 +84,49 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.Type === "CollectionFolder" || item.Type === "Playlist") {
|
if (item.Type === "CollectionFolder") {
|
||||||
return {
|
return {
|
||||||
pathname: "/[libraryId]" as const,
|
pathname: "/[libraryId]" as const,
|
||||||
params: { libraryId: item.Id! },
|
params: { libraryId: item.Id! },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Music types - use shared routes for proper back navigation
|
||||||
|
if (item.Type === "MusicArtist") {
|
||||||
|
return {
|
||||||
|
pathname: "/music/artist/[artistId]" as const,
|
||||||
|
params: { artistId: item.Id! },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "MusicAlbum") {
|
||||||
|
return {
|
||||||
|
pathname: "/music/album/[albumId]" as const,
|
||||||
|
params: { albumId: item.Id! },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "Audio") {
|
||||||
|
// Navigate to the album if available, otherwise to the item page
|
||||||
|
if (item.AlbumId) {
|
||||||
|
return {
|
||||||
|
pathname: "/music/album/[albumId]" as const,
|
||||||
|
params: { albumId: item.AlbumId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pathname: "/items/page" as const,
|
||||||
|
params: { id: item.Id! },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "Playlist") {
|
||||||
|
return {
|
||||||
|
pathname: "/music/playlist/[playlistId]" as const,
|
||||||
|
params: { playlistId: item.Id! },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Default case - items page
|
// Default case - items page
|
||||||
return {
|
return {
|
||||||
pathname: "/items/page" as const,
|
pathname: "/items/page" as const,
|
||||||
@@ -98,26 +136,20 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
|
|||||||
|
|
||||||
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||||
item,
|
item,
|
||||||
isOffline = false,
|
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||||
|
const router = useRouter();
|
||||||
|
const isOffline = useOfflineMode();
|
||||||
|
const { deleteFile } = useDownload();
|
||||||
|
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
// For offline mode, we still need to use query params
|
|
||||||
if (isOffline) {
|
|
||||||
const url = `${itemRouter(item, from)}&offline=true`;
|
|
||||||
router.push(url as any);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force music libraries to navigate via the explicit string route.
|
// Force music libraries to navigate via the explicit string route.
|
||||||
// This avoids losing the dynamic [libraryId] param when going through a nested navigator.
|
// This avoids losing the dynamic [libraryId] param when going through a nested navigator.
|
||||||
if ("CollectionType" in item && item.CollectionType === "music") {
|
if ("CollectionType" in item && item.CollectionType === "music") {
|
||||||
@@ -127,7 +159,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
|
|
||||||
const navigation = getItemNavigation(item, from);
|
const navigation = getItemNavigation(item, from);
|
||||||
router.push(navigation as any);
|
router.push(navigation as any);
|
||||||
}, [from, isOffline, item, router]);
|
}, [from, item, router]);
|
||||||
|
|
||||||
const showActionSheet = useCallback(() => {
|
const showActionSheet = useCallback(() => {
|
||||||
if (
|
if (
|
||||||
@@ -143,14 +175,19 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
"Mark as Played",
|
"Mark as Played",
|
||||||
"Mark as Not Played",
|
"Mark as Not Played",
|
||||||
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
|
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
|
||||||
|
...(isOffline ? ["Delete Download"] : []),
|
||||||
"Cancel",
|
"Cancel",
|
||||||
];
|
];
|
||||||
const cancelButtonIndex = options.length - 1;
|
const cancelButtonIndex = options.length - 1;
|
||||||
|
const destructiveButtonIndex = isOffline
|
||||||
|
? cancelButtonIndex - 1
|
||||||
|
: undefined;
|
||||||
|
|
||||||
showActionSheetWithOptions(
|
showActionSheetWithOptions(
|
||||||
{
|
{
|
||||||
options,
|
options,
|
||||||
cancelButtonIndex,
|
cancelButtonIndex,
|
||||||
|
destructiveButtonIndex,
|
||||||
},
|
},
|
||||||
async (selectedIndex) => {
|
async (selectedIndex) => {
|
||||||
if (selectedIndex === 0) {
|
if (selectedIndex === 0) {
|
||||||
@@ -159,6 +196,8 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
await markAsPlayedStatus(false);
|
await markAsPlayedStatus(false);
|
||||||
} else if (selectedIndex === 2) {
|
} else if (selectedIndex === 2) {
|
||||||
toggleFavorite();
|
toggleFavorite();
|
||||||
|
} else if (isOffline && selectedIndex === 3 && item.Id) {
|
||||||
|
deleteFile(item.Id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -167,6 +206,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
isFavorite,
|
isFavorite,
|
||||||
markAsPlayedStatus,
|
markAsPlayedStatus,
|
||||||
toggleFavorite,
|
toggleFavorite,
|
||||||
|
isOffline,
|
||||||
|
deleteFile,
|
||||||
|
item.Id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { View, type ViewProps } from "react-native";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
width: "32%",
|
|
||||||
}}
|
|
||||||
className='flex flex-col'
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
aspectRatio: "10/15",
|
|
||||||
}}
|
|
||||||
className='w-full bg-neutral-800 mb-2 rounded-lg'
|
|
||||||
/>
|
|
||||||
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
|
||||||
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
|
||||||
<View className='h-2 bg-neutral-800 rounded-full mb-2 w-1/2' />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +10,8 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";
|
import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";
|
||||||
import { JobStatus } from "@/providers/Downloads/types";
|
import { JobStatus } from "@/providers/Downloads/types";
|
||||||
@@ -37,7 +37,7 @@ interface DownloadCardProps extends TouchableOpacityProps {
|
|||||||
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||||
const { cancelDownload } = useDownload();
|
const { cancelDownload } = useDownload();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
|||||||
return (
|
return (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
isOffline={true}
|
|
||||||
onLongPress={showActionSheet}
|
onLongPress={showActionSheet}
|
||||||
className='flex flex-col mb-4'
|
className='flex flex-col mb-4'
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|||||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
}, [showActionSheetWithOptions, handleDeleteFile]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableItemRouter onLongPress={showActionSheet} item={item} isOffline>
|
<TouchableItemRouter onLongPress={showActionSheet} item={item}>
|
||||||
{base64Image ? (
|
{base64Image ? (
|
||||||
<View className='relative w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
|
<View className='relative w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { router } from "expo-router";
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
@@ -14,6 +14,7 @@ import { Text } from "../common/Text";
|
|||||||
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||||
const { deleteItems } = useDownload();
|
const { deleteItems } = useDownload();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
return storage.getString(items[0].SeriesId!);
|
return storage.getString(items[0].SeriesId!);
|
||||||
@@ -46,7 +47,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => router.push(`/downloads/${items[0].SeriesId}`)}
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: "/series/[id]",
|
||||||
|
params: { id: items[0].SeriesId!, offline: "true" },
|
||||||
|
})
|
||||||
|
}
|
||||||
onLongPress={showActionSheet}
|
onLongPress={showActionSheet}
|
||||||
>
|
>
|
||||||
{base64Image ? (
|
{base64Image ? (
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { Api } from "@jellyfin/sdk";
|
|||||||
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
@@ -10,6 +9,7 @@ import { Text, View } from "react-native";
|
|||||||
// PNG ASSET
|
// PNG ASSET
|
||||||
import heart from "@/assets/icons/heart.fill.png";
|
import heart from "@/assets/icons/heart.fill.png";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { InfiniteScrollingCollectionList } from "./InfiniteScrollingCollectionList";
|
import { InfiniteScrollingCollectionList } from "./InfiniteScrollingCollectionList";
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
getUserViewsApi,
|
getUserViewsApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigation, useRouter, useSegments } from "expo-router";
|
import { useNavigation, useSegments } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -21,9 +21,9 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -33,12 +33,16 @@ import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecom
|
|||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
type InfiniteScrollingCollectionListSection = {
|
type InfiniteScrollingCollectionListSection = {
|
||||||
type: "InfiniteScrollingCollectionList";
|
type: "InfiniteScrollingCollectionList";
|
||||||
@@ -48,6 +52,7 @@ type InfiniteScrollingCollectionListSection = {
|
|||||||
orientation?: "horizontal" | "vertical";
|
orientation?: "horizontal" | "vertical";
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
priority?: 1 | 2; // 1 = high priority (loads first), 2 = low priority
|
priority?: 1 | 2; // 1 = high priority (loads first), 2 = low priority
|
||||||
|
parentId?: string; // Library ID for "See All" navigation
|
||||||
};
|
};
|
||||||
|
|
||||||
type MediaListSectionType = {
|
type MediaListSectionType = {
|
||||||
@@ -79,6 +84,21 @@ export const Home = () => {
|
|||||||
} = useNetworkStatus();
|
} = useNetworkStatus();
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
||||||
|
const { showIntro } = useIntroSheet();
|
||||||
|
|
||||||
|
// Show intro modal on first launch
|
||||||
|
useEffect(() => {
|
||||||
|
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
||||||
|
if (!hasShownIntro) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
showIntro();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [showIntro]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isConnected && !prevIsConnected.current) {
|
if (isConnected && !prevIsConnected.current) {
|
||||||
@@ -101,7 +121,7 @@ export const Home = () => {
|
|||||||
}
|
}
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/downloads");
|
router.push("/(auth)/downloads");
|
||||||
}}
|
}}
|
||||||
@@ -113,7 +133,7 @@ export const Home = () => {
|
|||||||
color={hasDownloads ? Colors.primary : "white"}
|
color={hasDownloads ? Colors.primary : "white"}
|
||||||
size={24}
|
size={24}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}, [navigation, router, hasDownloads]);
|
}, [navigation, router, hasDownloads]);
|
||||||
@@ -213,6 +233,7 @@ export const Home = () => {
|
|||||||
},
|
},
|
||||||
type: "InfiniteScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
pageSize,
|
pageSize,
|
||||||
|
parentId,
|
||||||
}),
|
}),
|
||||||
[api, user?.Id],
|
[api, user?.Id],
|
||||||
);
|
);
|
||||||
@@ -616,6 +637,18 @@ export const Home = () => {
|
|||||||
) : null;
|
) : null;
|
||||||
if (section.type === "InfiniteScrollingCollectionList") {
|
if (section.type === "InfiniteScrollingCollectionList") {
|
||||||
const isHighPriority = section.priority === 1;
|
const isHighPriority = section.priority === 1;
|
||||||
|
const handleSeeAll = section.parentId
|
||||||
|
? () => {
|
||||||
|
router.push({
|
||||||
|
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
|
||||||
|
params: {
|
||||||
|
libraryId: section.parentId!,
|
||||||
|
sortBy: SortByOption.DateCreated,
|
||||||
|
sortOrder: SortOrderOption.Descending,
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
return (
|
return (
|
||||||
<View key={index} className='flex flex-col space-y-4'>
|
<View key={index} className='flex flex-col space-y-4'>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
@@ -631,6 +664,7 @@ export const Home = () => {
|
|||||||
? () => markSectionLoaded(section.queryKey)
|
? () => markSectionLoaded(section.queryKey)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
onPressSeeAll={handleSeeAll}
|
||||||
/>
|
/>
|
||||||
{streamystatsSections}
|
{streamystatsSections}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
getUserViewsApi,
|
getUserViewsApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigation, useRouter, useSegments } from "expo-router";
|
import { useNavigation, useSegments } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -35,6 +35,7 @@ import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecom
|
|||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
@@ -568,29 +569,31 @@ export const HomeWithCarousel = () => {
|
|||||||
settings.streamyStatsSeriesRecommendations ||
|
settings.streamyStatsSeriesRecommendations ||
|
||||||
settings.streamyStatsPromotedWatchlists;
|
settings.streamyStatsPromotedWatchlists;
|
||||||
const streamystatsSections =
|
const streamystatsSections =
|
||||||
index === streamystatsIndex && hasStreamystatsContent ? (
|
index === streamystatsIndex && hasStreamystatsContent
|
||||||
<>
|
? [
|
||||||
{settings.streamyStatsMovieRecommendations && (
|
settings.streamyStatsMovieRecommendations && (
|
||||||
<StreamystatsRecommendations
|
<StreamystatsRecommendations
|
||||||
title={t(
|
key='movie-recommendations'
|
||||||
"home.settings.plugins.streamystats.recommended_movies",
|
title={t(
|
||||||
)}
|
"home.settings.plugins.streamystats.recommended_movies",
|
||||||
type='Movie'
|
)}
|
||||||
/>
|
type='Movie'
|
||||||
)}
|
/>
|
||||||
{settings.streamyStatsSeriesRecommendations && (
|
),
|
||||||
<StreamystatsRecommendations
|
settings.streamyStatsSeriesRecommendations && (
|
||||||
title={t(
|
<StreamystatsRecommendations
|
||||||
"home.settings.plugins.streamystats.recommended_series",
|
key='series-recommendations'
|
||||||
)}
|
title={t(
|
||||||
type='Series'
|
"home.settings.plugins.streamystats.recommended_series",
|
||||||
/>
|
)}
|
||||||
)}
|
type='Series'
|
||||||
{settings.streamyStatsPromotedWatchlists && (
|
/>
|
||||||
<StreamystatsPromotedWatchlists />
|
),
|
||||||
)}
|
settings.streamyStatsPromotedWatchlists && (
|
||||||
</>
|
<StreamystatsPromotedWatchlists key='promoted-watchlists' />
|
||||||
) : null;
|
),
|
||||||
|
].filter(Boolean)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (section.type === "InfiniteScrollingCollectionList") {
|
if (section.type === "InfiniteScrollingCollectionList") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user