Compare commits
239 Commits
feature/ne
...
I10n_crowd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f51aa85149 | ||
|
|
7a0f70778d | ||
|
|
6957c4fd64 | ||
|
|
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 | ||
|
|
24d04c1003 | ||
|
|
7da52441ab | ||
|
|
70268e6120 | ||
|
|
96fbb9fe1f | ||
|
|
3b104b91fc | ||
|
|
e4134d6f9a | ||
|
|
5b2e7b3883 | ||
|
|
1fde3c82a3 | ||
|
|
054fb05651 | ||
|
|
a2058a8009 | ||
|
|
d22827bc9b | ||
|
|
4121502bfe | ||
|
|
b6e59aab01 | ||
|
|
ab3465aec5 | ||
|
|
b1da9f8777 | ||
|
|
36d24176ae | ||
|
|
bfdc2c053b | ||
|
|
245c9597c4 | ||
|
|
966a8e8f24 | ||
|
|
f941c88457 | ||
|
|
bd4e5bb70a | ||
|
|
9334263414 | ||
|
|
4ae3c44d02 | ||
|
|
4fb3fb195c | ||
|
|
e8089cfd20 | ||
|
|
039bf9729a | ||
|
|
3ff7c47b7f | ||
|
|
1d8d92175a | ||
|
|
60b0040681 | ||
|
|
9cd55cf544 | ||
|
|
090e0cb170 | ||
|
|
85d707ef45 | ||
|
|
792eef20a9 | ||
|
|
6487c8b5a1 | ||
|
|
baa96d222f | ||
|
|
74d86b5d12 | ||
|
|
d1795c9df8 | ||
|
|
149609f46e | ||
|
|
cf269ba83e | ||
|
|
24d5fdefdf | ||
|
|
c05cef295e | ||
|
|
3c57829360 | ||
|
|
06349a4319 | ||
|
|
55ac9ae9d4 | ||
|
|
c8bdcc4df0 | ||
|
|
e7013edd84 | ||
|
|
991b45de06 | ||
|
|
97fe899cb0 | ||
|
|
86d7642dca | ||
|
|
631a5ef94e | ||
|
|
8b8b928837 | ||
|
|
56a3c62ed2 | ||
|
|
82683407da | ||
|
|
7b146e30bd | ||
|
|
5f48bec0f2 | ||
|
|
94362169b6 | ||
|
|
8aefdac50f | ||
|
|
665a79924a | ||
|
|
b9ddcf8404 | ||
|
|
64ffc8db8b | ||
|
|
2a61124a0d | ||
|
|
36178c2082 | ||
|
|
e1c69a9ec9 | ||
|
|
01110b8d13 | ||
|
|
21034f5671 | ||
|
|
1439bcee0d | ||
|
|
9a906e6d39 | ||
|
|
48de7b7c6d | ||
|
|
85e5c25206 | ||
|
|
3dc84818e8 | ||
|
|
18102a3045 | ||
|
|
2be78a232c | ||
|
|
30dc3980e3 | ||
|
|
f7da29b9c9 | ||
|
|
7a5f0b52b6 | ||
|
|
62dfe7c9e1 | ||
|
|
50d559d528 | ||
|
|
38aba3d67a | ||
|
|
5765793d79 | ||
|
|
222ba13529 | ||
|
|
de6c2072c9 | ||
|
|
76ec8e0e46 | ||
|
|
389d9e2d31 | ||
|
|
485dc6eeac | ||
|
|
154788cf91 | ||
|
|
3e181eca72 | ||
|
|
f5b9e03dd9 | ||
|
|
196f91400b | ||
|
|
51a14c6058 | ||
|
|
5432476ca1 | ||
|
|
9d9ec974ff | ||
|
|
0dadfd3d90 | ||
|
|
96d6220f5e | ||
|
|
b847baa314 | ||
|
|
a4cce27737 | ||
|
|
ce37351099 | ||
|
|
e4e1e556bf | ||
|
|
1ff723b29f | ||
|
|
11d35c846d | ||
|
|
ce82f3044b | ||
|
|
df638dae28 | ||
|
|
9a5e49ae16 | ||
|
|
4f2120f85d | ||
|
|
8eeea35441 | ||
|
|
781464b768 | ||
|
|
61d60c2e74 | ||
|
|
6c722e1fd9 | ||
|
|
e117e98c8e | ||
|
|
f5c791cce4 | ||
|
|
eaebb79f4c | ||
|
|
7e82d9c7b6 | ||
|
|
c62c8ca077 | ||
|
|
25cfcf0735 | ||
|
|
f19b33eff6 | ||
|
|
1c4f54584f | ||
|
|
87d02078c8 | ||
|
|
1e9fba6f85 | ||
|
|
dbcf5759da | ||
|
|
37364b235f | ||
|
|
fcb8e83845 | ||
|
|
16046a4a15 | ||
|
|
13b29bdee0 | ||
|
|
bf4a1f9462 | ||
|
|
264439858d | ||
|
|
6576a58884 | ||
|
|
3eae2f3fde | ||
|
|
deae6ab21a | ||
|
|
5478179367 | ||
|
|
3e20050b64 | ||
|
|
59e9913c78 | ||
|
|
2b2797005a | ||
|
|
c53acb16fc | ||
|
|
53570a5ee5 | ||
|
|
e3b7dd8241 | ||
|
|
786d082706 | ||
|
|
164de0af0d | ||
|
|
820b30b7e2 | ||
|
|
5bc4c4a856 | ||
|
|
f7e0667416 | ||
|
|
8c68283c56 | ||
|
|
bb0149406c | ||
|
|
b1d5630025 | ||
|
|
a5f5531bb9 | ||
|
|
c2a3817fa8 | ||
|
|
700bb2dc79 | ||
|
|
d741ca3ecc | ||
|
|
ae9f6b1ce4 | ||
|
|
cd3f1a8cee | ||
|
|
be745dc136 | ||
|
|
7b6fe0a6c0 | ||
|
|
fc44283f09 | ||
|
|
b2f6edc54e | ||
|
|
b42d033b87 | ||
|
|
1fb166bcd1 | ||
|
|
de6133581b |
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
@@ -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)_
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(find:*)",
|
||||
"Bash(bun install:*)",
|
||||
"Bash(bunx expo prebuild:*)",
|
||||
"Bash(bunx expo run:*)",
|
||||
"Bash(npx expo prebuild:*)",
|
||||
"Bash(npx expo run:*)",
|
||||
"Bash(xcodebuild:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
description: Don't write code directly in the ios folder.
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
We never write code directly in the ios folder. This code is generated by expo plugins.
|
||||
33
.github/CONTRIBUTING.md
vendored
@@ -1,11 +1,13 @@
|
||||
# Contributing to Streamyfin
|
||||
|
||||
Thank you for your interest in contributing to the Streamyfin mobile app project! This document provides guidelines to smoothly collaborate on the Streamyfin codebase and help improve the app for all users.
|
||||
Thank you for your interest in contributing to the Streamyfin project. This document outlines the guidelines for effective collaboration across the Streamyfin codebase and aims to ensure a smooth, productive experience for all contributors.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
|
||||
- [AI Assistance Disclosure](#ai-assistance-disclosure)
|
||||
- [Reporting Issues](#reporting-issues)
|
||||
- [Reporting Security Vulnerabilities](#reporting-security-vulnerabilities)
|
||||
- [Requesting Features & Enhancements](#requesting-features--enhancements)
|
||||
@@ -19,6 +21,25 @@ Thank you for your interest in contributing to the Streamyfin mobile app project
|
||||
|
||||
---
|
||||
|
||||
## AI Assistance Disclosure
|
||||
|
||||
> [!IMPORTANT]
|
||||
> If any AI tool was used while contributing to Streamyfin, it must be disclosed in the pull request.
|
||||
|
||||
State in your PR whether AI assistance was used and to what extent (for example, *docs only* or *code generation*).
|
||||
If AI-generated text was used in PR discussions or responses, disclose that as well.
|
||||
Minor autocomplete or keyword suggestions do not require disclosure.
|
||||
|
||||
### Examples
|
||||
|
||||
> This PR was written primarily by Claude Code.
|
||||
> I used Cursor to explore parts of the codebase, but the implementation is fully manual.
|
||||
|
||||
Failing to disclose AI usage wastes maintainers’ time and complicates review efforts.
|
||||
AI-assisted contributions are welcome, but contributors remain fully responsible for the code they submit.
|
||||
|
||||
Always disclose AI involvement to maintain transparency and respect for maintainers’ time.
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Streamyfin uses GitHub issues to track bugs and improvements. Before opening a new issue:
|
||||
@@ -46,11 +67,11 @@ When creating a new feature request:
|
||||
|
||||
- Check if the idea or similar request exists.
|
||||
- Use reactions like 👍 to support existing requests.
|
||||
- Provide a clear explanation of the use case and benefits.
|
||||
|
||||
- Clearly describe the use case and potential benefits.
|
||||
- Include screenshots when relevant.
|
||||
---
|
||||
|
||||
## Developing the Mobile App
|
||||
## Developing Streamyfin
|
||||
|
||||
### Codebase Overview
|
||||
|
||||
@@ -136,6 +157,8 @@ When opening a PR:
|
||||
- Provide a detailed description in the PR body, explaining what, why, and any impacts.
|
||||
- Include screenshots or recordings if UI changes are involved.
|
||||
- Ensure CI checks are green (lint, type-check, build).
|
||||
- Confirm that the branch is **up to date with `main`** before submission.
|
||||
- Mention if AI-generated code or content was used (see [AI Assistance Disclosure](#ai-assistance-disclosure)).
|
||||
- Do not include secrets, tokens, or production credentials. Redact sensitive data in logs and screenshots.
|
||||
- Keep PRs focused; avoid bundling unrelated changes together.
|
||||
|
||||
@@ -159,4 +182,4 @@ PRs require review and approval by maintainers before merging.---
|
||||
|
||||
---
|
||||
|
||||
Thank you for helping make Streamyfin a better app for everyone!
|
||||
Thank you for contributing to make Streamyfin better for everyone!
|
||||
|
||||
9
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
@@ -77,13 +77,8 @@ body:
|
||||
label: Streamyfin Version
|
||||
description: What version of Streamyfin are you running?
|
||||
options:
|
||||
- 0.47.1
|
||||
- 0.30.2
|
||||
- 0.29.0
|
||||
- 0.28.0
|
||||
- 0.27.0
|
||||
- 0.26.1
|
||||
- 0.26.0
|
||||
- 0.25.0
|
||||
- older
|
||||
- TestFlight/Development build
|
||||
validations:
|
||||
@@ -116,4 +111,4 @@ body:
|
||||
id: additional-info
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Any additional context that might help us understand and reproduce the issue.
|
||||
description: Any additional context that might help us understand and reproduce the issue.
|
||||
|
||||
98
.github/copilot-instructions.md
vendored
@@ -3,58 +3,94 @@
|
||||
## Project Overview
|
||||
|
||||
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
|
||||
It supports mobile (iOS/Android) and TV platforms, and integrates with Jellyfin and Jellyseerr APIs.
|
||||
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Jellyseerr APIs,
|
||||
and provides seamless media streaming with offline capabilities and Chromecast support.
|
||||
|
||||
## Main Technologies
|
||||
|
||||
- React Native (Expo)
|
||||
- TypeScript
|
||||
- React Query
|
||||
- Jotai (state management)
|
||||
- Jellyfin SDK (TypeScript)
|
||||
- BiomeJS (code formatting/linting)
|
||||
- EAS (Expo Application Services)
|
||||
- Shell scripting (for automation)
|
||||
- GitHub Actions (CI/CD)
|
||||
- **Runtime**: Bun (JavaScript/TypeScript execution)
|
||||
- **Framework**: React Native (Expo)
|
||||
- **Language**: TypeScript (strict mode)
|
||||
- **State Management**: Jotai (global state) + React Query (server state)
|
||||
- **API SDK**: Jellyfin SDK (TypeScript)
|
||||
- **Navigation**: Expo Router (file-based routing)
|
||||
- **Code Quality**: BiomeJS (formatting/linting)
|
||||
- **Build Platform**: EAS (Expo Application Services)
|
||||
- **CI/CD**: GitHub Actions with Bun
|
||||
|
||||
## Package Management
|
||||
|
||||
**CRITICAL: ALWAYS use `bun` for all package management operations**
|
||||
|
||||
- **NEVER use `npm`, `yarn` or `npx` commands**
|
||||
- Use `bun install` instead of `npm install` or `yarn install`
|
||||
- Use `bun add <package>` instead of `npm install <package>`
|
||||
- Use `bun remove <package>` instead of `npm uninstall <package>`
|
||||
- Use `bun run <script>` instead of `npm run <script>`
|
||||
- Use `bunx <command>` instead of `npx <command>`
|
||||
- For Expo: use `bunx create-expo-app` or `bunx @expo/cli`
|
||||
|
||||
## Code Structure
|
||||
|
||||
- `app/` – Main application code (screens, navigation, etc.)
|
||||
- `components/` – Reusable UI components
|
||||
- `providers/` – Context and API providers (e.g., JellyfinProvider.tsx)
|
||||
- `utils/` – Utility functions and atoms
|
||||
- `utils/` – Utility functions and Jotai atoms
|
||||
- `assets/` – Images and static assets
|
||||
- `scripts/` – Automation scripts (Node.js, Bash)
|
||||
- `plugins/` – Expo/Metro plugins
|
||||
- `README.md` – Project documentation
|
||||
|
||||
## Coding Conventions
|
||||
## Coding Standards
|
||||
|
||||
- Use TypeScript for all new code.
|
||||
- Prefer functional React components.
|
||||
- Use hooks for state and side effects.
|
||||
- Use Jotai for global state.
|
||||
- Use React Query for data fetching/caching.
|
||||
- Use BiomeJS for formatting and linting.
|
||||
- Follow the established folder structure for screens/components.
|
||||
- Use TypeScript for ALL files (no .js files)
|
||||
- Use descriptive English names for variables, functions, and components
|
||||
- Prefer functional React components with hooks
|
||||
- Use Jotai atoms for global state management
|
||||
- Use React Query for server state and caching
|
||||
- Follow BiomeJS formatting and linting rules
|
||||
- Use `const` over `let`, avoid `var` entirely
|
||||
- Implement proper error boundaries
|
||||
- Use React.memo() for performance optimization
|
||||
- Handle both mobile and TV navigation patterns
|
||||
|
||||
## API Usage
|
||||
## API Integration
|
||||
|
||||
- Use the Jellyfin SDK for all server interactions.
|
||||
- Use the `apiAtom` and `userAtom` from `JellyfinProvider` for authenticated API calls.
|
||||
- For navigation, use `expo-router`.
|
||||
- Use Jellyfin SDK for all server interactions
|
||||
- Access authenticated APIs via `apiAtom` and `userAtom` from JellyfinProvider
|
||||
- Implement proper loading states and error handling
|
||||
- Use React Query for caching and background updates
|
||||
- Handle offline scenarios gracefully
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
- Leverage Bun's superior runtime performance
|
||||
- Optimize FlatList components with proper props
|
||||
- Use lazy loading for non-critical components
|
||||
- Implement proper image caching strategies
|
||||
- Monitor bundle size and use tree-shaking effectively
|
||||
|
||||
## Testing
|
||||
|
||||
- Use Bun's built-in test runner when possible
|
||||
- Test files: `*.test.ts` or `*.test.tsx`
|
||||
- Run tests with: `bun test`
|
||||
- Mock external APIs in tests
|
||||
- Focus on testing business logic and custom hooks
|
||||
|
||||
## Commit Messages
|
||||
|
||||
- Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) (e.g., `feat:`, `fix:`, `chore:`).
|
||||
- Example: `feat(player): add Chromecast support`
|
||||
Use Conventional Commits (https://www.conventionalcommits.org/):
|
||||
Exemples:
|
||||
- `feat(player): add Chromecast support`
|
||||
- `fix(auth): handle expired JWT tokens`
|
||||
- `chore(deps): update Jellyfin SDK`
|
||||
|
||||
## Special Instructions
|
||||
|
||||
- When suggesting code, prefer using existing atoms, hooks, and utility functions.
|
||||
- When adding new features, ensure they are accessible via both mobile and TV navigation if relevant.
|
||||
- When updating dependencies or scripts, check for compatibility with Expo and EAS.
|
||||
|
||||
---
|
||||
- Prioritize cross-platform compatibility (mobile + TV)
|
||||
- Ensure accessibility for TV remote navigation
|
||||
- Use existing atoms, hooks, and utilities before creating new ones
|
||||
- Maintain compatibility with Expo and EAS workflows
|
||||
- Always verify Bun compatibility when suggesting new dependencies
|
||||
|
||||
**Copilot: Please use these instructions to provide context-aware suggestions and code completions for this repository.**
|
||||
2
.github/workflows/artifact-comment.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 🔍 Get PR and Artifacts
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: |
|
||||
// Check if we're running from a fork (more precise detection)
|
||||
|
||||
46
.github/workflows/build-apps.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -29,12 +29,12 @@ jobs:
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 💾 Cache project Gradle (.gradle)
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: android/.gradle
|
||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload APK artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
|
||||
path: |
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -100,12 +100,12 @@ jobs:
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
run: bun run prebuild:tv
|
||||
|
||||
- name: 💾 Cache project Gradle (.gradle)
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: android/.gradle
|
||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
@@ -147,7 +147,7 @@ jobs:
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload APK artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
|
||||
path: |
|
||||
@@ -156,14 +156,14 @@ jobs:
|
||||
|
||||
build-ios-phone:
|
||||
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||
runs-on: macos-15
|
||||
runs-on: macos-26
|
||||
name: 🍎 Build iOS IPA (Phone)
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -171,12 +171,12 @@ jobs:
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
@@ -191,6 +191,11 @@ jobs:
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
||||
with:
|
||||
xcode-version: "26.0.1"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@main
|
||||
with:
|
||||
@@ -210,7 +215,7 @@ jobs:
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload IPA artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
|
||||
path: build-*.ipa
|
||||
@@ -219,7 +224,7 @@ jobs:
|
||||
# Disabled for now - uncomment when ready to build iOS TV
|
||||
# build-ios-tv:
|
||||
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||
# runs-on: macos-15
|
||||
# runs-on: macos-26
|
||||
# name: 🍎 Build iOS IPA (TV)
|
||||
# permissions:
|
||||
# contents: read
|
||||
@@ -254,6 +259,11 @@ jobs:
|
||||
# - name: 🛠️ Generate project files
|
||||
# run: bun run prebuild:tv
|
||||
#
|
||||
# - name: 🔧 Setup Xcode
|
||||
# uses: maxim-lobanov/setup-xcode@v1
|
||||
# with:
|
||||
# xcode-version: '26.0.1'
|
||||
#
|
||||
# - name: 🏗️ Setup EAS
|
||||
# uses: expo/expo-github-action@main
|
||||
# with:
|
||||
|
||||
6
.github/workflows/check-lockfile.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
@@ -27,12 +27,12 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
|
||||
12
.github/workflows/ci-codeql.yml
vendored
@@ -24,20 +24,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
fetch-depth: 0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: 🏁 Initialize CodeQL
|
||||
uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
|
||||
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended,security-and-quality
|
||||
|
||||
- name: 🛠️ Autobuild
|
||||
uses: github/codeql-action/autobuild@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
|
||||
uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
|
||||
- name: 🧪 Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
|
||||
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
|
||||
53
.github/workflows/crowdin.yml
vendored
@@ -1,34 +1,51 @@
|
||||
name: Crowdin Action
|
||||
name: 🌐 Translation Sync
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [develop]
|
||||
paths:
|
||||
- "translations/**"
|
||||
- "crowdin.yml"
|
||||
- "i18n.ts"
|
||||
- ".github/workflows/crowdin.yml"
|
||||
# Run weekly to pull new translations
|
||||
schedule:
|
||||
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
synchronize-with-crowdin:
|
||||
sync-translations:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: 📥 Checkout Repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v2
|
||||
- name: 🌐 Sync Translations with Crowdin
|
||||
uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
download_translations: true
|
||||
localization_branch_name: l10n_crowdin_translations
|
||||
localization_branch_name: I10n_crowdin_translations
|
||||
create_pull_request: true
|
||||
pull_request_title: 'feat: New Crowdin Translations'
|
||||
pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
|
||||
pull_request_base_branch_name: 'develop'
|
||||
pull_request_title: "feat: New Crowdin Translations"
|
||||
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
|
||||
pull_request_base_branch_name: "develop"
|
||||
pull_request_labels: "🌐 translation"
|
||||
# Quality control options
|
||||
skip_untranslated_strings: false
|
||||
skip_untranslated_files: false
|
||||
export_only_approved: false
|
||||
# Commit customization
|
||||
commit_message: "feat(i18n): update translations from Crowdin"
|
||||
env:
|
||||
# A classic GitHub Personal Access Token with the 'repo' scope selected (the user should have write access to the repository).
|
||||
GITHUB_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
|
||||
|
||||
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
|
||||
# Visit https://crowdin.com/settings#api-key to create this token
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
14
.github/workflows/linting.yml
vendored
@@ -51,13 +51,13 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
|
||||
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||
with:
|
||||
fail-on-severity: high
|
||||
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
||||
@@ -69,14 +69,14 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: 🛒 Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
@@ -100,19 +100,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "📥 Checkout PR code"
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: '24.x'
|
||||
|
||||
- name: "🍞 Setup Bun"
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
|
||||
25
.github/workflows/notification.yml
vendored
@@ -1,13 +1,18 @@
|
||||
name: 🛎️ Discord Pull Request Notification
|
||||
name: 🛎️ Discord Notification
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened]
|
||||
branches: [develop]
|
||||
workflow_run:
|
||||
workflows: ["*"]
|
||||
types: [completed]
|
||||
branches: [develop]
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-24.04
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: 🛎️ Notify Discord
|
||||
uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # 0.4.0
|
||||
@@ -21,3 +26,21 @@ jobs:
|
||||
**By:** ${{ github.event.pull_request.user.login }}
|
||||
**Branch:** ${{ github.event.pull_request.head.ref }}
|
||||
🔗 ${{ github.event.pull_request.html_url }}
|
||||
|
||||
notify-on-failure:
|
||||
runs-on: ubuntu-24.04
|
||||
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'failure'
|
||||
steps:
|
||||
- name: 🚨 Notify Discord on Failure
|
||||
uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # 0.4.0
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.WEBHOOK_FAILED_JOB_URL }}
|
||||
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640
|
||||
with:
|
||||
args: |
|
||||
🚨 **Workflow Failed** in **${{ github.repository }}**
|
||||
**Workflow:** ${{ github.event.workflow_run.name }}
|
||||
**Branch:** ${{ github.event.workflow_run.head_branch }}
|
||||
**Triggered by:** ${{ github.event.workflow_run.triggering_actor.login }}
|
||||
**Commit:** ${{ github.event.workflow_run.head_commit.message }}
|
||||
🔗 ${{ github.event.workflow_run.html_url }}
|
||||
|
||||
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@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.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
|
||||
6
.github/workflows/update-issue-form.yml
vendored
@@ -18,10 +18,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: '24.x'
|
||||
cache: 'npm'
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
dry_run: no-push
|
||||
|
||||
- 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:
|
||||
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
||||
branch: ci-update-bug-report
|
||||
|
||||
79
.gitignore
vendored
@@ -1,27 +1,16 @@
|
||||
# Dependencies and Package Managers
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
npm-debug.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
modules/vlc-player/android/build
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
expo-env.d.ts
|
||||
|
||||
Streamyfin.app
|
||||
|
||||
*.mp4
|
||||
Streamyfin.app
|
||||
bun.lock
|
||||
bun.lockb
|
||||
package-lock.json
|
||||
|
||||
# Expo and React Native Build Artifacts
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
.tsbuildinfo
|
||||
|
||||
# Platform-specific Build Directories
|
||||
/ios
|
||||
/android
|
||||
/iostv
|
||||
@@ -29,21 +18,55 @@ package-lock.json
|
||||
/androidmobile
|
||||
/androidtv
|
||||
|
||||
# Module-specific Builds
|
||||
modules/mpv-player/android/build
|
||||
modules/player/android
|
||||
modules/hls-downloader/android/build
|
||||
|
||||
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||
credentials.json
|
||||
# Generated Applications
|
||||
Streamyfin.app
|
||||
*.apk
|
||||
*.ipa
|
||||
.continuerc.json
|
||||
*.aab
|
||||
|
||||
# Certificates and Keys
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Debug and Temporary Files
|
||||
npm-debug.*
|
||||
*.orig.*
|
||||
*.mp4
|
||||
|
||||
# OS-specific Files
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# IDE and Editor Files
|
||||
.vscode/
|
||||
.idea/
|
||||
.ruby-lsp
|
||||
modules/hls-downloader/android/build
|
||||
streamyfin-4fec1-firebase-adminsdk.json
|
||||
.cursor/
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
|
||||
# Environment and Configuration
|
||||
expo-env.d.ts
|
||||
.continuerc.json
|
||||
.env
|
||||
.env.local
|
||||
*.aab
|
||||
|
||||
# Secrets and Credentials
|
||||
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||
credentials.json
|
||||
streamyfin-4fec1-firebase-adminsdk.json
|
||||
|
||||
# Version and Backup Files
|
||||
/version-backup-*
|
||||
bun.lockb
|
||||
modules/background-downloader/android/build/*
|
||||
/modules/sf-player/android/build
|
||||
/modules/music-controls/android/build
|
||||
/modules/mpv-player/android/build
|
||||
|
||||
24
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
// ==========================================
|
||||
// Streamyfin - Recommended VS Code Extensions
|
||||
// ==========================================
|
||||
// Essential extensions for working with Streamyfin
|
||||
// See .github/copilot-instructions.md for coding standards
|
||||
|
||||
"recommendations": [
|
||||
// Code Quality & Formatting
|
||||
"biomejs.biome", // Fast formatter and linter for JavaScript/TypeScript - replaces ESLint + Prettier
|
||||
|
||||
// React Native & Expo
|
||||
"expo.vscode-expo-tools", // Official Expo extension - provides commands, debugging, and config IntelliSense
|
||||
"msjsdiag.vscode-react-native", // React Native debugging and IntelliSense - essential for RN development
|
||||
|
||||
// Developer Experience
|
||||
"bradlc.vscode-tailwindcss", // Tailwind CSS IntelliSense - autocomplete for NativeWind classes
|
||||
"yoavbls.pretty-ts-errors", // Makes TypeScript error messages human-readable with formatting and highlights
|
||||
"usernamehw.errorlens", // Shows errors and warnings inline in the editor - faster debugging
|
||||
|
||||
// Bun Support
|
||||
"oven.bun-vscode" // Official Bun extension - provides debugging and language support for Bun runtime
|
||||
]
|
||||
}
|
||||
35
.vscode/settings.json
vendored
@@ -1,24 +1,25 @@
|
||||
{
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"prettier.printWidth": 120,
|
||||
"[swift]": {
|
||||
"editor.defaultFormatter": "sswg.swift-lang"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"editor.formatOnSaveMode": "file"
|
||||
}
|
||||
|
||||
136
CLAUDE.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# CLAUDE.md
|
||||
|
||||
@.claude/learned-facts.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 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.
|
||||
|
||||
## Development Commands
|
||||
|
||||
**CRITICAL: Always use `bun` for package management. Never use `npm`, `yarn`, or `npx`.**
|
||||
|
||||
```bash
|
||||
# Setup
|
||||
bun i && bun run submodule-reload
|
||||
|
||||
# Development builds
|
||||
bun run prebuild # Mobile prebuild
|
||||
bun run ios # Run iOS
|
||||
bun run android # Run Android
|
||||
|
||||
# TV builds (suffix with :tv)
|
||||
bun run prebuild:tv
|
||||
bun run ios:tv
|
||||
bun run android:tv
|
||||
|
||||
# Code quality
|
||||
bun run typecheck # TypeScript check
|
||||
bun run check # BiomeJS check
|
||||
bun run lint # BiomeJS lint + fix
|
||||
bun run format # BiomeJS format
|
||||
bun run test # Run all checks (typecheck, lint, format, doctor)
|
||||
|
||||
# iOS-specific
|
||||
bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build errors
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime**: Bun
|
||||
- **Framework**: React Native (Expo SDK 54)
|
||||
- **Language**: TypeScript (strict mode)
|
||||
- **State Management**: Jotai (global state atoms) + React Query (server state)
|
||||
- **API**: Jellyfin SDK (`@jellyfin/sdk`)
|
||||
- **Navigation**: Expo Router (file-based)
|
||||
- **Linting/Formatting**: BiomeJS
|
||||
- **Storage**: react-native-mmkv
|
||||
|
||||
## Architecture
|
||||
|
||||
### File Structure
|
||||
|
||||
- `app/` - Expo Router screens with file-based routing
|
||||
- `components/` - Reusable UI components
|
||||
- `providers/` - React Context providers
|
||||
- `hooks/` - Custom React hooks
|
||||
- `utils/` - Utilities including Jotai atoms
|
||||
- `modules/` - Native modules (vlc-player, mpv-player, background-downloader)
|
||||
- `translations/` - i18n translation files
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**State Management**:
|
||||
- Global state uses Jotai atoms in `utils/atoms/`
|
||||
- `settingsAtom` in `utils/atoms/settings.ts` for app settings
|
||||
- `apiAtom` and `userAtom` in `providers/JellyfinProvider.tsx` for auth state
|
||||
- Server state uses React Query with `@tanstack/react-query`
|
||||
|
||||
**Jellyfin API Access**:
|
||||
- Use `apiAtom` from `JellyfinProvider` for authenticated API calls
|
||||
- Access user via `userAtom`
|
||||
- Use Jellyfin SDK utilities from `@jellyfin/sdk/lib/utils/api`
|
||||
|
||||
**Navigation**:
|
||||
- File-based routing in `app/` directory
|
||||
- Tab navigation: `(home)`, `(search)`, `(favorites)`, `(libraries)`, `(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`):
|
||||
1. JotaiProvider
|
||||
2. QueryClientProvider
|
||||
3. JellyfinProvider (auth, API)
|
||||
4. NetworkStatusProvider
|
||||
5. PlaySettingsProvider
|
||||
6. WebSocketProvider
|
||||
7. DownloadProvider
|
||||
8. MusicPlayerProvider
|
||||
|
||||
### Native Modules
|
||||
|
||||
Located in `modules/`:
|
||||
- `vlc-player` - VLC video player integration
|
||||
- `mpv-player` - MPV video player integration (iOS)
|
||||
- `background-downloader` - Background download functionality
|
||||
- `sf-player` - Swift player module
|
||||
|
||||
### Path Aliases
|
||||
|
||||
Use `@/` prefix for imports (configured in `tsconfig.json`):
|
||||
```typescript
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
```
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- Use TypeScript for all files (no .js)
|
||||
- Use functional React components with hooks
|
||||
- Use Jotai atoms for global state, React Query for server state
|
||||
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
||||
- Handle both mobile and TV navigation patterns
|
||||
- Use existing atoms, hooks, and utilities before creating new ones
|
||||
- Use Conventional Commits: `feat(scope):`, `fix(scope):`, `chore(scope):`
|
||||
|
||||
## Platform Considerations
|
||||
|
||||
- TV version uses `:tv` suffix for scripts
|
||||
- Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"`
|
||||
- Some features disabled on TV (e.g., notifications, Chromecast)
|
||||
232
GLOBAL_MODAL_GUIDE.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# Global Modal System with Gorhom Bottom Sheet
|
||||
|
||||
This guide explains how to use the global modal system implemented in this project.
|
||||
|
||||
## Overview
|
||||
|
||||
The global modal system allows you to trigger a bottom sheet modal from anywhere in your app programmatically, and render any component inside it.
|
||||
|
||||
## Architecture
|
||||
|
||||
The system consists of three main parts:
|
||||
|
||||
1. **GlobalModalProvider** (`providers/GlobalModalProvider.tsx`) - Context provider that manages modal state
|
||||
2. **GlobalModal** (`components/GlobalModal.tsx`) - The actual modal component rendered at root level
|
||||
3. **useGlobalModal** hook - Hook to interact with the modal from anywhere
|
||||
|
||||
## Setup (Already Configured)
|
||||
|
||||
The system is already integrated into your app:
|
||||
|
||||
```tsx
|
||||
// In app/_layout.tsx
|
||||
<BottomSheetModalProvider>
|
||||
<GlobalModalProvider>
|
||||
{/* Your app content */}
|
||||
<GlobalModal />
|
||||
</GlobalModalProvider>
|
||||
</BottomSheetModalProvider>
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```tsx
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
import { View, Text } from "react-native";
|
||||
|
||||
function MyComponent() {
|
||||
const { showModal, hideModal } = useGlobalModal();
|
||||
|
||||
const handleOpenModal = () => {
|
||||
showModal(
|
||||
<View className='p-6'>
|
||||
<Text className='text-white text-2xl'>Hello from Modal!</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onPress={handleOpenModal} title="Open Modal" />
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### With Custom Options
|
||||
|
||||
```tsx
|
||||
const handleOpenModal = () => {
|
||||
showModal(
|
||||
<YourCustomComponent />,
|
||||
{
|
||||
snapPoints: ["25%", "50%", "90%"], // Custom snap points
|
||||
enablePanDownToClose: true, // Allow swipe to close
|
||||
backgroundStyle: { // Custom background
|
||||
backgroundColor: "#000000",
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Programmatic Control
|
||||
|
||||
```tsx
|
||||
// Open modal
|
||||
showModal(<Content />);
|
||||
|
||||
// Close modal from within the modal content
|
||||
function ModalContent() {
|
||||
const { hideModal } = useGlobalModal();
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Button onPress={hideModal} title="Close" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Close modal from outside
|
||||
hideModal();
|
||||
```
|
||||
|
||||
### In Event Handlers or Functions
|
||||
|
||||
```tsx
|
||||
function useApiCall() {
|
||||
const { showModal } = useGlobalModal();
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const result = await api.fetch();
|
||||
|
||||
// Show success modal
|
||||
showModal(
|
||||
<SuccessMessage data={result} />
|
||||
);
|
||||
} catch (error) {
|
||||
// Show error modal
|
||||
showModal(
|
||||
<ErrorMessage error={error} />
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return fetchData;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `useGlobalModal()`
|
||||
|
||||
Returns an object with the following properties:
|
||||
|
||||
- **`showModal(content, options?)`** - Show the modal with given content
|
||||
- `content: ReactNode` - Any React component or element to render
|
||||
- `options?: ModalOptions` - Optional configuration object
|
||||
|
||||
- **`hideModal()`** - Programmatically hide the modal
|
||||
|
||||
- **`isVisible: boolean`** - Current visibility state of the modal
|
||||
|
||||
### `ModalOptions`
|
||||
|
||||
```typescript
|
||||
interface ModalOptions {
|
||||
enableDynamicSizing?: boolean; // Auto-size based on content (default: true)
|
||||
snapPoints?: (string | number)[]; // Fixed snap points (e.g., ["50%", "90%"])
|
||||
enablePanDownToClose?: boolean; // Allow swipe down to close (default: true)
|
||||
backgroundStyle?: object; // Custom background styles
|
||||
handleIndicatorStyle?: object; // Custom handle indicator styles
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
|
||||
- Simple content modal
|
||||
- Modal with custom snap points
|
||||
- Complex component in modal
|
||||
- Success/error modals triggered from functions
|
||||
|
||||
## Default Styling
|
||||
|
||||
The modal uses these default styles (can be overridden via options):
|
||||
|
||||
```typescript
|
||||
{
|
||||
enableDynamicSizing: true,
|
||||
enablePanDownToClose: true,
|
||||
backgroundStyle: {
|
||||
backgroundColor: "#171717", // Dark background
|
||||
},
|
||||
handleIndicatorStyle: {
|
||||
backgroundColor: "white",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Keep content in separate components** - Don't inline large JSX in `showModal()` calls
|
||||
2. **Use the hook in custom hooks** - Create specialized hooks like `useShowSuccessModal()` for reusable modal patterns
|
||||
3. **Handle cleanup** - The modal automatically clears content when closed
|
||||
4. **Avoid nesting** - Don't show modals from within modals
|
||||
5. **Consider UX** - Only use for important, contextual information that requires user attention
|
||||
|
||||
## Using with PlatformDropdown
|
||||
|
||||
When using `PlatformDropdown` with option groups, avoid setting a `title` on the `OptionGroup` if you're already passing a `title` prop to `PlatformDropdown`. This prevents nested menu behavior on iOS where users have to click through an extra layer.
|
||||
|
||||
```tsx
|
||||
// Good - No title in option group (title is on PlatformDropdown)
|
||||
const optionGroups: OptionGroup[] = [
|
||||
{
|
||||
options: items.map((item) => ({
|
||||
type: "radio",
|
||||
label: item.name,
|
||||
value: item,
|
||||
selected: item.id === selected?.id,
|
||||
onPress: () => onChange(item),
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
<PlatformDropdown
|
||||
groups={optionGroups}
|
||||
title="Select Item" // Title here
|
||||
// ...
|
||||
/>
|
||||
|
||||
// Bad - Causes nested menu on iOS
|
||||
const optionGroups: OptionGroup[] = [
|
||||
{
|
||||
title: "Items", // This creates a nested Picker on iOS
|
||||
options: items.map((item) => ({
|
||||
type: "radio",
|
||||
label: item.name,
|
||||
value: item,
|
||||
selected: item.id === selected?.id,
|
||||
onPress: () => onChange(item),
|
||||
})),
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Modal doesn't appear
|
||||
- Ensure `GlobalModalProvider` is above the component calling `useGlobalModal()`
|
||||
- Check that `BottomSheetModalProvider` is present in the tree
|
||||
- Verify `GlobalModal` component is rendered
|
||||
|
||||
### Content is cut off
|
||||
- Use `enableDynamicSizing: true` for auto-sizing
|
||||
- Or specify appropriate `snapPoints`
|
||||
|
||||
### Modal won't close
|
||||
- Ensure `enablePanDownToClose` is `true`
|
||||
- Check that backdrop is clickable
|
||||
- Use `hideModal()` for programmatic closing
|
||||
169
README.md
@@ -5,146 +5,142 @@
|
||||
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
|
||||
</p>
|
||||
|
||||
**Streamyfin is a simple, 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.**
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img src="./assets/images/screenshots/screenshot1.png" width="22%">
|
||||
<img src="./assets/images/screenshots/screenshot1.png" width="20%">
|
||||
|
||||
<img src="./assets/images/screenshots/screenshot3.png" width="22%">
|
||||
<img src="./assets/images/screenshots/screenshot3.png" width="20%">
|
||||
|
||||
<img src="./assets/images/screenshots/screenshot2.png" width="22%">
|
||||
<img src="./assets/images/screenshots/screenshot2.png" width="20%">
|
||||
|
||||
<img src="./assets/images/jellyseerr.PNG" width="23%">
|
||||
<img src="./assets/images/jellyseerr.PNG" width="21%">
|
||||
</p>
|
||||
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
- 🚀 **Skip Intro / Credits Support**
|
||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
||||
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
|
||||
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
||||
- 👁️ **Sessions View:** View all active sessions currently streaming on your server.
|
||||
- 🚀 **Skip Intro / Credits Support**: Lets you quickly skip intros and credits during playback
|
||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking
|
||||
- 📥 **Download media**: Save your media locally and watch it offline
|
||||
- ⚙️ **Settings management**: Manage app configurations for all users through our plugin
|
||||
- 🤖 **Seerr (formerly Jellyseerr) integration**: Request media directly in the app
|
||||
- 👁️ **Sessions view:** View all active sessions currently streaming on your server
|
||||
- 📡 **Chromecast**: Cast your media to any Chromecast-enabled device
|
||||
|
||||
## 🧪 Experimental Features
|
||||
|
||||
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These features are still in development, and your patience and feedback are much appreciated as we work to improve them.
|
||||
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.
|
||||
|
||||
### 📥 Downloading
|
||||
|
||||
Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
||||
|
||||
### 🎥 Chromecast
|
||||
|
||||
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos, but we're working on adding support for subtitles and other features.
|
||||
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
|
||||
|
||||
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:
|
||||
|
||||
- Auto log in to Jellyseerr without the user having to do anything
|
||||
- Choose the default languages
|
||||
- Set download method and search provider
|
||||
- Customize home screen
|
||||
- And much more...
|
||||
- Automatic Seerr login with no user input required
|
||||
- Set your preferred default languages
|
||||
- Configure download method and search provider
|
||||
- Personalize your home screen
|
||||
- And much more
|
||||
|
||||
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
||||
|
||||
### 📡 Chromecast
|
||||
|
||||
Chromecast support is currently under development. Video casting is already available, and we're actively working on adding subtitle support and additional features.
|
||||
|
||||
### 🎬 MPV Player
|
||||
|
||||
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.
|
||||
Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building the native MPV module in Streamyfin.
|
||||
|
||||
### 🔍 Jellysearch
|
||||
|
||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin!
|
||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin
|
||||
|
||||
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
||||
|
||||
## 🛣️ Roadmap for V1
|
||||
## 🛣️ 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.
|
||||
|
||||
## 📥 Get it now
|
||||
## 📥 Download Streamyfin
|
||||
|
||||
<div style="display: flex; gap: 5px;">
|
||||
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get Streamyfin on Google Play Store" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
||||
<a href="https://github.com/streamyfin/streamyfin/releases/latest"><img height=50 alt="Get Streamyfin on Github" src="./assets/Download_on_Github_.png"/></a>
|
||||
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/streamyfin/streamyfin"><img height=50 alt="Add Streamyfin to Obtainium" src="./assets/Download_with_Obtainium.png"/></a>
|
||||
</div>
|
||||
|
||||
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
|
||||
### 🧪 Beta Testing
|
||||
|
||||
### 🧪 Beta testing
|
||||
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This grants you immediate access to the 🧪-beta-releases channel on Discord and lets me know you’ve subscribed. This is where I share APKs and IPAs. It does not provide automatic TestFlight access, so please send me a DM (Cagemaster) with the email you use for Apple so we can add you manually.
|
||||
|
||||
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and I'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that I can manually add you.
|
||||
|
||||
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
||||
**Note**: Anyone actively contributing to Streamyfin’s source code will receive automatic access to beta releases.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
### ⚙️ Prerequisites
|
||||
|
||||
- Ensure you have an active Jellyfin server.
|
||||
- Make sure your device is connected to the same network as your Jellyfin server.
|
||||
- Your device is on the same network as the Jellyfin server (for local connections)
|
||||
- Your Jellyfin server is up and running with remote access enabled if you plan to connect from outside your local network
|
||||
- Your server version is up to date (older versions may cause compatibility issues)
|
||||
- You have a valid Jellyfin user account with access to the media libraries you want to view
|
||||
- If using features such as **downloads** or **Seerr integration**, confirm the required plugins are installed and configured on your Jellyfin server
|
||||
|
||||
## 🙌 Contributing
|
||||
|
||||
We welcome any help to make Streamyfin better. If you'd like to contribute, please fork the repository and submit a pull request. For major changes, it's best to open an issue first to discuss your ideas.
|
||||
We welcome contributions that improve Streamyfin. Start by forking the repository and submitting a pull request. For major changes or new features, please open an issue first to discuss your ideas and ensure alignment with the project.
|
||||
|
||||
### 👨💻 Development info
|
||||
## 🌍 Translations
|
||||
|
||||
[](https://crowdin.com/project/streamyfin)
|
||||
|
||||
Streamyfin is available in multiple languages, and we’re always looking for contributors to help make the app accessible worldwide.
|
||||
You can contribute translations directly on our [Crowdin project page](https://crowdin.com/project/streamyfin).
|
||||
|
||||
### 👨💻 Development Info
|
||||
|
||||
1. Use node `>20`
|
||||
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/)
|
||||
- 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. 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.
|
||||
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
|
||||
|
||||
For the TV version suffix the npm commands with `:tv`.
|
||||
|
||||
`npm run prebuild:tv`
|
||||
`npm run ios:tv or npm run android:tv`
|
||||
|
||||
## 📄 License
|
||||
## 👋 Get in Touch with Us
|
||||
|
||||
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.
|
||||
Key points of the MPL-2.0:
|
||||
Need assistance or have any questions?
|
||||
|
||||
- You can use the software for any purpose
|
||||
- You can modify the software and distribute modified versions
|
||||
- You must include the original copyright and license notices
|
||||
- You must disclose your source code for any modifications to the covered files
|
||||
- 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
|
||||
- For the full text of the license, please see the LICENSE file in this repository
|
||||
- **Discord:** [Join our server](https://discord.gg/BuGG9ZNhaE)
|
||||
- **GitHub Issues:** [Report bugs or request features](https://github.com/streamyfin/streamyfin/issues)
|
||||
- **Email:** [developer@streamyfin.app](mailto:developer@streamyfin.app)
|
||||
|
||||
## 🌐 Connect with Us
|
||||
|
||||
Join our Discord: [](https://discord.gg/BuGG9ZNhaE)
|
||||
|
||||
Need support or have questions:
|
||||
|
||||
- GitHub Issues: Report bugs or request features here.
|
||||
- Email: [developer@streamyfin.app](mailto:developer@streamyfin.app)
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
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.
|
||||
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.
|
||||
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
|
||||
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
|
||||
|
||||
## 📝 Credits
|
||||
|
||||
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
||||
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built using Expo, React Native, and other open-source libraries.
|
||||
|
||||
## ✨ Acknowledgements
|
||||
|
||||
We would like to thank the Jellyfin team for their great software and awesome support on discord.
|
||||
|
||||
Special shoutout to the JF official clients for being an inspiration to ours.
|
||||
|
||||
### 🏆 Core Developers
|
||||
## 🎖️ Core Developers
|
||||
|
||||
Thanks to the following contributors for their significant contributions:
|
||||
|
||||
@@ -229,21 +225,42 @@ Thanks to the following contributors for their significant contributions:
|
||||
</table>
|
||||
</div>
|
||||
|
||||
And all other developers who have contributed to Streamyfin, thank you for your contributions.
|
||||
## ✨ Acknowledgements
|
||||
|
||||
I'd also like to thank the following people and projects for their contributions to Streamyfin:
|
||||
We would like to thank the Jellyfin team for their excellent software and support on Discord.
|
||||
|
||||
Special thanks to the official Jellyfin clients, which have served as an inspiration for Streamyfin.
|
||||
|
||||
We also thank all other developers who have contributed to Streamyfin, your efforts are greatly appreciated.
|
||||
|
||||
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
|
||||
- [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
|
||||
|
||||
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
|
||||
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
|
||||
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
|
||||
- The Jellyfin devs for always being helpful in the Discord.
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
[](https://star-history.com/#streamyfin/streamyfin&Date)
|
||||
|
||||
## 📄 License
|
||||
|
||||
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.
|
||||
Key points of the MPL-2.0:
|
||||
|
||||
- You can use the software for any purpose
|
||||
- You can modify the software and distribute modified versions
|
||||
- You must include the original copyright and license notices
|
||||
- You must disclose your source code for any modifications to the covered files
|
||||
- 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
|
||||
- For the full text of the license, please see the LICENSE file in this repository
|
||||
|
||||
## ⚠️ 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 or support requests 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
|
||||
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)
|
||||
|
||||
@@ -6,14 +6,16 @@ module.exports = ({ config }) => {
|
||||
"react-native-google-cast",
|
||||
{ useDefaultExpandedMediaControls: true },
|
||||
]);
|
||||
|
||||
// Add the background downloader plugin only for non-TV builds
|
||||
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
|
||||
}
|
||||
|
||||
// Only override googleServicesFile if env var is set
|
||||
const androidConfig = {};
|
||||
if (process.env.GOOGLE_SERVICES_JSON) {
|
||||
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
||||
}
|
||||
|
||||
return {
|
||||
android: {
|
||||
googleServicesFile: process.env.GOOGLE_SERVICES_JSON,
|
||||
},
|
||||
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
||||
...config,
|
||||
};
|
||||
};
|
||||
|
||||
65
app.json
@@ -2,12 +2,13 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.39.0",
|
||||
"version": "0.52.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
"userInterfaceStyle": "dark",
|
||||
"jsEngine": "hermes",
|
||||
"newArchEnabled": true,
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"requireFullScreen": true,
|
||||
@@ -16,6 +17,7 @@
|
||||
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
||||
"UIBackgroundModes": ["audio", "fetch"],
|
||||
"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": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
},
|
||||
@@ -27,17 +29,16 @@
|
||||
"usesNonExemptEncryption": false
|
||||
},
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
||||
"icon": {
|
||||
"dark": "./assets/images/icon-ios-plain.png",
|
||||
"light": "./assets/images/icon-ios-light.png",
|
||||
"tinted": "./assets/images/icon-ios-tinted.png"
|
||||
"entitlements": {
|
||||
"com.apple.developer.networking.wifi-info": true
|
||||
},
|
||||
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
||||
"icon": "./assets/images/icon-ios-liquid-glass.icon",
|
||||
"appleTeamId": "MWD5K362T8"
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 71,
|
||||
"versionCode": 92,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||
@@ -47,7 +48,8 @@
|
||||
"permissions": [
|
||||
"android.permission.FOREGROUND_SERVICE",
|
||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
||||
"android.permission.WRITE_SETTINGS"
|
||||
"android.permission.WRITE_SETTINGS",
|
||||
"android.permission.ACCESS_FINE_LOCATION"
|
||||
],
|
||||
"blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"],
|
||||
"googleServicesFile": "./google-services.json"
|
||||
@@ -56,19 +58,7 @@
|
||||
"@react-native-tvos/config-tv",
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
[
|
||||
"react-native-video",
|
||||
{
|
||||
"enableNotificationControls": true,
|
||||
"enableBackgroundAudio": true,
|
||||
"androidExtensions": {
|
||||
"useExoplayerRtsp": false,
|
||||
"useExoplayerSmoothStreaming": false,
|
||||
"useExoplayerHls": true,
|
||||
"useExoplayerDash": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"./plugins/withExcludeMedia3Dash.js",
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
@@ -77,11 +67,12 @@
|
||||
"useFrameworks": "static"
|
||||
},
|
||||
"android": {
|
||||
"compileSdkVersion": 35,
|
||||
"buildArchs": ["arm64-v8a", "x86_64"],
|
||||
"compileSdkVersion": 36,
|
||||
"targetSdkVersion": 35,
|
||||
"buildToolsVersion": "35.0.0",
|
||||
"kotlinVersion": "2.0.21",
|
||||
"minSdkVersion": 24,
|
||||
"minSdkVersion": 26,
|
||||
"usesCleartextTraffic": true,
|
||||
"packagingOptions": {
|
||||
"jniLibs": {
|
||||
@@ -99,12 +90,6 @@
|
||||
"initialOrientation": "DEFAULT"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-sensors",
|
||||
{
|
||||
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
|
||||
}
|
||||
],
|
||||
"expo-localization",
|
||||
"expo-asset",
|
||||
[
|
||||
@@ -115,10 +100,6 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||
["./plugins/withAndroidManifest.js"],
|
||||
["./plugins/withTrustLocalCerts.js"],
|
||||
["./plugins/withGradleProperties.js"],
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
@@ -134,8 +115,19 @@
|
||||
"color": "#9333EA"
|
||||
}
|
||||
],
|
||||
"./plugins/with-runtime-framework-headers.js",
|
||||
"react-native-bottom-tabs"
|
||||
"expo-web-browser",
|
||||
["./plugins/with-runtime-framework-headers.js"],
|
||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||
["./plugins/withAndroidManifest.js"],
|
||||
["./plugins/withTrustLocalCerts.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": {
|
||||
"typedRoutes": true
|
||||
@@ -154,7 +146,6 @@
|
||||
},
|
||||
"updates": {
|
||||
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||
},
|
||||
"newArchEnabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function CustomMenuLayout() {
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerShown: Platform.OS !== "ios",
|
||||
headerLargeTitle: true,
|
||||
headerTitle: t("tabs.custom_links"),
|
||||
headerBlurEffect: "none",
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function menuLinks() {
|
||||
);
|
||||
const config = response?.data;
|
||||
|
||||
if (!config && !Object.hasOwn(config, "menuLinks")) {
|
||||
if (!config || !Object.hasOwn(config, "menuLinks")) {
|
||||
console.error("Menu links not found");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { RefreshControl, ScrollView, View } from "react-native";
|
||||
import { Platform, RefreshControl, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Favorites } from "@/components/home/Favorites";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
@@ -28,7 +28,7 @@ export default function favorites() {
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
>
|
||||
<View className='my-4'>
|
||||
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
|
||||
<Favorites />
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
212
app/(auth)/(tabs)/(favorites)/see-all.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useWindowDimensions, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
type FavoriteTypes =
|
||||
| "Series"
|
||||
| "Movie"
|
||||
| "Episode"
|
||||
| "Video"
|
||||
| "BoxSet"
|
||||
| "Playlist";
|
||||
|
||||
const favoriteTypes: readonly FavoriteTypes[] = [
|
||||
"Series",
|
||||
"Movie",
|
||||
"Episode",
|
||||
"Video",
|
||||
"BoxSet",
|
||||
"Playlist",
|
||||
] as const;
|
||||
|
||||
function isFavoriteType(value: unknown): value is FavoriteTypes {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(favoriteTypes as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
export default function FavoritesSeeAllScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const searchParams = useLocalSearchParams<{
|
||||
type?: string;
|
||||
title?: string;
|
||||
}>();
|
||||
const typeParam = searchParams.type;
|
||||
const titleParam = searchParams.title;
|
||||
|
||||
const itemType = useMemo(() => {
|
||||
if (!isFavoriteType(typeParam)) return null;
|
||||
return typeParam as BaseItemKind;
|
||||
}, [typeParam]);
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (typeof titleParam === "string" && titleParam.trim().length > 0)
|
||||
return titleParam;
|
||||
return "";
|
||||
}, [titleParam]);
|
||||
|
||||
const pageSize = 50;
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({ pageParam }: { pageParam: number }): Promise<BaseItemDto[]> => {
|
||||
if (!api || !user?.Id || !itemType) return [];
|
||||
|
||||
const response = await getItemsApi(api as Api).getItems({
|
||||
userId: user.Id,
|
||||
sortBy: ["SeriesSortName", "SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
filters: ["IsFavorite"],
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
collapseBoxSetItems: false,
|
||||
excludeLocationTypes: ["Virtual"],
|
||||
enableTotalRecordCount: true,
|
||||
startIndex: pageParam,
|
||||
limit: pageSize,
|
||||
includeItemTypes: [itemType],
|
||||
});
|
||||
|
||||
return response.data.Items || [];
|
||||
},
|
||||
[api, itemType, user?.Id],
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["favorites", "see-all", itemType],
|
||||
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (!lastPage || lastPage.length < pageSize) return undefined;
|
||||
return pages.reduce((acc, page) => acc + page.length, 0);
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: !!api && !!user?.Id && !!itemType,
|
||||
});
|
||||
|
||||
const flatData = useMemo(() => data?.pages.flat() ?? [], [data]);
|
||||
|
||||
const nrOfCols = useMemo(() => {
|
||||
if (screenWidth < 350) return 2;
|
||||
if (screenWidth < 600) return 3;
|
||||
if (screenWidth < 900) return 5;
|
||||
return 6;
|
||||
}, [screenWidth]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
style={{
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
alignSelf:
|
||||
index % nrOfCols === 0
|
||||
? "flex-end"
|
||||
: (index + 1) % nrOfCols === 0
|
||||
? "flex-start"
|
||||
: "center",
|
||||
width: "89%",
|
||||
}}
|
||||
>
|
||||
<ItemPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
),
|
||||
[nrOfCols],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
const handleEndReached = useCallback(() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [fetchNextPage, hasNextPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerTitle: headerTitle,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: true,
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
{!itemType ? (
|
||||
<View className='flex-1 items-center justify-center px-6'>
|
||||
<Text className='text-neutral-500'>
|
||||
{t("favorites.noData", { defaultValue: "No items found." })}
|
||||
</Text>
|
||||
</View>
|
||||
) : isLoading ? (
|
||||
<View className='justify-center items-center h-full'>
|
||||
<Loader />
|
||||
</View>
|
||||
) : (
|
||||
<FlashList
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={nrOfCols}
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={0.8}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 24,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full py-12'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
{t("home.no_items", { defaultValue: "No items" })}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListFooterComponent={
|
||||
isFetching ? (
|
||||
<View style={{ paddingVertical: 16 }}>
|
||||
<Loader />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { Stack } from "expo-router";
|
||||
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 useRouter from "@/hooks/useAppRouter";
|
||||
|
||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||
|
||||
@@ -30,7 +32,6 @@ export default function IndexLayout() {
|
||||
{!Platform.isTV && (
|
||||
<>
|
||||
<Chromecast.Chromecast background='transparent' />
|
||||
|
||||
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
||||
<SettingsButton />
|
||||
</>
|
||||
@@ -42,57 +43,290 @@ export default function IndexLayout() {
|
||||
<Stack.Screen
|
||||
name='downloads/index'
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
title: t("home.downloads.downloads_title"),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='downloads/[seriesId]'
|
||||
options={{
|
||||
title: t("home.downloads.tvseries"),
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='sessions/index'
|
||||
options={{
|
||||
title: t("home.sessions.title"),
|
||||
headerShown: true,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings'
|
||||
options={{
|
||||
title: t("home.settings.settings_title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/marlin-search/page'
|
||||
name='settings/playback-controls/page'
|
||||
options={{
|
||||
title: "",
|
||||
title: t("home.settings.playback_controls.title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/jellyseerr/page'
|
||||
name='settings/audio-subtitles/page'
|
||||
options={{
|
||||
title: "",
|
||||
title: t("home.settings.audio_subtitles.title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/hide-libraries/page'
|
||||
name='settings/appearance/page'
|
||||
options={{
|
||||
title: "",
|
||||
title: t("home.settings.appearance.title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/music/page'
|
||||
options={{
|
||||
title: t("home.settings.music.title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/appearance/hide-libraries/page'
|
||||
options={{
|
||||
title: t("home.settings.other.hide_libraries"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/plugins/page'
|
||||
options={{
|
||||
title: t("home.settings.plugins.plugins_title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/plugins/marlin-search/page'
|
||||
options={{
|
||||
title: "Marlin Search",
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/plugins/jellyseerr/page'
|
||||
options={{
|
||||
title: "Jellyseerr",
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/plugins/streamystats/page'
|
||||
options={{
|
||||
title: "Streamystats",
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/plugins/kefinTweaks/page'
|
||||
options={{
|
||||
title: "KefinTweaks",
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/intro/page'
|
||||
options={{
|
||||
title: t("home.settings.intro.title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/logs/page'
|
||||
options={{
|
||||
title: "",
|
||||
title: t("home.settings.logs.logs_title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='intro/page'
|
||||
name='settings/network/page'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
presentation: "modal",
|
||||
title: t("home.settings.network.title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||
@@ -102,6 +336,11 @@ export default function IndexLayout() {
|
||||
name='collections/[collectionId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerLeft: () => (
|
||||
<Pressable onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerShown: true,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
@@ -116,13 +355,13 @@ const SettingsButton = () => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
router.push("/(auth)/settings");
|
||||
}}
|
||||
>
|
||||
<Feather name='settings' color={"white"} size={22} />
|
||||
</TouchableOpacity>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -131,7 +370,7 @@ const SessionsButton = () => {
|
||||
const { sessions = [] } = useSessions({} as useSessionsProps);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
router.push("/(auth)/sessions");
|
||||
}}
|
||||
@@ -142,6 +381,6 @@ const SessionsButton = () => {
|
||||
color={sessions.length === 0 ? "white" : "#9333ea"}
|
||||
size={28}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
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 { getDownloadedItems, deleteItems } = useDownload();
|
||||
|
||||
const series = useMemo(() => {
|
||||
try {
|
||||
return (
|
||||
getDownloadedItems()
|
||||
?.filter((f) => f.item.SeriesId === seriesId)
|
||||
?.sort(
|
||||
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
|
||||
) || []
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [getDownloadedItems]);
|
||||
|
||||
// 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 ||
|
||||
"";
|
||||
|
||||
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
||||
return seasonGroups[Number(seasonIndex)] ?? [];
|
||||
}, [seasonGroups, seasonIndex]);
|
||||
|
||||
const initialSeasonIndex = useMemo(
|
||||
() =>
|
||||
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
|
||||
series?.[0]?.item?.ParentIndexNumber,
|
||||
[groupBySeason],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (series.length > 0) {
|
||||
navigation.setOptions({
|
||||
title: series[0].item.SeriesName,
|
||||
});
|
||||
} else {
|
||||
storage.delete(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),
|
||||
style: "destructive",
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [groupBySeason]);
|
||||
|
||||
return (
|
||||
<View className='flex-1'>
|
||||
{series.length > 0 && (
|
||||
<View className='flex flex-row items-center justify-start my-2 px-4'>
|
||||
<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>
|
||||
)}
|
||||
<ScrollView key={seasonIndex} className='px-4'>
|
||||
{groupBySeason.map((episode, index) => (
|
||||
<EpisodeCard key={index} item={episode} />
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +1,37 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { Alert, Platform, ScrollView, View } from "react-native";
|
||||
import { Pressable } from "react-native-gesture-handler";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { type DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const {
|
||||
removeProcess,
|
||||
getDownloadedItems,
|
||||
deleteFileByType,
|
||||
deleteAllFiles,
|
||||
} = useDownload();
|
||||
const [_queue, _setQueue] = useAtom(queueAtom);
|
||||
const { downloadedItems, deleteFileByType, deleteAllFiles } = useDownload();
|
||||
const router = useRouter();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const [showMigration, setShowMigration] = useState(false);
|
||||
|
||||
const _insets = useSafeAreaInsets();
|
||||
|
||||
const migration_20241124 = () => {
|
||||
Alert.alert(
|
||||
t("home.downloads.new_app_version_requires_re_download"),
|
||||
@@ -62,7 +56,7 @@ export default function page() {
|
||||
);
|
||||
};
|
||||
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
const downloadedFiles = useMemo(() => downloadedItems, [downloadedItems]);
|
||||
|
||||
const movies = useMemo(() => {
|
||||
try {
|
||||
@@ -106,9 +100,12 @@ export default function page() {
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
|
||||
<Pressable
|
||||
onPress={bottomSheetModalRef.current?.present}
|
||||
className='px-2'
|
||||
>
|
||||
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
||||
</TouchableOpacity>
|
||||
</Pressable>
|
||||
),
|
||||
});
|
||||
}, [downloadedFiles]);
|
||||
@@ -119,7 +116,7 @@ export default function page() {
|
||||
}
|
||||
}, [showMigration]);
|
||||
|
||||
const deleteMovies = () =>
|
||||
const _deleteMovies = () =>
|
||||
deleteFileByType("Movie")
|
||||
.then(() =>
|
||||
toast.success(
|
||||
@@ -130,7 +127,7 @@ export default function page() {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
||||
});
|
||||
const deleteShows = () =>
|
||||
const _deleteShows = () =>
|
||||
deleteFileByType("Episode")
|
||||
.then(() =>
|
||||
toast.success(
|
||||
@@ -141,212 +138,124 @@ export default function page() {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||
});
|
||||
const deleteOtherMedia = () =>
|
||||
const _deleteOtherMedia = () =>
|
||||
Promise.all(
|
||||
otherMedia.map((item) =>
|
||||
deleteFileByType(item.item.Type)
|
||||
.then(() =>
|
||||
toast.success(
|
||||
t("home.downloads.toasts.deleted_media_successfully", {
|
||||
type: item.item.Type,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error(
|
||||
t("home.downloads.toasts.failed_to_delete_media", {
|
||||
type: item.item.Type,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
otherMedia
|
||||
.filter((item) => item.item.Type)
|
||||
.map((item) =>
|
||||
deleteFileByType(item.item.Type!)
|
||||
.then(() =>
|
||||
toast.success(
|
||||
t("home.downloads.toasts.deleted_media_successfully", {
|
||||
type: item.item.Type,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error(
|
||||
t("home.downloads.toasts.failed_to_delete_media", {
|
||||
type: item.item.Type,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const deleteAllMedia = async () =>
|
||||
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={{ flex: 1 }}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
|
||||
<View className='py-4'>
|
||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||
<View className='bg-neutral-900 p-4 rounded-2xl'>
|
||||
<OfflineModeProvider isOffline={true}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
>
|
||||
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
|
||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||
<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.queue")}
|
||||
{t("home.downloads.movies")}
|
||||
</Text>
|
||||
<Text className='text-xs opacity-70 text-red-600'>
|
||||
{t("home.downloads.queue_hint")}
|
||||
</Text>
|
||||
<View className='flex flex-col space-y-2 mt-2'>
|
||||
{queue.map((q, index) => (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
||||
}
|
||||
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between'
|
||||
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 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>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{movies?.map((item) => (
|
||||
<TouchableItemRouter item={item.item} key={item.item.Id}>
|
||||
<MovieCard item={item.item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{queue.length === 0 && (
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_items_in_queue")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ActiveDownloads />
|
||||
</ScrollView>
|
||||
</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>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{movies?.map((item) => (
|
||||
<TouchableItemRouter
|
||||
item={item.item}
|
||||
isOffline
|
||||
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>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{groupedBySeries?.map((items) => (
|
||||
<View
|
||||
className='mb-2 last:mb-0'
|
||||
key={items[0].item.SeriesId}
|
||||
>
|
||||
<SeriesCard
|
||||
items={items.map((i) => i.item)}
|
||||
key={items[0].item.SeriesId}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{otherMedia.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.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>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{otherMedia?.map((item) => (
|
||||
<TouchableItemRouter
|
||||
item={item.item}
|
||||
isOffline
|
||||
key={item.item.Id}
|
||||
>
|
||||
<MovieCard item={item.item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{downloadedFiles?.length === 0 && (
|
||||
<View className='flex px-4'>
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_downloaded_items")}
|
||||
)}
|
||||
{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>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className='p-4 space-y-4 mb-4'>
|
||||
<Button color='purple' onPress={deleteMovies}>
|
||||
{t("home.downloads.delete_all_movies_button")}
|
||||
</Button>
|
||||
<Button color='purple' onPress={deleteShows}>
|
||||
{t("home.downloads.delete_all_tvseries_button")}
|
||||
</Button>
|
||||
{otherMedia.length > 0 && (
|
||||
<Button color='purple' onPress={deleteOtherMedia}>
|
||||
{t("home.downloads.delete_all_other_media_button")}
|
||||
</Button>
|
||||
)}
|
||||
<Button color='red' onPress={deleteAllMedia}>
|
||||
{t("home.downloads.delete_all_button")}
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
</>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{groupedBySeries?.map((items) => (
|
||||
<View
|
||||
className='mb-2 last:mb-0'
|
||||
key={items[0].item.SeriesId}
|
||||
>
|
||||
<SeriesCard
|
||||
items={items.map((i) => i.item)}
|
||||
key={items[0].item.SeriesId}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{otherMedia.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.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>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{otherMedia?.map((item) => (
|
||||
<TouchableItemRouter item={item.item} key={item.item.Id}>
|
||||
<MovieCard item={item.item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{downloadedFiles?.length === 0 && (
|
||||
<View className='flex px-4'>
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_downloaded_items")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</OfflineModeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { HomeIndex } from "@/components/settings/HomeIndex";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Home } from "../../../../components/home/Home";
|
||||
import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
|
||||
|
||||
export default function page() {
|
||||
return <HomeIndex />;
|
||||
}
|
||||
const Index = () => {
|
||||
const { settings } = useSettings();
|
||||
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
|
||||
|
||||
if (showLargeHomeCarousel) {
|
||||
return <HomeWithCarousel />;
|
||||
}
|
||||
|
||||
return <Home />;
|
||||
};
|
||||
|
||||
export default Index;
|
||||
|
||||
@@ -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,11 +1,9 @@
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import {
|
||||
HardwareAccelerationType,
|
||||
type SessionInfoDto,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { HardwareAccelerationType } from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
GeneralCommandType,
|
||||
PlaystateCommand,
|
||||
SessionInfoDto,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
@@ -13,7 +11,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Badge } from "@/components/Badge";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
@@ -49,14 +47,13 @@ export default function page() {
|
||||
<FlashList
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingTop: 17,
|
||||
paddingTop: Platform.OS === "android" ? 17 : 0,
|
||||
paddingHorizontal: 17,
|
||||
paddingBottom: 150,
|
||||
}}
|
||||
data={sessions}
|
||||
renderItem={({ item }) => <SessionCard session={item} />}
|
||||
keyExtractor={(item) => item.Id || ""}
|
||||
estimatedItemSize={200}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
@@ -8,34 +8,17 @@ import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
|
||||
import DownloadSettings from "@/components/settings/DownloadSettings";
|
||||
import { GestureControls } from "@/components/settings/GestureControls";
|
||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||
import { OtherSettings } from "@/components/settings/OtherSettings";
|
||||
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||
import { UserInfo } from "@/components/settings/UserInfo";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { clearLogs } from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
export default function settings() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [_user] = useAtom(userAtom);
|
||||
const { logout } = useJellyfin();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
|
||||
const onClearLogsClicked = async () => {
|
||||
clearLogs();
|
||||
successHapticFeedback();
|
||||
};
|
||||
|
||||
const navigation = useNavigation();
|
||||
useEffect(() => {
|
||||
@@ -46,7 +29,7 @@ export default function settings() {
|
||||
logout();
|
||||
}}
|
||||
>
|
||||
<Text className='text-red-600'>
|
||||
<Text className='text-red-600 px-2'>
|
||||
{t("home.settings.log_out_button")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -56,61 +39,68 @@ export default function settings() {
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View className='p-4 flex flex-col gap-y-4'>
|
||||
<UserInfo />
|
||||
<View
|
||||
className='p-4 flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<View className='mb-4'>
|
||||
<UserInfo />
|
||||
</View>
|
||||
|
||||
<QuickConnect className='mb-4' />
|
||||
|
||||
<MediaProvider>
|
||||
<MediaToggles className='mb-4' />
|
||||
<GestureControls className='mb-4' />
|
||||
<AudioToggles className='mb-4' />
|
||||
<SubtitleToggles className='mb-4' />
|
||||
</MediaProvider>
|
||||
|
||||
<OtherSettings />
|
||||
|
||||
{!Platform.isTV && <DownloadSettings />}
|
||||
|
||||
<PluginSettings />
|
||||
|
||||
<AppLanguageSelector />
|
||||
|
||||
{!Platform.isTV && <ChromecastSettings />}
|
||||
|
||||
<ListGroup title={"Intro"}>
|
||||
<ListItem
|
||||
onPress={() => {
|
||||
router.push("/intro/page");
|
||||
}}
|
||||
title={t("home.settings.intro.show_intro")}
|
||||
/>
|
||||
<ListItem
|
||||
textColor='red'
|
||||
onPress={() => {
|
||||
storage.set("hasShownIntro", false);
|
||||
}}
|
||||
title={t("home.settings.intro.reset_intro")}
|
||||
/>
|
||||
</ListGroup>
|
||||
<View className='mb-4'>
|
||||
<AppLanguageSelector />
|
||||
</View>
|
||||
|
||||
<View className='mb-4'>
|
||||
<ListGroup title={t("home.settings.logs.logs_title")}>
|
||||
<ListGroup title={t("home.settings.categories.title")}>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/playback-controls/page")}
|
||||
showArrow
|
||||
title={t("home.settings.playback_controls.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/audio-subtitles/page")}
|
||||
showArrow
|
||||
title={t("home.settings.audio_subtitles.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/music/page")}
|
||||
showArrow
|
||||
title={t("home.settings.music.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/appearance/page")}
|
||||
showArrow
|
||||
title={t("home.settings.appearance.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/page")}
|
||||
showArrow
|
||||
title={t("home.settings.plugins.plugins_title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/intro/page")}
|
||||
showArrow
|
||||
title={t("home.settings.intro.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/network/page")}
|
||||
showArrow
|
||||
title={t("home.settings.network.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/logs/page")}
|
||||
showArrow
|
||||
title={t("home.settings.logs.logs_title")}
|
||||
/>
|
||||
<ListItem
|
||||
textColor='red'
|
||||
onPress={onClearLogsClicked}
|
||||
title={t("home.settings.logs.delete_all_logs")}
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, Switch, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const user = useAtomValue(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["user-views", user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getUserViewsApi(api!).getUserViews({
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return response.data.Items || null;
|
||||
},
|
||||
});
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<View className='mt-4'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
||||
className='px-4'
|
||||
>
|
||||
<ListGroup title={t("home.settings.other.hide_libraries")}>
|
||||
{data?.map((view) => (
|
||||
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||
<Switch
|
||||
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({
|
||||
hiddenLibraries: value
|
||||
? [...(settings.hiddenLibraries || []), view.Id!]
|
||||
: settings.hiddenLibraries?.filter(
|
||||
(id) => id !== view.Id,
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</ListGroup>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
25
app/(auth)/(tabs)/(home)/settings/appearance/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AppearanceSettings } from "@/components/settings/AppearanceSettings";
|
||||
|
||||
export default function AppearancePage() {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className='p-4 flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<AppearanceSettings />
|
||||
<View className='h-24' />
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
31
app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||
import { MpvSubtitleSettings } from "@/components/settings/MpvSubtitleSettings";
|
||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||
|
||||
export default function AudioSubtitlesPage() {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className='p-4 flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<MediaProvider>
|
||||
<AudioToggles className='mb-4' />
|
||||
<SubtitleToggles className='mb-4' />
|
||||
<MpvSubtitleSettings className='mb-4' />
|
||||
</MediaProvider>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
45
app/(auth)/(tabs)/(home)/settings/intro/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
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 { useIntroSheet } from "@/providers/IntroSheetProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
export default function IntroPage() {
|
||||
const { showIntro } = useIntroSheet();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className='p-4 flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<ListGroup title={t("home.settings.intro.title")}>
|
||||
<ListItem
|
||||
onPress={() => {
|
||||
showIntro();
|
||||
}}
|
||||
title={t("home.settings.intro.show_intro")}
|
||||
/>
|
||||
<ListItem
|
||||
textColor='red'
|
||||
onPress={() => {
|
||||
storage.set("hasShownIntro", false);
|
||||
}}
|
||||
title={t("home.settings.intro.reset_intro")}
|
||||
/>
|
||||
</ListGroup>
|
||||
<View className='h-24' />
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
const { pluginSettings } = useSettings();
|
||||
|
||||
return (
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||
className='p-4'
|
||||
>
|
||||
<JellyseerrSettings />
|
||||
</DisabledSetting>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { File, Paths } from "expo-file-system";
|
||||
import { useNavigation } from "expo-router";
|
||||
import * as Sharing from "expo-sharing";
|
||||
import type * as SharingType from "expo-sharing";
|
||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import Collapsible from "react-native-collapsible";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
||||
|
||||
// Conditionally import expo-sharing only on non-TV platforms
|
||||
const Sharing = Platform.isTV
|
||||
? null
|
||||
: (require("expo-sharing") as typeof SharingType);
|
||||
|
||||
export default function Page() {
|
||||
const navigation = useNavigation();
|
||||
const { logs } = useLog();
|
||||
@@ -33,6 +39,7 @@ export default function Page() {
|
||||
|
||||
const _orderId = useId();
|
||||
const _levelsId = useId();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const filteredLogs = useMemo(
|
||||
() =>
|
||||
@@ -47,27 +54,30 @@ export default function Page() {
|
||||
|
||||
// Sharing it as txt while its formatted allows us to share it with many more applications
|
||||
const share = useCallback(async () => {
|
||||
const uri = `${FileSystem.documentDirectory}logs.txt`;
|
||||
if (!Sharing) return;
|
||||
|
||||
const logsFile = new File(Paths.document, "logs.txt");
|
||||
|
||||
setLoading(true);
|
||||
FileSystem.writeAsStringAsync(uri, JSON.stringify(filteredLogs))
|
||||
.then(() => {
|
||||
setLoading(false);
|
||||
Sharing.shareAsync(uri, { mimeType: "txt", UTI: "txt" });
|
||||
})
|
||||
.catch((e) =>
|
||||
writeErrorLog("Something went wrong attempting to export", e),
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
}, [filteredLogs]);
|
||||
try {
|
||||
logsFile.write(JSON.stringify(filteredLogs));
|
||||
await Sharing.shareAsync(logsFile.uri, { mimeType: "txt", UTI: "txt" });
|
||||
} catch (e: any) {
|
||||
writeErrorLog("Something went wrong attempting to export", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filteredLogs, Sharing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
loading ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<TouchableOpacity onPress={share}>
|
||||
<TouchableOpacity onPress={share} className='px-2'>
|
||||
<Text>{t("home.settings.logs.export_logs")}</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
@@ -75,7 +85,12 @@ export default function Page() {
|
||||
}, [share, loading]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
className='flex-1'
|
||||
style={{
|
||||
paddingTop: insets.top + 48,
|
||||
}}
|
||||
>
|
||||
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
||||
<FilterButton
|
||||
id={orderFilterId}
|
||||
@@ -157,6 +172,6 @@ export default function Page() {
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Linking,
|
||||
Switch,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||
|
||||
const onSave = (val: string) => {
|
||||
updateSettings({
|
||||
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
||||
});
|
||||
toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
|
||||
};
|
||||
|
||||
const handleOpenLink = () => {
|
||||
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||
};
|
||||
|
||||
const disabled = useMemo(() => {
|
||||
return (
|
||||
pluginSettings?.searchEngine?.locked === true &&
|
||||
pluginSettings?.marlinServerUrl?.locked === true
|
||||
);
|
||||
}, [pluginSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pluginSettings?.marlinServerUrl?.locked) {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={() => onSave(value)}>
|
||||
<Text className='text-blue-500'>
|
||||
{t("home.settings.plugins.marlin_search.save_button")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [navigation, value]);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<DisabledSetting disabled={disabled} className='px-4'>
|
||||
<ListGroup>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.searchEngine?.locked === true}
|
||||
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||
>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||
)}
|
||||
onPress={() => {
|
||||
updateSettings({ searchEngine: "Jellyfin" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
>
|
||||
<Switch
|
||||
value={settings.searchEngine === "Marlin"}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</DisabledSetting>
|
||||
</ListGroup>
|
||||
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
||||
showText={!pluginSettings?.searchEngine?.locked}
|
||||
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
|
||||
>
|
||||
<View className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}>
|
||||
<Text className='mr-4'>
|
||||
{t("home.settings.plugins.marlin_search.url")}
|
||||
</Text>
|
||||
<TextInput
|
||||
editable={settings.searchEngine === "Marlin"}
|
||||
className='text-white'
|
||||
placeholder={t(
|
||||
"home.settings.plugins.marlin_search.server_url_placeholder",
|
||||
)}
|
||||
value={value}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
onChangeText={(text) => setValue(text)}
|
||||
/>
|
||||
</View>
|
||||
</DisabledSetting>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||
</Text>
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
);
|
||||
}
|
||||
251
app/(auth)/(tabs)/(home)/settings/music/page.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { Switch } from "react-native-gesture-handler";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
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";
|
||||
|
||||
const CACHE_SIZE_OPTIONS = [
|
||||
{ label: "100 MB", value: 100 },
|
||||
{ label: "250 MB", value: 250 },
|
||||
{ label: "500 MB", value: 500 },
|
||||
{ label: "1 GB", value: 1024 },
|
||||
{ label: "2 GB", value: 2048 },
|
||||
];
|
||||
|
||||
const LOOKAHEAD_COUNT_OPTIONS = [
|
||||
{ label: "1 song", value: 1 },
|
||||
{ label: "2 songs", value: 2 },
|
||||
{ label: "3 songs", value: 3 },
|
||||
{ label: "5 songs", value: 5 },
|
||||
];
|
||||
|
||||
export default function MusicSettingsPage() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
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(
|
||||
() => [
|
||||
{
|
||||
options: CACHE_SIZE_OPTIONS.map((option) => ({
|
||||
type: "radio" as const,
|
||||
label: option.label,
|
||||
value: String(option.value),
|
||||
selected: option.value === settings?.audioMaxCacheSizeMB,
|
||||
onPress: () => updateSettings({ audioMaxCacheSizeMB: option.value }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings?.audioMaxCacheSizeMB, updateSettings],
|
||||
);
|
||||
|
||||
const currentCacheSizeLabel =
|
||||
CACHE_SIZE_OPTIONS.find((o) => o.value === settings?.audioMaxCacheSizeMB)
|
||||
?.label ?? `${settings?.audioMaxCacheSizeMB} MB`;
|
||||
|
||||
const lookaheadCountOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: LOOKAHEAD_COUNT_OPTIONS.map((option) => ({
|
||||
type: "radio" as const,
|
||||
label: option.label,
|
||||
value: String(option.value),
|
||||
selected: option.value === settings?.audioLookaheadCount,
|
||||
onPress: () => updateSettings({ audioLookaheadCount: option.value }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings?.audioLookaheadCount, updateSettings],
|
||||
);
|
||||
|
||||
const currentLookaheadLabel =
|
||||
LOOKAHEAD_COUNT_OPTIONS.find(
|
||||
(o) => o.value === settings?.audioLookaheadCount,
|
||||
)?.label ?? `${settings?.audioLookaheadCount} songs`;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className='p-4 flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<ListGroup
|
||||
title={t("home.settings.music.playback_title")}
|
||||
description={
|
||||
<Text className='text-[#8E8D91] text-xs'>
|
||||
{t("home.settings.music.playback_description")}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<ListItem
|
||||
title={t("home.settings.music.prefer_downloaded")}
|
||||
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||
>
|
||||
<Switch
|
||||
value={settings.preferLocalAudio}
|
||||
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ preferLocalAudio: value })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
|
||||
<View className='mt-4'>
|
||||
<ListGroup
|
||||
title={t("home.settings.music.caching_title")}
|
||||
description={
|
||||
<Text className='text-[#8E8D91] text-xs'>
|
||||
{t("home.settings.music.caching_description")}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<ListItem
|
||||
title={t("home.settings.music.lookahead_enabled")}
|
||||
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||
>
|
||||
<Switch
|
||||
value={settings.audioLookaheadEnabled}
|
||||
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ audioLookaheadEnabled: value })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t("home.settings.music.lookahead_count")}
|
||||
disabled={
|
||||
pluginSettings?.audioLookaheadCount?.locked ||
|
||||
!settings.audioLookaheadEnabled
|
||||
}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={lookaheadCountOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{currentLookaheadLabel}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.music.lookahead_count")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t("home.settings.music.max_cache_size")}
|
||||
disabled={pluginSettings?.audioMaxCacheSizeMB?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={cacheSizeOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{currentCacheSizeLabel}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.music.max_cache_size")}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</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>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
35
app/(auth)/(tabs)/(home)/settings/playback-controls/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { GestureControls } from "@/components/settings/GestureControls";
|
||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||
import { PlaybackControlsSettings } from "@/components/settings/PlaybackControlsSettings";
|
||||
import { ChromecastSettings } from "../../../../../../components/settings/ChromecastSettings";
|
||||
|
||||
export default function PlaybackControlsPage() {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className='p-4 flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<View className='mb-4'>
|
||||
<MediaProvider>
|
||||
<MediaToggles className='mb-4' />
|
||||
<GestureControls className='mb-4' />
|
||||
<PlaybackControlsSettings />
|
||||
</MediaProvider>
|
||||
</View>
|
||||
{!Platform.isTV && <ChromecastSettings />}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ScrollView } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
const { pluginSettings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||
className='px-4'
|
||||
>
|
||||
<JellyseerrSettings />
|
||||
</DisabledSetting>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ScrollView } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
const { pluginSettings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.useKefinTweaks?.locked === true}
|
||||
className='px-4'
|
||||
>
|
||||
<KefinTweaksSettings />
|
||||
</DisabledSetting>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
142
app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Linking,
|
||||
ScrollView,
|
||||
Switch,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
|
||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||
|
||||
const onSave = (val: string) => {
|
||||
updateSettings({
|
||||
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
||||
});
|
||||
toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
|
||||
};
|
||||
|
||||
const handleOpenLink = () => {
|
||||
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||
};
|
||||
|
||||
const disabled = useMemo(() => {
|
||||
return (
|
||||
pluginSettings?.searchEngine?.locked === true &&
|
||||
pluginSettings?.marlinServerUrl?.locked === true
|
||||
);
|
||||
}, [pluginSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pluginSettings?.marlinServerUrl?.locked) {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
|
||||
<Text className='text-blue-500'>
|
||||
{t("home.settings.plugins.marlin_search.save_button")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<DisabledSetting disabled={disabled} className='px-4'>
|
||||
<ListGroup>
|
||||
<DisabledSetting
|
||||
disabled={
|
||||
pluginSettings?.searchEngine?.locked === true ||
|
||||
!!pluginSettings?.streamyStatsServerUrl?.value
|
||||
}
|
||||
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||
>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||
)}
|
||||
onPress={() => {
|
||||
updateSettings({ searchEngine: "Jellyfin" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
>
|
||||
<Switch
|
||||
value={settings.searchEngine === "Marlin"}
|
||||
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({
|
||||
searchEngine: value ? "Marlin" : "Jellyfin",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</DisabledSetting>
|
||||
</ListGroup>
|
||||
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
||||
showText={!pluginSettings?.searchEngine?.locked}
|
||||
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
|
||||
>
|
||||
<View
|
||||
className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}
|
||||
>
|
||||
<Text className='mr-4'>
|
||||
{t("home.settings.plugins.marlin_search.url")}
|
||||
</Text>
|
||||
<TextInput
|
||||
editable={settings.searchEngine === "Marlin"}
|
||||
className='text-white'
|
||||
placeholder={t(
|
||||
"home.settings.plugins.marlin_search.server_url_placeholder",
|
||||
)}
|
||||
value={value}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
onChangeText={(text) => setValue(text)}
|
||||
/>
|
||||
</View>
|
||||
</DisabledSetting>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||
</Text>
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
24
app/(auth)/(tabs)/(home)/settings/plugins/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||
|
||||
export default function PluginsPage() {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className='px-4 flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<PluginSettings />
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
262
app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Linking,
|
||||
ScrollView,
|
||||
Switch,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const {
|
||||
settings,
|
||||
updateSettings,
|
||||
pluginSettings,
|
||||
refreshStreamyfinPluginSettings,
|
||||
} = useSettings();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
|
||||
// Local state for all editable fields
|
||||
const [url, setUrl] = useState<string>(settings?.streamyStatsServerUrl || "");
|
||||
const [useForSearch, setUseForSearch] = useState<boolean>(
|
||||
settings?.searchEngine === "Streamystats",
|
||||
);
|
||||
const [movieRecs, setMovieRecs] = useState<boolean>(
|
||||
settings?.streamyStatsMovieRecommendations ?? false,
|
||||
);
|
||||
const [seriesRecs, setSeriesRecs] = useState<boolean>(
|
||||
settings?.streamyStatsSeriesRecommendations ?? false,
|
||||
);
|
||||
const [promotedWatchlists, setPromotedWatchlists] = useState<boolean>(
|
||||
settings?.streamyStatsPromotedWatchlists ?? false,
|
||||
);
|
||||
const [hideWatchlistsTab, setHideWatchlistsTab] = useState<boolean>(
|
||||
settings?.hideWatchlistsTab ?? false,
|
||||
);
|
||||
|
||||
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
||||
const isStreamystatsEnabled = !!url;
|
||||
|
||||
const onSave = useCallback(() => {
|
||||
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||
updateSettings({
|
||||
streamyStatsServerUrl: cleanUrl,
|
||||
searchEngine: useForSearch ? "Streamystats" : "Jellyfin",
|
||||
streamyStatsMovieRecommendations: movieRecs,
|
||||
streamyStatsSeriesRecommendations: seriesRecs,
|
||||
streamyStatsPromotedWatchlists: promotedWatchlists,
|
||||
hideWatchlistsTab: hideWatchlistsTab,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["streamystats"] });
|
||||
toast.success(t("home.settings.plugins.streamystats.toasts.saved"));
|
||||
}, [
|
||||
url,
|
||||
useForSearch,
|
||||
movieRecs,
|
||||
seriesRecs,
|
||||
promotedWatchlists,
|
||||
hideWatchlistsTab,
|
||||
updateSettings,
|
||||
queryClient,
|
||||
t,
|
||||
]);
|
||||
|
||||
// Set up header save button
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={onSave}>
|
||||
<Text className='text-blue-500 font-medium'>
|
||||
{t("home.settings.plugins.streamystats.save")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [navigation, onSave, t]);
|
||||
|
||||
const handleClearStreamystats = useCallback(() => {
|
||||
setUrl("");
|
||||
setUseForSearch(false);
|
||||
setMovieRecs(false);
|
||||
setSeriesRecs(false);
|
||||
setPromotedWatchlists(false);
|
||||
setHideWatchlistsTab(false);
|
||||
updateSettings({
|
||||
streamyStatsServerUrl: "",
|
||||
searchEngine: "Jellyfin",
|
||||
streamyStatsMovieRecommendations: false,
|
||||
streamyStatsSeriesRecommendations: false,
|
||||
streamyStatsPromotedWatchlists: false,
|
||||
hideWatchlistsTab: false,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["streamystats"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
toast.success(t("home.settings.plugins.streamystats.toasts.disabled"));
|
||||
}, [updateSettings, queryClient, t]);
|
||||
|
||||
const handleOpenLink = () => {
|
||||
Linking.openURL("https://github.com/fredrikburmester/streamystats");
|
||||
};
|
||||
|
||||
const handleRefreshFromServer = useCallback(async () => {
|
||||
const newPluginSettings = await refreshStreamyfinPluginSettings(true);
|
||||
// Update local state with new values
|
||||
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
||||
setUrl(newUrl);
|
||||
if (newUrl) {
|
||||
setUseForSearch(true);
|
||||
}
|
||||
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
|
||||
}, [refreshStreamyfinPluginSettings, t]);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View className='px-4'>
|
||||
<ListGroup className='flex-1'>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.streamystats.url")}
|
||||
disabledByAdmin={isUrlLocked}
|
||||
>
|
||||
<TextInput
|
||||
editable={!isUrlLocked}
|
||||
className='text-white text-right flex-1'
|
||||
placeholder={t(
|
||||
"home.settings.plugins.streamystats.server_url_placeholder",
|
||||
)}
|
||||
value={url}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
onChangeText={setUrl}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.plugins.streamystats.streamystats_search_hint")}{" "}
|
||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||
{t(
|
||||
"home.settings.plugins.streamystats.read_more_about_streamystats",
|
||||
)}
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
<ListGroup
|
||||
title={t("home.settings.plugins.streamystats.features_title")}
|
||||
className='mt-4'
|
||||
>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.streamystats.enable_search")}
|
||||
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
|
||||
>
|
||||
<Switch
|
||||
value={useForSearch}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
onValueChange={setUseForSearch}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
||||
)}
|
||||
disabledByAdmin={
|
||||
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
value={movieRecs}
|
||||
onValueChange={setMovieRecs}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.enable_series_recommendations",
|
||||
)}
|
||||
disabledByAdmin={
|
||||
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
value={seriesRecs}
|
||||
onValueChange={setSeriesRecs}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
||||
)}
|
||||
disabledByAdmin={
|
||||
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
value={promotedWatchlists}
|
||||
onValueChange={setPromotedWatchlists}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
||||
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
|
||||
>
|
||||
<Switch
|
||||
value={hideWatchlistsTab}
|
||||
onValueChange={setHideWatchlistsTab}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleRefreshFromServer}
|
||||
className='mt-6 py-3 rounded-xl bg-neutral-800'
|
||||
>
|
||||
<Text className='text-center text-blue-500'>
|
||||
{t("home.settings.plugins.streamystats.refresh_from_server")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
|
||||
{!isUrlLocked && isStreamystatsEnabled && (
|
||||
<TouchableOpacity
|
||||
onPress={handleClearStreamystats}
|
||||
className='mt-3 mb-4 py-3 rounded-xl bg-neutral-800'
|
||||
>
|
||||
<Text className='text-center text-red-500'>
|
||||
{t("home.settings.plugins.streamystats.disable_streamystats")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ItemContent } from "@/components/ItemContent";
|
||||
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const { id } = useLocalSearchParams() as { id: string };
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||
const isOffline = offline === "true";
|
||||
|
||||
const { data: item, isError } = useItemQuery(id, isOffline);
|
||||
|
||||
const opacity = useSharedValue(1);
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: opacity.value,
|
||||
};
|
||||
});
|
||||
|
||||
const fadeOut = (callback: any) => {
|
||||
setTimeout(() => {
|
||||
opacity.value = withTiming(0, { duration: 500 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const fadeIn = (callback: any) => {
|
||||
setTimeout(() => {
|
||||
opacity.value = withTiming(1, { duration: 500 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
fadeOut(() => {});
|
||||
} else {
|
||||
fadeIn(() => {});
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
if (isError)
|
||||
return (
|
||||
<View className='flex flex-col items-center justify-center h-screen w-screen'>
|
||||
<Text>{t("item_card.could_not_load_item")}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className='flex flex-1 relative'>
|
||||
<Animated.View
|
||||
pointerEvents={"none"}
|
||||
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={{
|
||||
height: item?.Type === "Episode" ? 300 : 450,
|
||||
}}
|
||||
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-3 bg-neutral-900 rounded mb-3 w-8' />
|
||||
<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>
|
||||
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
|
||||
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
||||
</Animated.View>
|
||||
{item && <ItemContent item={item} isOffline={isOffline} />}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,161 +0,0 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { AddToFavorites } from "@/components/AddToFavorites";
|
||||
import { DownloadItems } from "@/components/DownloadItem";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { NextUp } from "@/components/series/NextUp";
|
||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const params = useLocalSearchParams();
|
||||
const { id: seriesId, seasonIndex } = params as {
|
||||
id: string;
|
||||
seasonIndex: string;
|
||||
};
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data: item } = useQuery({
|
||||
queryKey: ["series", seriesId],
|
||||
queryFn: async () =>
|
||||
await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: seriesId,
|
||||
}),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
() =>
|
||||
getBackdropUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
}),
|
||||
[item],
|
||||
);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
() =>
|
||||
getLogoImageUrlById({
|
||||
api,
|
||||
item,
|
||||
}),
|
||||
[item],
|
||||
);
|
||||
|
||||
const { data: allEpisodes, isLoading } = useQuery({
|
||||
queryKey: ["AllEpisodes", item?.Id],
|
||||
queryFn: async () => {
|
||||
const res = await getTvShowsApi(api!).getEpisodes({
|
||||
seriesId: item?.Id!,
|
||||
userId: user?.Id!,
|
||||
enableUserData: true,
|
||||
// Note: Including trick play is necessary to enable trick play downloads
|
||||
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||
});
|
||||
return res?.data.Items || [];
|
||||
},
|
||||
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(
|
||||
(a, b) =>
|
||||
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
||||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
||||
),
|
||||
staleTime: 60,
|
||||
enabled: !!api && !!user?.Id && !!item?.Id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
!isLoading &&
|
||||
item &&
|
||||
allEpisodes &&
|
||||
allEpisodes.length > 0 && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<AddToFavorites item={item} />
|
||||
{!Platform.isTV && (
|
||||
<DownloadItems
|
||||
size='large'
|
||||
title={t("item_card.download.download_series")}
|
||||
items={allEpisodes || []}
|
||||
MissingDownloadIconComponent={() => (
|
||||
<Ionicons name='download' size={22} color='white' />
|
||||
)}
|
||||
DownloadedIconComponent={() => (
|
||||
<Ionicons
|
||||
name='checkmark-done-outline'
|
||||
size={24}
|
||||
color='#9333ea'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [allEpisodes, isLoading, item]);
|
||||
|
||||
if (!item || !backdropUrl) return null;
|
||||
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerHeight={400}
|
||||
headerImage={
|
||||
<Image
|
||||
source={{
|
||||
uri: backdropUrl,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
logo={
|
||||
logoUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUrl,
|
||||
}}
|
||||
style={{
|
||||
height: 130,
|
||||
width: "100%",
|
||||
}}
|
||||
contentFit='contain'
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<View className='flex flex-col pt-4'>
|
||||
<SeriesHeader item={item} />
|
||||
<View className='mb-4'>
|
||||
<NextUp seriesId={seriesId} />
|
||||
</View>
|
||||
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
@@ -16,6 +16,7 @@ import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FlatList, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
@@ -204,154 +205,154 @@ const page: React.FC = () => {
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
const _insets = useSafeAreaInsets();
|
||||
|
||||
const ListHeaderComponent = useCallback(
|
||||
() => (
|
||||
<View className=''>
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
display: "flex",
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 16,
|
||||
flexDirection: "row",
|
||||
}}
|
||||
extraData={[
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]}
|
||||
data={[
|
||||
{
|
||||
key: "reset",
|
||||
component: <ResetFiltersButton />,
|
||||
},
|
||||
{
|
||||
key: "genre",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='genreFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Genres || [];
|
||||
}}
|
||||
set={setSelectedGenres}
|
||||
values={selectedGenres}
|
||||
title={t("library.filters.genres")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "year",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='yearFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Years || [];
|
||||
}}
|
||||
set={setSelectedYears}
|
||||
values={selectedYears}
|
||||
title={t("library.filters.years")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) => item.includes(search)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='tagsFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Tags || [];
|
||||
}}
|
||||
set={setSelectedTags}
|
||||
values={selectedTags}
|
||||
title={t("library.filters.tags")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortBy",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='sortBy'
|
||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||
set={setSortBy}
|
||||
values={sortBy}
|
||||
title={t("library.filters.sort_by")}
|
||||
renderItemLabel={(item) =>
|
||||
sortOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortOrder",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='sortOrder'
|
||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||
set={setSortOrder}
|
||||
values={sortOrder}
|
||||
title={t("library.filters.sort_order")}
|
||||
renderItemLabel={(item) =>
|
||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
renderItem={({ item }) => item.component}
|
||||
keyExtractor={(item) => item.key}
|
||||
/>
|
||||
</View>
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
display: "flex",
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 16,
|
||||
flexDirection: "row",
|
||||
}}
|
||||
extraData={[
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]}
|
||||
data={[
|
||||
{
|
||||
key: "reset",
|
||||
component: <ResetFiltersButton />,
|
||||
},
|
||||
{
|
||||
key: "genre",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='genreFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Genres || [];
|
||||
}}
|
||||
set={setSelectedGenres}
|
||||
values={selectedGenres}
|
||||
title={t("library.filters.genres")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "year",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='yearFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Years || [];
|
||||
}}
|
||||
set={setSelectedYears}
|
||||
values={selectedYears}
|
||||
title={t("library.filters.years")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) => item.includes(search)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='tagsFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Tags || [];
|
||||
}}
|
||||
set={setSelectedTags}
|
||||
values={selectedTags}
|
||||
title={t("library.filters.tags")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortBy",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='sortBy'
|
||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||
set={setSortBy}
|
||||
values={sortBy}
|
||||
title={t("library.filters.sort_by")}
|
||||
renderItemLabel={(item) =>
|
||||
sortOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortOrder",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='sortOrder'
|
||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||
set={setSortOrder}
|
||||
values={sortOrder}
|
||||
title={t("library.filters.sort_order")}
|
||||
renderItemLabel={(item) =>
|
||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
renderItem={({ item }) => item.component}
|
||||
keyExtractor={(item) => item.key}
|
||||
/>
|
||||
),
|
||||
[
|
||||
collectionId,
|
||||
@@ -393,7 +394,6 @@ const page: React.FC = () => {
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemSize={255}
|
||||
numColumns={
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ItemContent } from "@/components/ItemContent";
|
||||
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const { id } = useLocalSearchParams() as { id: string };
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||
const isOffline = offline === "true";
|
||||
|
||||
// Exclude MediaSources/MediaStreams from initial fetch for faster loading
|
||||
// (especially important for plugins like Gelato)
|
||||
const { data: item, isError } = useItemQuery(id, isOffline, undefined, [
|
||||
ItemFields.MediaSources,
|
||||
ItemFields.MediaSourceCount,
|
||||
ItemFields.MediaStreams,
|
||||
]);
|
||||
|
||||
// Lazily preload item with full media sources in background
|
||||
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, []);
|
||||
|
||||
const opacity = useSharedValue(1);
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: opacity.value,
|
||||
};
|
||||
});
|
||||
|
||||
const fadeOut = (callback: any) => {
|
||||
setTimeout(() => {
|
||||
opacity.value = withTiming(0, { duration: 500 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const fadeIn = (callback: any) => {
|
||||
setTimeout(() => {
|
||||
opacity.value = withTiming(1, { duration: 500 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
fadeOut(() => {});
|
||||
} else {
|
||||
fadeIn(() => {});
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
if (isError)
|
||||
return (
|
||||
<View className='flex flex-col items-center justify-center h-screen w-screen'>
|
||||
<Text>{t("item_card.could_not_load_item")}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<OfflineModeProvider isOffline={isOffline}>
|
||||
<View className='flex flex-1 relative'>
|
||||
<Animated.View
|
||||
pointerEvents={"none"}
|
||||
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={{
|
||||
height: item?.Type === "Episode" ? 300 : 450,
|
||||
}}
|
||||
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-3 bg-neutral-900 rounded mb-3 w-8' />
|
||||
<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>
|
||||
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
|
||||
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
||||
</Animated.View>
|
||||
{item && <ItemContent item={item} itemWithSources={itemWithSources} />}
|
||||
</View>
|
||||
</OfflineModeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -21,19 +21,18 @@ export default function page() {
|
||||
companyId: string;
|
||||
name: string;
|
||||
image: string;
|
||||
type: DiscoverSliderType;
|
||||
type: DiscoverSliderType; //This gets converted to a string because it's a url param
|
||||
};
|
||||
|
||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
|
||||
queryKey: ["jellyseerr", "company", type, companyId],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const params: any = {
|
||||
page: Number(pageParam),
|
||||
};
|
||||
|
||||
return jellyseerrApi?.discover(
|
||||
`${
|
||||
type === DiscoverSliderType.NETWORKS
|
||||
Number(type) === DiscoverSliderType.NETWORKS
|
||||
? Endpoints.DISCOVER_TV_NETWORK
|
||||
: Endpoints.DISCOVER_MOVIES_STUDIO
|
||||
}/${companyId}`,
|
||||
@@ -86,6 +85,7 @@ export default function page() {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
logo={
|
||||
<Image
|
||||
id={companyId}
|
||||
@@ -8,42 +8,50 @@ import {
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
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 { toast } from "sonner-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
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 { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { JellyserrRatings } from "@/components/Ratings";
|
||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||
import { ItemActions } from "@/components/series/SeriesActions";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||
import {
|
||||
type IssueType,
|
||||
IssueTypeName,
|
||||
} from "@/utils/jellyseerr/server/constants/issue";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaType,
|
||||
} from "@/utils/jellyseerr/server/constants/media";
|
||||
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||
import {
|
||||
hasPermission,
|
||||
Permission,
|
||||
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import type {
|
||||
MovieResult,
|
||||
TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
|
||||
import RequestModal from "@/components/jellyseerr/RequestModal";
|
||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const params = useLocalSearchParams();
|
||||
@@ -60,11 +68,12 @@ const Page: React.FC = () => {
|
||||
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||
|
||||
const navigation = useNavigation();
|
||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||
|
||||
const [issueType, setIssueType] = useState<IssueType>();
|
||||
const [issueMessage, setIssueMessage] = useState<string>();
|
||||
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
||||
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
|
||||
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
@@ -92,6 +101,46 @@ const Page: React.FC = () => {
|
||||
const [canRequest, hasAdvancedRequestPermission] =
|
||||
useJellyseerrCanRequest(details);
|
||||
|
||||
const canManageRequests = useMemo(() => {
|
||||
if (!jellyseerrUser) return false;
|
||||
return hasPermission(
|
||||
Permission.MANAGE_REQUESTS,
|
||||
jellyseerrUser.permissions,
|
||||
);
|
||||
}, [jellyseerrUser]);
|
||||
|
||||
const pendingRequest = useMemo(() => {
|
||||
return details?.mediaInfo?.requests?.find(
|
||||
(r: MediaRequest) => r.status === MediaRequestStatus.PENDING,
|
||||
);
|
||||
}, [details]);
|
||||
|
||||
const handleApproveRequest = useCallback(async () => {
|
||||
if (!pendingRequest?.id) return;
|
||||
|
||||
try {
|
||||
await jellyseerrApi?.approveRequest(pendingRequest.id);
|
||||
toast.success(t("jellyseerr.toasts.request_approved"));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
|
||||
console.error("Failed to approve request:", error);
|
||||
}
|
||||
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||
|
||||
const handleDeclineRequest = useCallback(async () => {
|
||||
if (!pendingRequest?.id) return;
|
||||
|
||||
try {
|
||||
await jellyseerrApi?.declineRequest(pendingRequest.id);
|
||||
toast.success(t("jellyseerr.toasts.request_declined"));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
|
||||
console.error("Failed to decline request:", error);
|
||||
}
|
||||
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
@@ -115,6 +164,10 @@ const Page: React.FC = () => {
|
||||
}
|
||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||
|
||||
const handleIssueModalDismiss = useCallback(() => {
|
||||
setIssueTypeDropdownOpen(false);
|
||||
}, []);
|
||||
|
||||
const setRequestBody = useCallback(
|
||||
(body: MediaRequestBody) => {
|
||||
_setRequestBody(body);
|
||||
@@ -128,9 +181,11 @@ const Page: React.FC = () => {
|
||||
mediaId: Number(result.id!),
|
||||
mediaType: mediaType!,
|
||||
tvdbId: details?.externalIds?.tvdbId,
|
||||
seasons: (details as TvDetails)?.seasons
|
||||
?.filter?.((s) => s.seasonNumber !== 0)
|
||||
?.map?.((s) => s.seasonNumber),
|
||||
...(mediaType === MediaType.TV && {
|
||||
seasons: (details as TvDetails)?.seasons
|
||||
?.filter?.((s) => s.seasonNumber !== 0)
|
||||
?.map?.((s) => s.seasonNumber),
|
||||
}),
|
||||
};
|
||||
|
||||
if (hasAdvancedRequestPermission) {
|
||||
@@ -156,11 +211,31 @@ const Page: React.FC = () => {
|
||||
[details],
|
||||
);
|
||||
|
||||
const issueTypeOptionGroups = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("jellyseerr.types"),
|
||||
options: Object.entries(IssueTypeName)
|
||||
.reverse()
|
||||
.map(([key, value]) => ({
|
||||
type: "radio" as const,
|
||||
label: value,
|
||||
value: key,
|
||||
selected: key === String(issueType),
|
||||
onPress: () => setIssueType(key as unknown as IssueType),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[issueType, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (details) {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
|
||||
<TouchableOpacity
|
||||
className={`rounded-full pl-1.5 ${Platform.OS === "android" ? "" : "bg-neutral-800/80"}`}
|
||||
>
|
||||
<ItemActions item={details} />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
@@ -309,6 +384,60 @@ const Page: React.FC = () => {
|
||||
</View>
|
||||
)
|
||||
)}
|
||||
{canManageRequests && pendingRequest && (
|
||||
<View className='flex flex-col space-y-2 mt-4'>
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<Ionicons name='person-outline' size={16} color='#9CA3AF' />
|
||||
<Text className='text-sm text-neutral-400'>
|
||||
{t("jellyseerr.requested_by", {
|
||||
user:
|
||||
pendingRequest.requestedBy?.displayName ||
|
||||
pendingRequest.requestedBy?.username ||
|
||||
pendingRequest.requestedBy?.jellyfinUsername ||
|
||||
t("jellyseerr.unknown_user"),
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='flex flex-row space-x-2'>
|
||||
<Button
|
||||
className='flex-1 bg-green-600/50 border-green-400 ring-green-400 text-green-100'
|
||||
color='transparent'
|
||||
onPress={handleApproveRequest}
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name='checkmark-outline'
|
||||
size={20}
|
||||
color='white'
|
||||
/>
|
||||
}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
<Text className='text-sm'>{t("jellyseerr.approve")}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
|
||||
color='transparent'
|
||||
onPress={handleDeclineRequest}
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name='close-outline'
|
||||
size={20}
|
||||
color='white'
|
||||
/>
|
||||
}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
<Text className='text-sm'>{t("jellyseerr.decline")}</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<OverviewText text={result.overview} className='mt-4' />
|
||||
</View>
|
||||
|
||||
@@ -355,6 +484,8 @@ const Page: React.FC = () => {
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={renderBackdrop}
|
||||
stackBehavior='push'
|
||||
onDismiss={handleIssueModalDismiss}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||
@@ -364,50 +495,25 @@ const Page: React.FC = () => {
|
||||
</Text>
|
||||
</View>
|
||||
<View className='flex flex-col space-y-2 items-start'>
|
||||
<View className='flex flex-col'>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.issue_type")}
|
||||
<View className='flex flex-col w-full'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.issue_type")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={issueTypeOptionGroups}
|
||||
trigger={
|
||||
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text numberOfLines={1}>
|
||||
{issueType
|
||||
? IssueTypeName[issueType]
|
||||
: t("jellyseerr.select_an_issue")}
|
||||
</Text>
|
||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text style={{}} className='' numberOfLines={1}>
|
||||
{issueType
|
||||
? IssueTypeName[issueType]
|
||||
: t("jellyseerr.select_an_issue")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={false}
|
||||
side='bottom'
|
||||
align='center'
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
sideOffset={0}
|
||||
>
|
||||
<DropdownMenu.Label>
|
||||
{t("jellyseerr.types")}
|
||||
</DropdownMenu.Label>
|
||||
{Object.entries(IssueTypeName)
|
||||
.reverse()
|
||||
.map(([key, value], _idx) => (
|
||||
<DropdownMenu.Item
|
||||
key={value}
|
||||
onSelect={() =>
|
||||
setIssueType(key as unknown as IssueType)
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{value}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
}
|
||||
title={t("jellyseerr.types")}
|
||||
open={issueTypeDropdownOpen}
|
||||
onOpenChange={setIssueTypeDropdownOpen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
||||
@@ -87,14 +87,15 @@ export default function page() {
|
||||
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
|
||||
<Text className='opacity-50'>
|
||||
{t("jellyseerr.born")}{" "}
|
||||
{new Date(data?.details?.birthday!).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
)}{" "}
|
||||
{data?.details?.birthday &&
|
||||
new Date(data.details.birthday).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
)}{" "}
|
||||
| {data?.details?.placeOfBirth}
|
||||
</Text>
|
||||
</>
|
||||
@@ -33,7 +33,6 @@ export default function page() {
|
||||
<View className='flex flex-1'>
|
||||
<FlashList
|
||||
data={channels?.Items}
|
||||
estimatedItemSize={76}
|
||||
renderItem={({ item }) => (
|
||||
<View className='flex flex-row items-center px-4 mb-2'>
|
||||
<View className='w-22 mr-4 rounded-lg overflow-hidden'>
|
||||
@@ -0,0 +1,300 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||
import {
|
||||
downloadTrack,
|
||||
isPermanentlyDownloaded,
|
||||
} from "@/providers/AudioStorage";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||
const ARTWORK_SIZE = SCREEN_WIDTH * 0.5;
|
||||
|
||||
export default function AlbumDetailScreen() {
|
||||
const { albumId } = useLocalSearchParams<{ albumId: string }>();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const { playQueue } = useMusicPlayer();
|
||||
|
||||
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
||||
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
||||
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
||||
setSelectedTrack(track);
|
||||
setTrackOptionsOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleAddToPlaylist = useCallback(() => {
|
||||
setPlaylistPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateNewPlaylist = useCallback(() => {
|
||||
setCreatePlaylistOpen(true);
|
||||
}, []);
|
||||
|
||||
const { data: album, isLoading: loadingAlbum } = useQuery({
|
||||
queryKey: ["music-album", albumId, user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getUserLibraryApi(api!).getItem({
|
||||
userId: user?.Id,
|
||||
itemId: albumId!,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!albumId,
|
||||
});
|
||||
|
||||
const { data: tracks, isLoading: loadingTracks } = useQuery({
|
||||
queryKey: ["music-album-tracks", albumId, user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getItemsApi(api!).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: albumId,
|
||||
sortBy: ["IndexNumber"],
|
||||
sortOrder: ["Ascending"],
|
||||
});
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!albumId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
title: album?.Name ?? "",
|
||||
headerTransparent: true,
|
||||
headerStyle: { backgroundColor: "transparent" },
|
||||
headerShadowVisible: false,
|
||||
});
|
||||
}, [album?.Name, navigation]);
|
||||
|
||||
const imageUrl = useMemo(
|
||||
() => (album ? getPrimaryImageUrl({ api, item: album }) : null),
|
||||
[api, album],
|
||||
);
|
||||
|
||||
const totalDuration = useMemo(() => {
|
||||
if (!tracks) return "";
|
||||
const totalTicks = tracks.reduce(
|
||||
(acc, track) => acc + (track.RunTimeTicks || 0),
|
||||
0,
|
||||
);
|
||||
return runtimeTicksToMinutes(totalTicks);
|
||||
}, [tracks]);
|
||||
|
||||
const handlePlayAll = useCallback(() => {
|
||||
if (tracks && tracks.length > 0) {
|
||||
playQueue(tracks, 0);
|
||||
}
|
||||
}, [playQueue, tracks]);
|
||||
|
||||
const handleShuffle = useCallback(() => {
|
||||
if (tracks && tracks.length > 0) {
|
||||
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
|
||||
playQueue(shuffled, 0);
|
||||
}
|
||||
}, [playQueue, tracks]);
|
||||
|
||||
// Check if all tracks are already permanently downloaded
|
||||
const allTracksDownloaded = useMemo(() => {
|
||||
if (!tracks || tracks.length === 0) return false;
|
||||
return tracks.every((track) => isPermanentlyDownloaded(track.Id));
|
||||
}, [tracks]);
|
||||
|
||||
const handleDownloadAlbum = useCallback(async () => {
|
||||
if (!tracks || !api || !user?.Id || isDownloading) return;
|
||||
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
for (const track of tracks) {
|
||||
if (!track.Id || isPermanentlyDownloaded(track.Id)) continue;
|
||||
const result = await getAudioStreamUrl(api, user.Id, track.Id);
|
||||
if (result?.url && !result.isTranscoding) {
|
||||
await downloadTrack(track.Id, result.url, {
|
||||
permanent: true,
|
||||
container: result.mediaSource?.Container || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silent fail
|
||||
}
|
||||
setIsDownloading(false);
|
||||
}, [tracks, api, user?.Id, isDownloading]);
|
||||
|
||||
const isLoading = loadingAlbum || loadingTracks;
|
||||
|
||||
// Only show loading if we have no cached data to display
|
||||
if (isLoading && !album) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!album) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Text className='text-neutral-500'>{t("music.album_not_found")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
data={tracks || []}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 100,
|
||||
}}
|
||||
ListHeaderComponent={
|
||||
<View
|
||||
className='items-center px-4 pb-6 bg-black'
|
||||
style={{ paddingTop: insets.top + 60 }}
|
||||
>
|
||||
{/* Album artwork */}
|
||||
<View
|
||||
style={{
|
||||
width: ARTWORK_SIZE,
|
||||
height: ARTWORK_SIZE,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Ionicons name='disc' size={60} color='#666' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Album info */}
|
||||
<Text className='text-white text-xl font-bold mt-4 text-center'>
|
||||
{album.Name}
|
||||
</Text>
|
||||
<Text className='text-purple-400 text-base mt-1'>
|
||||
{album.AlbumArtist || album.Artists?.join(", ")}
|
||||
</Text>
|
||||
<Text className='text-neutral-500 text-sm mt-1'>
|
||||
{album.ProductionYear && `${album.ProductionYear} • `}
|
||||
{tracks?.length} tracks • {totalDuration}
|
||||
</Text>
|
||||
|
||||
{/* Play buttons */}
|
||||
<View className='flex flex-row mt-4 items-center'>
|
||||
<TouchableOpacity
|
||||
onPress={handlePlayAll}
|
||||
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mr-3'
|
||||
>
|
||||
<Ionicons name='play' size={20} color='white' />
|
||||
<Text className='text-white font-medium ml-2'>
|
||||
{t("music.play")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleShuffle}
|
||||
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full mr-3'
|
||||
>
|
||||
<Ionicons name='shuffle' size={20} color='white' />
|
||||
<Text className='text-white font-medium ml-2'>
|
||||
{t("music.shuffle")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleDownloadAlbum}
|
||||
disabled={allTracksDownloaded || isDownloading}
|
||||
className='flex items-center justify-center bg-neutral-800 p-3 rounded-full'
|
||||
>
|
||||
{isDownloading ? (
|
||||
<ActivityIndicator size={20} color='white' />
|
||||
) : (
|
||||
<Ionicons
|
||||
name={
|
||||
allTracksDownloaded
|
||||
? "checkmark-circle"
|
||||
: "download-outline"
|
||||
}
|
||||
size={20}
|
||||
color={allTracksDownloaded ? "#22c55e" : "white"}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item, index }) => (
|
||||
<MusicTrackItem
|
||||
track={item}
|
||||
index={index + 1}
|
||||
queue={tracks}
|
||||
showArtwork={false}
|
||||
onOptionsPress={handleTrackOptionsPress}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={(item) => item.Id!}
|
||||
ListFooterComponent={
|
||||
<>
|
||||
<TrackOptionsSheet
|
||||
open={trackOptionsOpen}
|
||||
setOpen={setTrackOptionsOpen}
|
||||
track={selectedTrack}
|
||||
onAddToPlaylist={handleAddToPlaylist}
|
||||
/>
|
||||
<PlaylistPickerSheet
|
||||
open={playlistPickerOpen}
|
||||
setOpen={setPlaylistPickerOpen}
|
||||
trackToAdd={selectedTrack}
|
||||
onCreateNew={handleCreateNewPlaylist}
|
||||
/>
|
||||
<CreatePlaylistModal
|
||||
open={createPlaylistOpen}
|
||||
setOpen={setCreatePlaylistOpen}
|
||||
initialTrackId={selectedTrack?.Id}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dimensions, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
|
||||
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||
const ARTWORK_SIZE = SCREEN_WIDTH * 0.4;
|
||||
|
||||
export default function ArtistDetailScreen() {
|
||||
const { artistId } = useLocalSearchParams<{ artistId: string }>();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const { playQueue } = useMusicPlayer();
|
||||
|
||||
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
||||
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
||||
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
||||
|
||||
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
||||
setSelectedTrack(track);
|
||||
setTrackOptionsOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleAddToPlaylist = useCallback(() => {
|
||||
setPlaylistPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateNewPlaylist = useCallback(() => {
|
||||
setCreatePlaylistOpen(true);
|
||||
}, []);
|
||||
|
||||
const { data: artist, isLoading: loadingArtist } = useQuery({
|
||||
queryKey: ["music-artist", artistId, user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getUserLibraryApi(api!).getItem({
|
||||
userId: user?.Id,
|
||||
itemId: artistId!,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!artistId,
|
||||
});
|
||||
|
||||
const { data: albums, isLoading: loadingAlbums } = useQuery({
|
||||
queryKey: ["music-artist-albums", artistId, user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getItemsApi(api!).getItems({
|
||||
userId: user?.Id,
|
||||
artistIds: [artistId!],
|
||||
includeItemTypes: ["MusicAlbum"],
|
||||
sortBy: ["ProductionYear", "SortName"],
|
||||
sortOrder: ["Descending", "Ascending"],
|
||||
recursive: true,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!artistId,
|
||||
});
|
||||
|
||||
const { data: topTracks, isLoading: loadingTracks } = useQuery({
|
||||
queryKey: ["music-artist-top-tracks", artistId, user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getItemsApi(api!).getItems({
|
||||
userId: user?.Id,
|
||||
artistIds: [artistId!],
|
||||
includeItemTypes: ["Audio"],
|
||||
sortBy: ["PlayCount"],
|
||||
sortOrder: ["Descending"],
|
||||
limit: 10,
|
||||
recursive: true,
|
||||
filters: ["IsPlayed"],
|
||||
});
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!artistId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
title: artist?.Name ?? "",
|
||||
headerTransparent: true,
|
||||
headerStyle: { backgroundColor: "transparent" },
|
||||
headerShadowVisible: false,
|
||||
});
|
||||
}, [artist?.Name, navigation]);
|
||||
|
||||
const imageUrl = useMemo(
|
||||
() => (artist ? getPrimaryImageUrl({ api, item: artist }) : null),
|
||||
[api, artist],
|
||||
);
|
||||
|
||||
const handlePlayAllTracks = useCallback(() => {
|
||||
if (topTracks && topTracks.length > 0) {
|
||||
playQueue(topTracks, 0);
|
||||
}
|
||||
}, [playQueue, topTracks]);
|
||||
|
||||
const isLoading = loadingArtist || loadingAlbums || loadingTracks;
|
||||
|
||||
// Only show loading if we have no cached data to display
|
||||
if (isLoading && !artist) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!artist) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Text className='text-neutral-500'>{t("music.artist_not_found")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const sections = [];
|
||||
|
||||
// Top tracks section
|
||||
if (topTracks && topTracks.length > 0) {
|
||||
sections.push({
|
||||
id: "top-tracks",
|
||||
title: t("music.top_tracks"),
|
||||
type: "tracks" as const,
|
||||
data: topTracks,
|
||||
});
|
||||
}
|
||||
|
||||
// Albums section
|
||||
if (albums && albums.length > 0) {
|
||||
sections.push({
|
||||
id: "albums",
|
||||
title: t("music.tabs.albums"),
|
||||
type: "albums" as const,
|
||||
data: albums,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
data={sections}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 100,
|
||||
}}
|
||||
ListHeaderComponent={
|
||||
<View
|
||||
className='items-center px-4 pb-6 bg-black'
|
||||
style={{ paddingTop: insets.top + 50 }}
|
||||
>
|
||||
{/* Artist image */}
|
||||
<View
|
||||
style={{
|
||||
width: ARTWORK_SIZE,
|
||||
height: ARTWORK_SIZE,
|
||||
borderRadius: ARTWORK_SIZE / 2,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Ionicons name='person' size={60} color='#666' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Artist info */}
|
||||
<Text className='text-white text-2xl font-bold mt-4 text-center'>
|
||||
{artist.Name}
|
||||
</Text>
|
||||
<Text className='text-neutral-500 text-sm mt-1'>
|
||||
{albums?.length || 0} {t("music.tabs.albums").toLowerCase()}
|
||||
</Text>
|
||||
|
||||
{/* Play button */}
|
||||
{topTracks && topTracks.length > 0 && (
|
||||
<TouchableOpacity
|
||||
onPress={handlePlayAllTracks}
|
||||
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mt-4'
|
||||
>
|
||||
<Ionicons name='play' size={20} color='white' />
|
||||
<Text className='text-white font-medium ml-2'>
|
||||
{t("music.play_top_tracks")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item: section }) => (
|
||||
<View className='mb-6'>
|
||||
<Text className='text-lg font-bold px-4 mb-3'>{section.title}</Text>
|
||||
{section.type === "albums" ? (
|
||||
<HorizontalScroll
|
||||
data={section.data}
|
||||
height={178}
|
||||
keyExtractor={(item) => item.Id!}
|
||||
renderItem={(item) => <MusicAlbumCard album={item} />}
|
||||
/>
|
||||
) : (
|
||||
section.data
|
||||
.slice(0, 5)
|
||||
.map((track, index) => (
|
||||
<MusicTrackItem
|
||||
key={track.Id}
|
||||
track={track}
|
||||
index={index + 1}
|
||||
queue={section.data}
|
||||
onOptionsPress={handleTrackOptionsPress}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={(item) => item.id}
|
||||
ListFooterComponent={
|
||||
<>
|
||||
<TrackOptionsSheet
|
||||
open={trackOptionsOpen}
|
||||
setOpen={setTrackOptionsOpen}
|
||||
track={selectedTrack}
|
||||
onAddToPlaylist={handleAddToPlaylist}
|
||||
/>
|
||||
<PlaylistPickerSheet
|
||||
open={playlistPickerOpen}
|
||||
setOpen={setPlaylistPickerOpen}
|
||||
trackToAdd={selectedTrack}
|
||||
onCreateNew={handleCreateNewPlaylist}
|
||||
/>
|
||||
<CreatePlaylistModal
|
||||
open={createPlaylistOpen}
|
||||
setOpen={setCreatePlaylistOpen}
|
||||
initialTrackId={selectedTrack?.Id}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||
import { PlaylistOptionsSheet } from "@/components/music/PlaylistOptionsSheet";
|
||||
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||
import { useRemoveFromPlaylist } from "@/hooks/usePlaylistMutations";
|
||||
import { downloadTrack, getLocalPath } from "@/providers/AudioStorage";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
|
||||
const ARTWORK_SIZE = 120;
|
||||
|
||||
export default function PlaylistDetailScreen() {
|
||||
const { playlistId } = useLocalSearchParams<{ playlistId: string }>();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const { playQueue } = useMusicPlayer();
|
||||
|
||||
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
||||
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
||||
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
||||
const [playlistOptionsOpen, setPlaylistOptionsOpen] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const removeFromPlaylist = useRemoveFromPlaylist();
|
||||
|
||||
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
||||
setSelectedTrack(track);
|
||||
setTrackOptionsOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleAddToPlaylist = useCallback(() => {
|
||||
setPlaylistPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateNewPlaylist = useCallback(() => {
|
||||
setCreatePlaylistOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleRemoveFromPlaylist = useCallback(() => {
|
||||
if (selectedTrack?.Id && playlistId) {
|
||||
removeFromPlaylist.mutate({
|
||||
playlistId,
|
||||
entryIds: [selectedTrack.PlaylistItemId ?? selectedTrack.Id],
|
||||
});
|
||||
}
|
||||
}, [selectedTrack, playlistId, removeFromPlaylist]);
|
||||
|
||||
const { data: playlist, isLoading: loadingPlaylist } = useQuery({
|
||||
queryKey: ["music-playlist", playlistId, user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getUserLibraryApi(api!).getItem({
|
||||
userId: user?.Id,
|
||||
itemId: playlistId!,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!playlistId,
|
||||
});
|
||||
|
||||
const { data: tracks, isLoading: loadingTracks } = useQuery({
|
||||
queryKey: ["music-playlist-tracks", playlistId, user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getItemsApi(api!).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: playlistId,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!playlistId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
title: playlist?.Name ?? "",
|
||||
headerTransparent: true,
|
||||
headerStyle: { backgroundColor: "transparent" },
|
||||
headerShadowVisible: false,
|
||||
headerRight: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => setPlaylistOptionsOpen(true)}
|
||||
className='p-1.5'
|
||||
>
|
||||
<Ionicons name='ellipsis-horizontal' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [playlist?.Name, navigation]);
|
||||
|
||||
const imageUrl = useMemo(
|
||||
() => (playlist ? getPrimaryImageUrl({ api, item: playlist }) : null),
|
||||
[api, playlist],
|
||||
);
|
||||
|
||||
const totalDuration = useMemo(() => {
|
||||
if (!tracks) return "";
|
||||
const totalTicks = tracks.reduce(
|
||||
(acc, track) => acc + (track.RunTimeTicks || 0),
|
||||
0,
|
||||
);
|
||||
return runtimeTicksToMinutes(totalTicks);
|
||||
}, [tracks]);
|
||||
|
||||
const handlePlayAll = useCallback(() => {
|
||||
if (tracks && tracks.length > 0) {
|
||||
playQueue(tracks, 0);
|
||||
}
|
||||
}, [playQueue, tracks]);
|
||||
|
||||
const handleShuffle = useCallback(() => {
|
||||
if (tracks && tracks.length > 0) {
|
||||
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
|
||||
playQueue(shuffled, 0);
|
||||
}
|
||||
}, [playQueue, tracks]);
|
||||
|
||||
// Check if all tracks are already downloaded
|
||||
const allTracksDownloaded = useMemo(() => {
|
||||
if (!tracks || tracks.length === 0) return false;
|
||||
return tracks.every((track) => !!getLocalPath(track.Id));
|
||||
}, [tracks]);
|
||||
|
||||
const handleDownloadPlaylist = useCallback(async () => {
|
||||
if (!tracks || !api || !user?.Id || isDownloading) return;
|
||||
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
for (const track of tracks) {
|
||||
if (!track.Id || getLocalPath(track.Id)) continue;
|
||||
const result = await getAudioStreamUrl(api, user.Id, track.Id);
|
||||
if (result?.url && !result.isTranscoding) {
|
||||
await downloadTrack(track.Id, result.url, {
|
||||
permanent: true,
|
||||
container: result.mediaSource?.Container || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silent fail
|
||||
}
|
||||
setIsDownloading(false);
|
||||
}, [tracks, api, user?.Id, isDownloading]);
|
||||
|
||||
const isLoading = loadingPlaylist || loadingTracks;
|
||||
|
||||
// Only show loading if we have no cached data to display
|
||||
if (isLoading && !playlist) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!playlist) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Text className='text-neutral-500'>
|
||||
{t("music.playlist_not_found")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
data={tracks || []}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 100,
|
||||
}}
|
||||
ListHeaderComponent={
|
||||
<View
|
||||
className='items-center px-4 pb-6 bg-black'
|
||||
style={{ paddingTop: insets.top + 50 }}
|
||||
>
|
||||
{/* Playlist artwork */}
|
||||
<View
|
||||
style={{
|
||||
width: ARTWORK_SIZE,
|
||||
height: ARTWORK_SIZE,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Ionicons name='list' size={60} color='#666' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Playlist info */}
|
||||
<Text className='text-white text-xl font-bold mt-4 text-center'>
|
||||
{playlist.Name}
|
||||
</Text>
|
||||
<Text className='text-neutral-500 text-sm mt-1'>
|
||||
{tracks?.length} tracks • {totalDuration}
|
||||
</Text>
|
||||
|
||||
{/* Play buttons */}
|
||||
<View className='flex flex-row mt-4 items-center'>
|
||||
<TouchableOpacity
|
||||
onPress={handlePlayAll}
|
||||
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mr-3'
|
||||
>
|
||||
<Ionicons name='play' size={20} color='white' />
|
||||
<Text className='text-white font-medium ml-2'>
|
||||
{t("music.play")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleShuffle}
|
||||
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full mr-3'
|
||||
>
|
||||
<Ionicons name='shuffle' size={20} color='white' />
|
||||
<Text className='text-white font-medium ml-2'>
|
||||
{t("music.shuffle")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleDownloadPlaylist}
|
||||
disabled={allTracksDownloaded || isDownloading}
|
||||
className='flex items-center justify-center bg-neutral-800 p-3 rounded-full'
|
||||
>
|
||||
{isDownloading ? (
|
||||
<ActivityIndicator size={20} color='white' />
|
||||
) : (
|
||||
<Ionicons
|
||||
name={
|
||||
allTracksDownloaded
|
||||
? "checkmark-circle"
|
||||
: "download-outline"
|
||||
}
|
||||
size={20}
|
||||
color={allTracksDownloaded ? "#22c55e" : "white"}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item, index }) => (
|
||||
<MusicTrackItem
|
||||
track={item}
|
||||
index={index + 1}
|
||||
queue={tracks}
|
||||
onOptionsPress={handleTrackOptionsPress}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={(item) => item.Id!}
|
||||
ListFooterComponent={
|
||||
<>
|
||||
<TrackOptionsSheet
|
||||
open={trackOptionsOpen}
|
||||
setOpen={setTrackOptionsOpen}
|
||||
track={selectedTrack}
|
||||
onAddToPlaylist={handleAddToPlaylist}
|
||||
playlistId={playlistId}
|
||||
onRemoveFromPlaylist={handleRemoveFromPlaylist}
|
||||
/>
|
||||
<PlaylistPickerSheet
|
||||
open={playlistPickerOpen}
|
||||
setOpen={setPlaylistPickerOpen}
|
||||
trackToAdd={selectedTrack}
|
||||
onCreateNew={handleCreateNewPlaylist}
|
||||
/>
|
||||
<CreatePlaylistModal
|
||||
open={createPlaylistOpen}
|
||||
setOpen={setCreatePlaylistOpen}
|
||||
initialTrackId={selectedTrack?.Id}
|
||||
/>
|
||||
<PlaylistOptionsSheet
|
||||
open={playlistOptionsOpen}
|
||||
setOpen={setPlaylistOptionsOpen}
|
||||
playlist={playlist}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { AddToFavorites } from "@/components/AddToFavorites";
|
||||
import { DownloadItems } from "@/components/DownloadItem";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { NextUp } from "@/components/series/NextUp";
|
||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
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 { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const params = useLocalSearchParams();
|
||||
const {
|
||||
id: seriesId,
|
||||
seasonIndex,
|
||||
offline: offlineParam,
|
||||
} = params as {
|
||||
id: string;
|
||||
seasonIndex: string;
|
||||
offline?: string;
|
||||
};
|
||||
|
||||
const isOffline = offlineParam === "true";
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
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({
|
||||
queryKey: ["series", seriesId, isOffline, downloadedItems.length],
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
return buildOfflineSeriesFromEpisodes(getDownloadedItems(), seriesId);
|
||||
}
|
||||
return await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: seriesId,
|
||||
});
|
||||
},
|
||||
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||
enabled: isOffline || (!!api && !!user?.Id),
|
||||
});
|
||||
|
||||
// For offline mode, use stored base64 image
|
||||
const base64Image = useMemo(() => {
|
||||
if (isOffline) {
|
||||
return storage.getString(seriesId);
|
||||
}
|
||||
return null;
|
||||
}, [isOffline, seriesId]);
|
||||
|
||||
const backdropUrl = useMemo(() => {
|
||||
if (isOffline && base64Image) {
|
||||
return `data:image/jpeg;base64,${base64Image}`;
|
||||
}
|
||||
return getBackdropUrl({
|
||||
api,
|
||||
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({
|
||||
queryKey: ["AllEpisodes", seriesId, isOffline, downloadedItems.length],
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
return getDownloadedEpisodesForSeries(getDownloadedItems(), seriesId);
|
||||
}
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
const res = await getTvShowsApi(api).getEpisodes({
|
||||
seriesId: seriesId,
|
||||
userId: user.Id,
|
||||
enableUserData: true,
|
||||
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||
});
|
||||
return res?.data.Items || [];
|
||||
},
|
||||
select: (data) =>
|
||||
[...(data || [])].sort(
|
||||
(a, b) =>
|
||||
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
||||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
||||
),
|
||||
staleTime: isOffline ? Infinity : 60,
|
||||
enabled: isOffline || (!!api && !!user?.Id),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Don't show header buttons in offline mode
|
||||
if (isOffline) {
|
||||
navigation.setOptions({
|
||||
headerRight: () => null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
!isLoading && item && allEpisodes && allEpisodes.length > 0 ? (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<AddToFavorites item={item} />
|
||||
{!Platform.isTV && (
|
||||
<DownloadItems
|
||||
size='large'
|
||||
title={t("item_card.download.download_series")}
|
||||
items={allEpisodes || []}
|
||||
MissingDownloadIconComponent={() => (
|
||||
<Ionicons name='download' size={22} color='white' />
|
||||
)}
|
||||
DownloadedIconComponent={() => (
|
||||
<Ionicons
|
||||
name='checkmark-done-outline'
|
||||
size={24}
|
||||
color='#9333ea'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
) : null,
|
||||
});
|
||||
}, [allEpisodes, isLoading, item, isOffline]);
|
||||
|
||||
// For offline mode, we can show the page even without backdropUrl
|
||||
if (!item || (!isOffline && !backdropUrl)) return null;
|
||||
|
||||
return (
|
||||
<OfflineModeProvider isOffline={isOffline}>
|
||||
<ParallaxScrollView
|
||||
headerHeight={400}
|
||||
headerImage={
|
||||
backdropUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: backdropUrl,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
logo={
|
||||
logoUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUrl,
|
||||
}}
|
||||
style={{
|
||||
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>
|
||||
</ParallaxScrollView>
|
||||
</OfflineModeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
ItemFilter,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getFilterApi,
|
||||
@@ -27,7 +28,11 @@ import { useOrientation } from "@/hooks/useOrientation";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
FilterByOption,
|
||||
FilterByPreferenceAtom,
|
||||
filterByAtom,
|
||||
genreFilterAtom,
|
||||
getFilterByPreference,
|
||||
getSortByPreference,
|
||||
getSortOrderPreference,
|
||||
SortByOption,
|
||||
@@ -39,12 +44,19 @@ import {
|
||||
sortOrderOptions,
|
||||
sortOrderPreferenceAtom,
|
||||
tagsFilterAtom,
|
||||
useFilterOptions,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
const Page = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const { libraryId } = searchParams as { libraryId: string };
|
||||
const searchParams = useLocalSearchParams() as {
|
||||
libraryId: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
filterBy?: string;
|
||||
};
|
||||
const { libraryId } = searchParams;
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -54,9 +66,13 @@ const Page = () => {
|
||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||
const [sortBy, _setSortBy] = useAtom(sortByAtom);
|
||||
const [filterBy, _setFilterBy] = useAtom(filterByAtom);
|
||||
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
||||
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
||||
const [sortOrderPreference, setOderByPreference] = useAtom(
|
||||
const [filterByPreference, setFilterByPreference] = useAtom(
|
||||
FilterByPreferenceAtom,
|
||||
);
|
||||
const [sortOrderPreference, setOrderByPreference] = useAtom(
|
||||
sortOrderPreferenceAtom,
|
||||
);
|
||||
|
||||
@@ -65,17 +81,33 @@ const Page = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||
if (sop) {
|
||||
_setSortOrder([sop]);
|
||||
// Check for URL params first (from "See All" navigation)
|
||||
const urlSortBy = searchParams.sortBy as SortByOption | undefined;
|
||||
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 {
|
||||
_setSortOrder([SortOrderOption.Ascending]);
|
||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||
_setSortOrder([sop || SortOrderOption.Ascending]);
|
||||
}
|
||||
const obp = getSortByPreference(libraryId, sortByPreference);
|
||||
if (obp) {
|
||||
_setSortBy([obp]);
|
||||
|
||||
// Apply sortBy: URL param > saved preference > default
|
||||
if (urlSortBy && Object.values(SortByOption).includes(urlSortBy)) {
|
||||
_setSortBy([urlSortBy]);
|
||||
} else {
|
||||
_setSortBy([SortByOption.SortName]);
|
||||
const obp = getSortByPreference(libraryId, sortByPreference);
|
||||
_setSortBy([obp || SortByOption.SortName]);
|
||||
}
|
||||
|
||||
// Apply filterBy: URL param > saved preference > default
|
||||
if (urlFilterBy && Object.values(FilterByOption).includes(urlFilterBy)) {
|
||||
_setFilterBy([urlFilterBy]);
|
||||
} else {
|
||||
const fp = getFilterByPreference(libraryId, filterByPreference);
|
||||
_setFilterBy(fp ? [fp] : []);
|
||||
}
|
||||
}, [
|
||||
libraryId,
|
||||
@@ -83,6 +115,11 @@ const Page = () => {
|
||||
sortByPreference,
|
||||
_setSortOrder,
|
||||
_setSortBy,
|
||||
filterByPreference,
|
||||
_setFilterBy,
|
||||
searchParams.sortBy,
|
||||
searchParams.sortOrder,
|
||||
searchParams.filterBy,
|
||||
]);
|
||||
|
||||
const setSortBy = useCallback(
|
||||
@@ -100,14 +137,28 @@ const Page = () => {
|
||||
(sortOrder: SortOrderOption[]) => {
|
||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||
if (sortOrder[0] !== sop) {
|
||||
setOderByPreference({
|
||||
setOrderByPreference({
|
||||
...sortOrderPreference,
|
||||
[libraryId]: sortOrder[0],
|
||||
});
|
||||
}
|
||||
_setSortOrder(sortOrder);
|
||||
},
|
||||
[libraryId, sortOrderPreference, setOderByPreference, _setSortOrder],
|
||||
[libraryId, sortOrderPreference, setOrderByPreference, _setSortOrder],
|
||||
);
|
||||
|
||||
const setFilter = useCallback(
|
||||
(filterBy: FilterByOption[]) => {
|
||||
const fp = getFilterByPreference(libraryId, filterByPreference);
|
||||
if (filterBy[0] !== fp) {
|
||||
setFilterByPreference({
|
||||
...filterByPreference,
|
||||
[libraryId]: filterBy[0],
|
||||
});
|
||||
}
|
||||
_setFilterBy(filterBy);
|
||||
},
|
||||
[libraryId, filterByPreference, setFilterByPreference, _setFilterBy],
|
||||
);
|
||||
|
||||
const nrOfCols = useMemo(() => {
|
||||
@@ -158,6 +209,10 @@ const Page = () => {
|
||||
itemType = "Series";
|
||||
} else if (library.CollectionType === "boxsets") {
|
||||
itemType = "BoxSet";
|
||||
} else if (library.CollectionType === "homevideos") {
|
||||
itemType = "Video";
|
||||
} else if (library.CollectionType === "musicvideos") {
|
||||
itemType = "MusicVideo";
|
||||
}
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
@@ -168,6 +223,7 @@ const Page = () => {
|
||||
sortBy: [sortBy[0], "SortName", "ProductionYear"],
|
||||
sortOrder: [sortOrder[0]],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
filters: filterBy as ItemFilter[],
|
||||
// true is needed for merged versions
|
||||
recursive: true,
|
||||
imageTypeLimit: 1,
|
||||
@@ -190,6 +246,7 @@ const Page = () => {
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
filterBy,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -203,6 +260,7 @@ const Page = () => {
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
filterBy,
|
||||
],
|
||||
queryFn: fetchItems,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
@@ -268,148 +326,167 @@ const Page = () => {
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
const generalFilters = useFilterOptions();
|
||||
const settings = useSettings();
|
||||
const ListHeaderComponent = useCallback(
|
||||
() => (
|
||||
<View className=''>
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
display: "flex",
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 16,
|
||||
flexDirection: "row",
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
key: "reset",
|
||||
component: <ResetFiltersButton />,
|
||||
},
|
||||
{
|
||||
key: "genre",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='genreFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Genres || [];
|
||||
}}
|
||||
set={setSelectedGenres}
|
||||
values={selectedGenres}
|
||||
title={t("library.filters.genres")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "year",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='yearFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Years || [];
|
||||
}}
|
||||
set={setSelectedYears}
|
||||
values={selectedYears}
|
||||
title={t("library.filters.years")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) => item.includes(search)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='tagsFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Tags || [];
|
||||
}}
|
||||
set={setSelectedTags}
|
||||
values={selectedTags}
|
||||
title={t("library.filters.tags")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortBy",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='sortBy'
|
||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||
set={setSortBy}
|
||||
values={sortBy}
|
||||
title={t("library.filters.sort_by")}
|
||||
renderItemLabel={(item) =>
|
||||
sortOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortOrder",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='sortOrder'
|
||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||
set={setSortOrder}
|
||||
values={sortOrder}
|
||||
title={t("library.filters.sort_order")}
|
||||
renderItemLabel={(item) =>
|
||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
renderItem={({ item }) => item.component}
|
||||
keyExtractor={(item) => item.key}
|
||||
/>
|
||||
</View>
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
display: "flex",
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 16,
|
||||
flexDirection: "row",
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
key: "reset",
|
||||
component: <ResetFiltersButton />,
|
||||
},
|
||||
{
|
||||
key: "genre",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='genreFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Genres || [];
|
||||
}}
|
||||
set={setSelectedGenres}
|
||||
values={selectedGenres}
|
||||
title={t("library.filters.genres")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "year",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='yearFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Years || [];
|
||||
}}
|
||||
set={setSelectedYears}
|
||||
values={selectedYears}
|
||||
title={t("library.filters.years")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) => item.includes(search)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='tagsFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Tags || [];
|
||||
}}
|
||||
set={setSelectedTags}
|
||||
values={selectedTags}
|
||||
title={t("library.filters.tags")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortBy",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='sortBy'
|
||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||
set={setSortBy}
|
||||
values={sortBy}
|
||||
title={t("library.filters.sort_by")}
|
||||
renderItemLabel={(item) =>
|
||||
sortOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortOrder",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='sortOrder'
|
||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||
set={setSortOrder}
|
||||
values={sortOrder}
|
||||
title={t("library.filters.sort_order")}
|
||||
renderItemLabel={(item) =>
|
||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "filterOptions",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='filters'
|
||||
queryFn={async () => generalFilters.map((s) => s.key)}
|
||||
set={setFilter}
|
||||
values={filterBy}
|
||||
title={t("library.filters.filter_by")}
|
||||
renderItemLabel={(item) =>
|
||||
generalFilters.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
renderItem={({ item }) => item.component}
|
||||
keyExtractor={(item) => item.key}
|
||||
/>
|
||||
),
|
||||
[
|
||||
libraryId,
|
||||
@@ -426,6 +503,9 @@ const Page = () => {
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
isFetching,
|
||||
filterBy,
|
||||
setFilter,
|
||||
settings,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -453,7 +533,6 @@ const Page = () => {
|
||||
renderItem={renderItem}
|
||||
extraData={[orientation, nrOfCols]}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemSize={244}
|
||||
numColumns={nrOfCols}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
|
||||
@@ -1,85 +1,208 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity } from "react-native";
|
||||
import { LibraryOptionsSheet } from "@/components/settings/LibraryOptionsSheet";
|
||||
import { Platform, View } from "react-native";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function IndexLayout() {
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const [optionsSheetOpen, setOptionsSheetOpen] = useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Reset dropdown state when component unmounts or navigates away
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Memoize callbacks to prevent recreating on every render
|
||||
const handleDisplayRow = useCallback(() => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
display: "row",
|
||||
},
|
||||
});
|
||||
}, [settings.libraryOptions, updateSettings]);
|
||||
|
||||
const handleDisplayList = useCallback(() => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
display: "list",
|
||||
},
|
||||
});
|
||||
}, [settings.libraryOptions, updateSettings]);
|
||||
|
||||
const handleImageStylePoster = useCallback(() => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
imageStyle: "poster",
|
||||
},
|
||||
});
|
||||
}, [settings.libraryOptions, updateSettings]);
|
||||
|
||||
const handleImageStyleCover = useCallback(() => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
imageStyle: "cover",
|
||||
},
|
||||
});
|
||||
}, [settings.libraryOptions, updateSettings]);
|
||||
|
||||
const handleToggleTitles = useCallback(() => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showTitles: !settings.libraryOptions.showTitles,
|
||||
},
|
||||
});
|
||||
}, [settings.libraryOptions, updateSettings]);
|
||||
|
||||
const handleToggleStats = useCallback(() => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showStats: !settings.libraryOptions.showStats,
|
||||
},
|
||||
});
|
||||
}, [settings.libraryOptions, updateSettings]);
|
||||
|
||||
// Memoize groups to prevent recreating the array on every render
|
||||
const dropdownGroups = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("library.options.display"),
|
||||
options: [
|
||||
{
|
||||
type: "radio" as const,
|
||||
label: t("library.options.row"),
|
||||
value: "row",
|
||||
selected: settings.libraryOptions.display === "row",
|
||||
onPress: handleDisplayRow,
|
||||
},
|
||||
{
|
||||
type: "radio" as const,
|
||||
label: t("library.options.list"),
|
||||
value: "list",
|
||||
selected: settings.libraryOptions.display === "list",
|
||||
onPress: handleDisplayList,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("library.options.image_style"),
|
||||
options: [
|
||||
{
|
||||
type: "radio" as const,
|
||||
label: t("library.options.poster"),
|
||||
value: "poster",
|
||||
selected: settings.libraryOptions.imageStyle === "poster",
|
||||
onPress: handleImageStylePoster,
|
||||
},
|
||||
{
|
||||
type: "radio" as const,
|
||||
label: t("library.options.cover"),
|
||||
value: "cover",
|
||||
selected: settings.libraryOptions.imageStyle === "cover",
|
||||
onPress: handleImageStyleCover,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Options",
|
||||
options: [
|
||||
{
|
||||
type: "toggle" as const,
|
||||
label: t("library.options.show_titles"),
|
||||
value: settings.libraryOptions.showTitles,
|
||||
onToggle: handleToggleTitles,
|
||||
disabled: settings.libraryOptions.imageStyle === "poster",
|
||||
},
|
||||
{
|
||||
type: "toggle" as const,
|
||||
label: t("library.options.show_stats"),
|
||||
value: settings.libraryOptions.showStats,
|
||||
onToggle: handleToggleStats,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
t,
|
||||
settings.libraryOptions,
|
||||
handleDisplayRow,
|
||||
handleDisplayList,
|
||||
handleImageStylePoster,
|
||||
handleImageStyleCover,
|
||||
handleToggleTitles,
|
||||
handleToggleStats,
|
||||
],
|
||||
);
|
||||
|
||||
if (!settings?.libraryOptions) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerTitle: t("tabs.library"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerRight: () =>
|
||||
!pluginSettings?.libraryOptions?.locked &&
|
||||
!Platform.isTV && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setOptionsSheetOpen(true)}
|
||||
className='flex flex-row items-center justify-center w-9 h-9'
|
||||
>
|
||||
<Ionicons
|
||||
name='ellipsis-horizontal-outline'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='[libraryId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
<Stack.Screen
|
||||
name='collections/[collectionId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<LibraryOptionsSheet
|
||||
open={optionsSheetOpen}
|
||||
setOpen={setOptionsSheetOpen}
|
||||
settings={settings.libraryOptions}
|
||||
updateSettings={(options) =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
...options,
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={pluginSettings?.libraryOptions?.locked}
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerTitle: t("tabs.library"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerRight: () =>
|
||||
!pluginSettings?.libraryOptions?.locked &&
|
||||
!Platform.isTV && (
|
||||
<PlatformDropdown
|
||||
open={dropdownOpen}
|
||||
onOpenChange={setDropdownOpen}
|
||||
trigger={
|
||||
<View className='pl-1.5'>
|
||||
<Ionicons
|
||||
name='ellipsis-horizontal-outline'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("library.options.display")}
|
||||
groups={dropdownGroups}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
<Stack.Screen
|
||||
name='[libraryId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
<Stack.Screen
|
||||
name='collections/[collectionId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { Platform, StyleSheet, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
@@ -39,7 +39,6 @@ export default function index() {
|
||||
() =>
|
||||
data
|
||||
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
||||
.filter((l) => l.CollectionType !== "music")
|
||||
.filter((l) => l.CollectionType !== "books") || [],
|
||||
[data, settings?.hiddenLibraries],
|
||||
);
|
||||
@@ -84,11 +83,11 @@ export default function index() {
|
||||
extraData={settings}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingTop: 17,
|
||||
paddingTop: Platform.OS === "android" ? 17 : 0,
|
||||
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
||||
paddingBottom: 150,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingLeft: insets.left + 17,
|
||||
paddingRight: insets.right + 17,
|
||||
}}
|
||||
data={libraries}
|
||||
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||
@@ -105,7 +104,6 @@ export default function index() {
|
||||
<View className='h-4' />
|
||||
)
|
||||
}
|
||||
estimatedItemSize={200}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
85
app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
createMaterialTopTabNavigator,
|
||||
MaterialTopTabNavigationEventMap,
|
||||
MaterialTopTabNavigationOptions,
|
||||
} from "@react-navigation/material-top-tabs";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "@react-navigation/native";
|
||||
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const { Navigator } = createMaterialTopTabNavigator();
|
||||
|
||||
const TAB_LABEL_FONT_SIZE = 13;
|
||||
const TAB_ITEM_HORIZONTAL_PADDING = 12;
|
||||
|
||||
export const Tab = withLayoutContext<
|
||||
MaterialTopTabNavigationOptions,
|
||||
typeof Navigator,
|
||||
TabNavigationState<ParamListBase>,
|
||||
MaterialTopTabNavigationEventMap
|
||||
>(Navigator);
|
||||
|
||||
const Layout = () => {
|
||||
const { libraryId } = useLocalSearchParams<{ libraryId: string }>();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: t("music.title"),
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Tab
|
||||
initialRouteName='suggestions'
|
||||
keyboardDismissMode='none'
|
||||
screenOptions={{
|
||||
tabBarBounces: true,
|
||||
tabBarLabelStyle: {
|
||||
fontSize: TAB_LABEL_FONT_SIZE,
|
||||
fontWeight: "600",
|
||||
flexWrap: "nowrap",
|
||||
},
|
||||
tabBarItemStyle: {
|
||||
width: "auto",
|
||||
paddingHorizontal: TAB_ITEM_HORIZONTAL_PADDING,
|
||||
},
|
||||
tabBarStyle: { backgroundColor: "black" },
|
||||
animationEnabled: true,
|
||||
lazy: true,
|
||||
swipeEnabled: true,
|
||||
tabBarIndicatorStyle: { backgroundColor: "#9334E9" },
|
||||
tabBarScrollEnabled: true,
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name='suggestions'
|
||||
initialParams={{ libraryId }}
|
||||
options={{ title: t("music.tabs.suggestions") }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name='albums'
|
||||
initialParams={{ libraryId }}
|
||||
options={{ title: t("music.tabs.albums") }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name='artists'
|
||||
initialParams={{ libraryId }}
|
||||
options={{ title: t("music.tabs.artists") }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name='playlists'
|
||||
initialParams={{ libraryId }}
|
||||
options={{ title: t("music.tabs.playlists") }}
|
||||
/>
|
||||
</Tab>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
120
app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useRoute } from "@react-navigation/native";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshControl, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MusicAlbumRowCard } from "@/components/music/MusicAlbumRowCard";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
const ITEMS_PER_PAGE = 40;
|
||||
|
||||
export default function AlbumsScreen() {
|
||||
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
||||
const route = useRoute<any>();
|
||||
const libraryId =
|
||||
(Array.isArray(localParams.libraryId)
|
||||
? localParams.libraryId[0]
|
||||
: localParams.libraryId) ?? route?.params?.libraryId;
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
refetch,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["music-albums", libraryId, user?.Id],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
const response = await getItemsApi(api!).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
includeItemTypes: ["MusicAlbum"],
|
||||
sortBy: ["SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
limit: ITEMS_PER_PAGE,
|
||||
startIndex: pageParam,
|
||||
recursive: true,
|
||||
});
|
||||
return {
|
||||
items: response.data.Items || [],
|
||||
totalCount: response.data.TotalRecordCount || 0,
|
||||
startIndex: pageParam,
|
||||
};
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
|
||||
return nextStart < lastPage.totalCount ? nextStart : undefined;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: !!api && !!user?.Id && !!libraryId,
|
||||
});
|
||||
|
||||
const albums = useMemo(() => {
|
||||
return data?.pages.flatMap((page) => page.items) || [];
|
||||
}, [data]);
|
||||
|
||||
const handleEndReached = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (albums.length === 0) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Text className='text-neutral-500'>{t("music.no_albums")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='flex-1 bg-black'>
|
||||
<FlashList
|
||||
data={albums}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 100,
|
||||
paddingTop: 8,
|
||||
paddingHorizontal: 16,
|
||||
}}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={false}
|
||||
onRefresh={refetch}
|
||||
tintColor='#9334E9'
|
||||
/>
|
||||
}
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={0.5}
|
||||
renderItem={({ item }) => <MusicAlbumRowCard album={item} />}
|
||||
keyExtractor={(item) => item.Id!}
|
||||
ListFooterComponent={
|
||||
isFetchingNextPage ? (
|
||||
<View className='py-4'>
|
||||
<Loader />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
157
app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useRoute } from "@react-navigation/native";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshControl, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MusicArtistCard } from "@/components/music/MusicArtistCard";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
// Web uses Limit=100
|
||||
const ITEMS_PER_PAGE = 100;
|
||||
|
||||
export default function ArtistsScreen() {
|
||||
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
||||
const route = useRoute<any>();
|
||||
const libraryId =
|
||||
(Array.isArray(localParams.libraryId)
|
||||
? localParams.libraryId[0]
|
||||
: localParams.libraryId) ?? route?.params?.libraryId;
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isReady = Boolean(api && user?.Id && libraryId);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
refetch,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["music-artists", libraryId, user?.Id],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
const response = await getArtistsApi(api!).getArtists({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
sortBy: ["SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
limit: ITEMS_PER_PAGE,
|
||||
startIndex: pageParam,
|
||||
});
|
||||
return {
|
||||
items: response.data.Items || [],
|
||||
totalCount: response.data.TotalRecordCount || 0,
|
||||
startIndex: pageParam,
|
||||
};
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
|
||||
return nextStart < lastPage.totalCount ? nextStart : undefined;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: isReady,
|
||||
});
|
||||
|
||||
const artists = useMemo(() => {
|
||||
return data?.pages.flatMap((page) => page.items) || [];
|
||||
}, [data]);
|
||||
|
||||
const handleEndReached = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
if (!api || !user?.Id) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!libraryId) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
Missing music library id.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Only show loading if we have no cached data to display
|
||||
if (isLoading && artists.length === 0) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Only show error if we have no cached data to display
|
||||
// This allows offline access to previously cached artists
|
||||
if (isError && artists.length === 0) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
Failed to load artists: {(error as Error)?.message || "Unknown error"}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (artists.length === 0) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Text className='text-neutral-500'>{t("music.no_artists")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='flex-1 bg-black'>
|
||||
<FlashList
|
||||
data={artists}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 100,
|
||||
paddingTop: 8,
|
||||
paddingHorizontal: 16,
|
||||
}}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={false}
|
||||
onRefresh={refetch}
|
||||
tintColor='#9334E9'
|
||||
/>
|
||||
}
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={0.5}
|
||||
renderItem={({ item }) => <MusicArtistCard artist={item} />}
|
||||
keyExtractor={(item) => item.Id!}
|
||||
ListFooterComponent={
|
||||
isFetchingNextPage ? (
|
||||
<View className='py-4'>
|
||||
<Loader />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
234
app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useNavigation, useRoute } from "@react-navigation/native";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshControl, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||
import { MusicPlaylistCard } from "@/components/music/MusicPlaylistCard";
|
||||
import {
|
||||
type PlaylistSortOption,
|
||||
type PlaylistSortOrder,
|
||||
PlaylistSortSheet,
|
||||
} from "@/components/music/PlaylistSortSheet";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
const ITEMS_PER_PAGE = 40;
|
||||
|
||||
export default function PlaylistsScreen() {
|
||||
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
||||
const route = useRoute<any>();
|
||||
const navigation = useNavigation();
|
||||
const libraryId =
|
||||
(Array.isArray(localParams.libraryId)
|
||||
? localParams.libraryId[0]
|
||||
: localParams.libraryId) ?? route?.params?.libraryId;
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
|
||||
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 handleSortChange = useCallback(
|
||||
(newSortBy: PlaylistSortOption, newSortOrder: PlaylistSortOrder) => {
|
||||
setSortBy(newSortBy);
|
||||
setSortOrder(newSortOrder);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => setCreateModalOpen(true)}
|
||||
className='mr-4'
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name='add' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
refetch,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["music-playlists", libraryId, user?.Id, sortBy, sortOrder],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
const response = await getItemsApi(api!).getItems({
|
||||
userId: user?.Id,
|
||||
includeItemTypes: ["Playlist"],
|
||||
sortBy: [sortBy],
|
||||
sortOrder: [sortOrder],
|
||||
limit: ITEMS_PER_PAGE,
|
||||
startIndex: pageParam,
|
||||
recursive: true,
|
||||
mediaTypes: ["Audio"],
|
||||
});
|
||||
return {
|
||||
items: response.data.Items || [],
|
||||
totalCount: response.data.TotalRecordCount || 0,
|
||||
startIndex: pageParam,
|
||||
};
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
|
||||
return nextStart < lastPage.totalCount ? nextStart : undefined;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: isReady,
|
||||
});
|
||||
|
||||
const playlists = useMemo(() => {
|
||||
return data?.pages.flatMap((page) => page.items) || [];
|
||||
}, [data]);
|
||||
|
||||
const handleEndReached = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
if (!api || !user?.Id) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!libraryId) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
Missing music library id.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Only show loading if we have no cached data to display
|
||||
if (isLoading && playlists.length === 0) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Only show error if we have no cached data to display
|
||||
// This allows offline access to previously cached playlists
|
||||
if (isError && playlists.length === 0) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
Failed to load playlists:{" "}
|
||||
{(error as Error)?.message || "Unknown error"}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (playlists.length === 0) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Text className='text-neutral-500 mb-4'>{t("music.no_playlists")}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => setCreateModalOpen(true)}
|
||||
className='flex-row items-center bg-purple-600 px-6 py-3 rounded-full'
|
||||
>
|
||||
<Ionicons name='add' size={20} color='white' />
|
||||
<Text className='text-white font-semibold ml-2'>
|
||||
{t("music.playlists.create_playlist")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<CreatePlaylistModal
|
||||
open={createModalOpen}
|
||||
setOpen={setCreateModalOpen}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='flex-1 bg-black'>
|
||||
<FlashList
|
||||
data={playlists}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 100,
|
||||
paddingTop: 8,
|
||||
paddingHorizontal: 16,
|
||||
}}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={false}
|
||||
onRefresh={refetch}
|
||||
tintColor='#9334E9'
|
||||
/>
|
||||
}
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListHeaderComponent={
|
||||
<TouchableOpacity
|
||||
onPress={() => setSortSheetOpen(true)}
|
||||
className='flex-row items-center mb-2 py-1'
|
||||
>
|
||||
<Ionicons name='swap-vertical' size={18} color='#9334E9' />
|
||||
<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!}
|
||||
ListFooterComponent={
|
||||
isFetchingNextPage ? (
|
||||
<View className='py-4'>
|
||||
<Loader />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<CreatePlaylistModal
|
||||
open={createModalOpen}
|
||||
setOpen={setCreateModalOpen}
|
||||
/>
|
||||
<PlaylistSortSheet
|
||||
open={sortSheetOpen}
|
||||
setOpen={setSortSheetOpen}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
333
app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useRoute } from "@react-navigation/native";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshControl, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
|
||||
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { writeDebugLog } from "@/utils/log";
|
||||
|
||||
export default function SuggestionsScreen() {
|
||||
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
||||
const route = useRoute<any>();
|
||||
const libraryId =
|
||||
(Array.isArray(localParams.libraryId)
|
||||
? localParams.libraryId[0]
|
||||
: localParams.libraryId) ?? route?.params?.libraryId;
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
||||
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
||||
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
||||
|
||||
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
||||
setSelectedTrack(track);
|
||||
setTrackOptionsOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleAddToPlaylist = useCallback(() => {
|
||||
setPlaylistPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateNewPlaylist = useCallback(() => {
|
||||
setCreatePlaylistOpen(true);
|
||||
}, []);
|
||||
|
||||
const isReady = Boolean(api && user?.Id && libraryId);
|
||||
|
||||
writeDebugLog("Music suggestions params", {
|
||||
libraryId,
|
||||
localParams,
|
||||
routeParams: route?.params,
|
||||
isReady,
|
||||
});
|
||||
|
||||
// Latest audio - uses the same endpoint as web: /Users/{userId}/Items/Latest
|
||||
// This returns the most recently added albums
|
||||
const {
|
||||
data: latestAlbums,
|
||||
isLoading: loadingLatest,
|
||||
isError: isLatestError,
|
||||
error: latestError,
|
||||
refetch: refetchLatest,
|
||||
} = useQuery({
|
||||
queryKey: ["music-latest", libraryId, user?.Id],
|
||||
queryFn: async () => {
|
||||
// Prefer the exact endpoint the Web client calls (HAR):
|
||||
// /Users/{userId}/Items/Latest?IncludeItemTypes=Audio&ParentId=...
|
||||
// IMPORTANT: must use api.get(...) (not axiosInstance.get(fullUrl)) so the auth header is attached.
|
||||
const res = await api!.get<BaseItemDto[]>(
|
||||
`/Users/${user!.Id}/Items/Latest`,
|
||||
{
|
||||
params: {
|
||||
IncludeItemTypes: "Audio",
|
||||
Limit: 20,
|
||||
Fields: "PrimaryImageAspectRatio",
|
||||
ParentId: libraryId,
|
||||
ImageTypeLimit: 1,
|
||||
EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
|
||||
EnableTotalRecordCount: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (Array.isArray(res.data) && res.data.length > 0) {
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// Fallback: ask for albums directly via /Items (more reliable across server variants)
|
||||
const fallback = await getItemsApi(api!).getItems({
|
||||
userId: user!.Id,
|
||||
parentId: libraryId,
|
||||
includeItemTypes: ["MusicAlbum"],
|
||||
sortBy: ["DateCreated"],
|
||||
sortOrder: ["Descending"],
|
||||
limit: 20,
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
enableTotalRecordCount: false,
|
||||
});
|
||||
return fallback.data.Items || [];
|
||||
},
|
||||
enabled: isReady,
|
||||
});
|
||||
|
||||
// Recently played - matches web: SortBy=DatePlayed, Filters=IsPlayed
|
||||
const {
|
||||
data: recentlyPlayed,
|
||||
isLoading: loadingRecentlyPlayed,
|
||||
isError: isRecentlyPlayedError,
|
||||
error: recentlyPlayedError,
|
||||
refetch: refetchRecentlyPlayed,
|
||||
} = useQuery({
|
||||
queryKey: ["music-recently-played", libraryId, user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getItemsApi(api!).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
includeItemTypes: ["Audio"],
|
||||
sortBy: ["DatePlayed"],
|
||||
sortOrder: ["Descending"],
|
||||
limit: 10,
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||
filters: ["IsPlayed"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
enableTotalRecordCount: false,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: isReady,
|
||||
});
|
||||
|
||||
// Frequently played - matches web: SortBy=PlayCount, Filters=IsPlayed
|
||||
const {
|
||||
data: frequentlyPlayed,
|
||||
isLoading: loadingFrequent,
|
||||
isError: isFrequentError,
|
||||
error: frequentError,
|
||||
refetch: refetchFrequent,
|
||||
} = useQuery({
|
||||
queryKey: ["music-frequently-played", libraryId, user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getItemsApi(api!).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
includeItemTypes: ["Audio"],
|
||||
sortBy: ["PlayCount"],
|
||||
sortOrder: ["Descending"],
|
||||
limit: 10,
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||
filters: ["IsPlayed"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
enableTotalRecordCount: false,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: isReady,
|
||||
});
|
||||
|
||||
const isLoading = loadingLatest || loadingRecentlyPlayed || loadingFrequent;
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
refetchLatest();
|
||||
refetchRecentlyPlayed();
|
||||
refetchFrequent();
|
||||
}, [refetchLatest, refetchRecentlyPlayed, refetchFrequent]);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const result: {
|
||||
title: string;
|
||||
data: BaseItemDto[];
|
||||
type: "albums" | "tracks";
|
||||
}[] = [];
|
||||
|
||||
// Latest albums section
|
||||
if (latestAlbums && latestAlbums.length > 0) {
|
||||
result.push({
|
||||
title: t("music.recently_added"),
|
||||
data: latestAlbums,
|
||||
type: "albums",
|
||||
});
|
||||
}
|
||||
|
||||
// Recently played tracks
|
||||
if (recentlyPlayed && recentlyPlayed.length > 0) {
|
||||
result.push({
|
||||
title: t("music.recently_played"),
|
||||
data: recentlyPlayed,
|
||||
type: "tracks",
|
||||
});
|
||||
}
|
||||
|
||||
// Frequently played tracks
|
||||
if (frequentlyPlayed && frequentlyPlayed.length > 0) {
|
||||
result.push({
|
||||
title: t("music.frequently_played"),
|
||||
data: frequentlyPlayed,
|
||||
type: "tracks",
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [latestAlbums, frequentlyPlayed, recentlyPlayed, t]);
|
||||
|
||||
if (!api || !user?.Id) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!libraryId) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
Missing music library id.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Only show loading if we have no cached data to display
|
||||
if (isLoading && sections.length === 0) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Only show error if we have no cached data to display
|
||||
// This allows offline access to previously cached suggestions
|
||||
if (
|
||||
(isLatestError || isRecentlyPlayedError || isFrequentError) &&
|
||||
sections.length === 0
|
||||
) {
|
||||
const msg =
|
||||
(latestError as Error | undefined)?.message ||
|
||||
(recentlyPlayedError as Error | undefined)?.message ||
|
||||
(frequentError as Error | undefined)?.message ||
|
||||
"Unknown error";
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
Failed to load music: {msg}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Text className='text-neutral-500'>{t("music.no_suggestions")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='flex-1 bg-black'>
|
||||
<FlashList
|
||||
data={sections}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 100,
|
||||
paddingTop: 16,
|
||||
}}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={false}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor='#9334E9'
|
||||
/>
|
||||
}
|
||||
renderItem={({ item: section }) => (
|
||||
<View className='mb-6'>
|
||||
<Text className='text-lg font-bold px-4 mb-3'>{section.title}</Text>
|
||||
{section.type === "albums" ? (
|
||||
<HorizontalScroll
|
||||
data={section.data}
|
||||
height={178}
|
||||
keyExtractor={(item) => item.Id!}
|
||||
renderItem={(item) => <MusicAlbumCard album={item} />}
|
||||
/>
|
||||
) : (
|
||||
section.data
|
||||
.slice(0, 5)
|
||||
.map((track, index, _tracks) => (
|
||||
<MusicTrackItem
|
||||
key={track.Id}
|
||||
track={track}
|
||||
index={index + 1}
|
||||
queue={section.data}
|
||||
onOptionsPress={handleTrackOptionsPress}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={(item) => item.title}
|
||||
/>
|
||||
<TrackOptionsSheet
|
||||
open={trackOptionsOpen}
|
||||
setOpen={setTrackOptionsOpen}
|
||||
track={selectedTrack}
|
||||
onAddToPlaylist={handleAddToPlaylist}
|
||||
/>
|
||||
<PlaylistPickerSheet
|
||||
open={playlistPickerOpen}
|
||||
setOpen={setPlaylistPickerOpen}
|
||||
trackToAdd={selectedTrack}
|
||||
onCreateNew={handleCreateNewPlaylist}
|
||||
/>
|
||||
<CreatePlaylistModal
|
||||
open={createPlaylistOpen}
|
||||
setOpen={setCreatePlaylistOpen}
|
||||
initialTrackId={selectedTrack?.Id}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export default function SearchLayout() {
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "prominent",
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
|
||||
@@ -3,9 +3,11 @@ import type {
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAsyncDebouncer } from "@tanstack/react-pacer";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
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 {
|
||||
useCallback,
|
||||
@@ -19,13 +21,10 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { Tag } from "@/components/GenreTags";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import {
|
||||
JellyseerrSearchSort,
|
||||
@@ -33,12 +32,17 @@ import {
|
||||
} from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||
import { DiscoverFilters } from "@/components/search/DiscoverFilters";
|
||||
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { createStreamystatsApi } from "@/utils/streamystats";
|
||||
|
||||
type SearchType = "Library" | "Discover";
|
||||
|
||||
@@ -54,6 +58,7 @@ const exampleSearches = [
|
||||
export default function search() {
|
||||
const params = useLocalSearchParams();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
@@ -67,7 +72,23 @@ export default function search() {
|
||||
const [searchType, setSearchType] = useState<SearchType>("Library");
|
||||
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);
|
||||
|
||||
@@ -97,9 +118,11 @@ export default function search() {
|
||||
async ({
|
||||
types,
|
||||
query,
|
||||
signal,
|
||||
}: {
|
||||
types: BaseItemKind[];
|
||||
query: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<BaseItemDto[]> => {
|
||||
if (!api || !query) {
|
||||
return [];
|
||||
@@ -107,16 +130,71 @@ export default function search() {
|
||||
|
||||
try {
|
||||
if (searchEngine === "Jellyfin") {
|
||||
const searchApi = await getItemsApi(api).getItems({
|
||||
searchTerm: query,
|
||||
limit: 10,
|
||||
includeItemTypes: types,
|
||||
recursive: true,
|
||||
userId: user?.Id,
|
||||
});
|
||||
const searchApi = await getItemsApi(api).getItems(
|
||||
{
|
||||
searchTerm: query,
|
||||
limit: 10,
|
||||
includeItemTypes: types,
|
||||
recursive: true,
|
||||
userId: user?.Id,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
return (searchApi.data.Items as BaseItemDto[]) || [];
|
||||
}
|
||||
|
||||
if (searchEngine === "Streamystats") {
|
||||
if (!settings?.streamyStatsServerUrl || !api.accessToken) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const streamyStatsApi = createStreamystatsApi({
|
||||
serverUrl: settings.streamyStatsServerUrl,
|
||||
jellyfinToken: api.accessToken,
|
||||
});
|
||||
|
||||
const typeMap: Record<BaseItemKind, string> = {
|
||||
Movie: "movies",
|
||||
Series: "series",
|
||||
Episode: "episodes",
|
||||
Person: "actors",
|
||||
BoxSet: "movies",
|
||||
Audio: "audio",
|
||||
} as Record<BaseItemKind, string>;
|
||||
|
||||
const searchType = types.length === 1 ? typeMap[types[0]] : "media";
|
||||
const response = await streamyStatsApi.searchIds(
|
||||
query,
|
||||
searchType as "movies" | "series" | "episodes" | "actors" | "media",
|
||||
10,
|
||||
signal,
|
||||
);
|
||||
|
||||
const allIds: string[] = [
|
||||
...(response.data.movies || []),
|
||||
...(response.data.series || []),
|
||||
...(response.data.episodes || []),
|
||||
...(response.data.actors || []),
|
||||
...(response.data.audio || []),
|
||||
];
|
||||
|
||||
if (!allIds.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const itemsResponse = await getItemsApi(api).getItems(
|
||||
{
|
||||
ids: allIds,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
return (itemsResponse.data.Items as BaseItemDto[]) || [];
|
||||
}
|
||||
|
||||
// Marlin search
|
||||
if (!settings?.marlinServerUrl) {
|
||||
return [];
|
||||
}
|
||||
@@ -127,7 +205,7 @@ export default function search() {
|
||||
.map((type) => encodeURIComponent(type))
|
||||
.join("&includeItemTypes=")}`;
|
||||
|
||||
const response1 = await axios.get(url);
|
||||
const response1 = await axios.get(url, { signal });
|
||||
|
||||
const ids = response1.data.ids;
|
||||
|
||||
@@ -135,18 +213,63 @@ export default function search() {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response2 = await getItemsApi(api).getItems({
|
||||
ids,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
});
|
||||
const response2 = await getItemsApi(api).getItems(
|
||||
{
|
||||
ids,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
return (response2.data.Items as BaseItemDto[]) || [];
|
||||
} catch (error) {
|
||||
console.error("Error during search:", error);
|
||||
return []; // Ensure an empty array is returned in case of an error
|
||||
// Silently handle aborted requests
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[api, searchEngine, settings],
|
||||
[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 = {
|
||||
@@ -195,6 +318,7 @@ export default function search() {
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Movie"],
|
||||
signal: abortControllerRef.current?.signal,
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
@@ -205,6 +329,7 @@ export default function search() {
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Series"],
|
||||
signal: abortControllerRef.current?.signal,
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
@@ -215,6 +340,7 @@ export default function search() {
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Episode"],
|
||||
signal: abortControllerRef.current?.signal,
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
@@ -225,6 +351,7 @@ export default function search() {
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["BoxSet"],
|
||||
signal: abortControllerRef.current?.signal,
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
@@ -235,6 +362,52 @@ export default function search() {
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
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,
|
||||
});
|
||||
@@ -245,13 +418,27 @@ export default function search() {
|
||||
episodes?.length ||
|
||||
series?.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(() => {
|
||||
return l1 || l2 || l3 || l7 || l8;
|
||||
}, [l1, l2, l3, l7, l8]);
|
||||
return l1 || l2 || l3 || l7 || l8 || l9 || l10 || l11 || l12;
|
||||
}, [l1, l2, l3, l7, l8, l9, l10, l11, l12]);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
@@ -260,6 +447,7 @@ export default function search() {
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 60,
|
||||
}}
|
||||
>
|
||||
{/* <View
|
||||
@@ -284,67 +472,30 @@ export default function search() {
|
||||
)}
|
||||
<View
|
||||
className='flex flex-col'
|
||||
style={{
|
||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||
}}
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
{jellyseerrApi && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
|
||||
>
|
||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||
<Tag
|
||||
text={t("search.library")}
|
||||
textClass='p-1'
|
||||
className={
|
||||
searchType === "Library" ? "bg-purple-600" : undefined
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||
<Tag
|
||||
text={t("search.discover")}
|
||||
textClass='p-1'
|
||||
className={
|
||||
searchType === "Discover" ? "bg-purple-600" : undefined
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<View className='pl-4 pr-4 flex flex-row'>
|
||||
<SearchTabButtons
|
||||
searchType={searchType}
|
||||
setSearchType={setSearchType}
|
||||
t={t}
|
||||
/>
|
||||
{searchType === "Discover" &&
|
||||
!loading &&
|
||||
noResults &&
|
||||
debouncedSearch.length > 0 && (
|
||||
<View className='flex flex-row justify-end items-center space-x-1'>
|
||||
<FilterButton
|
||||
id={searchFilterId}
|
||||
queryKey='jellyseerr_search'
|
||||
queryFn={async () =>
|
||||
Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||
Number.isNaN(Number(v)),
|
||||
)
|
||||
}
|
||||
set={(value) => setJellyseerrOrderBy(value[0])}
|
||||
values={[jellyseerrOrderBy]}
|
||||
title={t("library.filters.sort_by")}
|
||||
renderItemLabel={(item) =>
|
||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
||||
}
|
||||
disableSearch={true}
|
||||
/>
|
||||
<FilterButton
|
||||
id={orderFilterId}
|
||||
queryKey='jellysearr_search'
|
||||
queryFn={async () => ["asc", "desc"]}
|
||||
set={(value) => setJellyseerrSortOrder(value[0])}
|
||||
values={[jellyseerrSortOrder]}
|
||||
title={t("library.filters.sort_order")}
|
||||
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
||||
disableSearch={true}
|
||||
/>
|
||||
</View>
|
||||
<DiscoverFilters
|
||||
searchFilterId={searchFilterId}
|
||||
orderFilterId={orderFilterId}
|
||||
jellyseerrOrderBy={jellyseerrOrderBy}
|
||||
setJellyseerrOrderBy={setJellyseerrOrderBy}
|
||||
jellyseerrSortOrder={jellyseerrSortOrder}
|
||||
setJellyseerrSortOrder={setJellyseerrSortOrder}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='mt-2'>
|
||||
@@ -435,6 +586,172 @@ export default function search() {
|
||||
</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>
|
||||
) : (
|
||||
<JellyserrIndexPage
|
||||
|
||||
298
app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
RefreshControl,
|
||||
TouchableOpacity,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import {
|
||||
useDeleteWatchlist,
|
||||
useRemoveFromWatchlist,
|
||||
} from "@/hooks/useWatchlistMutations";
|
||||
import {
|
||||
useWatchlistDetailQuery,
|
||||
useWatchlistItemsQuery,
|
||||
} from "@/hooks/useWatchlists";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
export default function WatchlistDetailScreen() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
|
||||
const user = useAtomValue(userAtom);
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const { orientation } = useOrientation();
|
||||
|
||||
const watchlistIdNum = watchlistId
|
||||
? Number.parseInt(watchlistId, 10)
|
||||
: undefined;
|
||||
|
||||
const nrOfCols = useMemo(() => {
|
||||
if (screenWidth < 300) return 2;
|
||||
if (screenWidth < 500) return 3;
|
||||
if (screenWidth < 800) return 5;
|
||||
if (screenWidth < 1000) return 6;
|
||||
if (screenWidth < 1500) return 7;
|
||||
return 6;
|
||||
}, [screenWidth]);
|
||||
|
||||
const {
|
||||
data: watchlist,
|
||||
isLoading: watchlistLoading,
|
||||
refetch: refetchWatchlist,
|
||||
} = useWatchlistDetailQuery(watchlistIdNum);
|
||||
|
||||
const {
|
||||
data: items,
|
||||
isLoading: itemsLoading,
|
||||
refetch: refetchItems,
|
||||
} = useWatchlistItemsQuery(watchlistIdNum);
|
||||
|
||||
const deleteWatchlist = useDeleteWatchlist();
|
||||
const removeFromWatchlist = useRemoveFromWatchlist();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const isOwner = useMemo(
|
||||
() => watchlist?.userId === user?.Id,
|
||||
[watchlist?.userId, user?.Id],
|
||||
);
|
||||
|
||||
// Set up header
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerTitle: watchlist?.name || "",
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
headerRight: isOwner
|
||||
? () => (
|
||||
<View className='flex-row gap-2'>
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push(`/(auth)/(tabs)/(watchlists)/edit/${watchlistId}`)
|
||||
}
|
||||
className='p-2'
|
||||
>
|
||||
<Ionicons name='pencil' size={20} color='white' />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleDelete} className='p-2'>
|
||||
<Ionicons name='trash-outline' size={20} color='#ef4444' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)
|
||||
: undefined,
|
||||
});
|
||||
}, [navigation, watchlist?.name, isOwner, watchlistId]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await Promise.all([refetchWatchlist(), refetchItems()]);
|
||||
setRefreshing(false);
|
||||
}, [refetchWatchlist, refetchItems]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
Alert.alert(
|
||||
t("watchlists.delete_confirm_title"),
|
||||
t("watchlists.delete_confirm_message", { name: watchlist?.name }),
|
||||
[
|
||||
{ text: t("watchlists.cancel_button"), style: "cancel" },
|
||||
{
|
||||
text: t("watchlists.delete_button"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
if (watchlistIdNum) {
|
||||
await deleteWatchlist.mutateAsync(watchlistIdNum);
|
||||
router.back();
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [deleteWatchlist, watchlistIdNum, watchlist?.name, router, t]);
|
||||
|
||||
const handleRemoveItem = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
if (!watchlistIdNum || !item.Id) return;
|
||||
|
||||
Alert.alert(
|
||||
t("watchlists.remove_item_title"),
|
||||
t("watchlists.remove_item_message", { name: item.Name }),
|
||||
[
|
||||
{ text: t("watchlists.cancel_button"), style: "cancel" },
|
||||
{
|
||||
text: t("watchlists.remove_button"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await removeFromWatchlist.mutateAsync({
|
||||
watchlistId: watchlistIdNum,
|
||||
itemId: item.Id!,
|
||||
watchlistName: watchlist?.name,
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
},
|
||||
[removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
item={item}
|
||||
onLongPress={isOwner ? () => handleRemoveItem(item) : undefined}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
alignSelf:
|
||||
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
? index % nrOfCols === 0
|
||||
? "flex-end"
|
||||
: (index + 1) % nrOfCols === 0
|
||||
? "flex-start"
|
||||
: "center"
|
||||
: "center",
|
||||
width: "89%",
|
||||
}}
|
||||
>
|
||||
<ItemPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
),
|
||||
[isOwner, handleRemoveItem, orientation, nrOfCols],
|
||||
);
|
||||
|
||||
const ListHeader = useMemo(
|
||||
() =>
|
||||
watchlist ? (
|
||||
<View className='px-4 pt-4 pb-6 mb-4 border-b border-neutral-800'>
|
||||
{watchlist.description && (
|
||||
<Text className='text-neutral-400 mb-2'>
|
||||
{watchlist.description}
|
||||
</Text>
|
||||
)}
|
||||
<View className='flex-row items-center gap-4'>
|
||||
<View className='flex-row items-center gap-1'>
|
||||
<Ionicons name='film-outline' size={14} color='#9ca3af' />
|
||||
<Text className='text-neutral-400 text-sm'>
|
||||
{items?.length ?? 0}{" "}
|
||||
{(items?.length ?? 0) === 1
|
||||
? t("watchlists.item")
|
||||
: t("watchlists.items")}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='flex-row items-center gap-1'>
|
||||
<Ionicons
|
||||
name={
|
||||
watchlist.isPublic ? "globe-outline" : "lock-closed-outline"
|
||||
}
|
||||
size={14}
|
||||
color='#9ca3af'
|
||||
/>
|
||||
<Text className='text-neutral-400 text-sm'>
|
||||
{watchlist.isPublic
|
||||
? t("watchlists.public")
|
||||
: t("watchlists.private")}
|
||||
</Text>
|
||||
</View>
|
||||
{!isOwner && (
|
||||
<Text className='text-neutral-500 text-sm'>
|
||||
{t("watchlists.by_owner")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
) : null,
|
||||
[watchlist, items?.length, isOwner, t],
|
||||
);
|
||||
|
||||
const EmptyComponent = useMemo(
|
||||
() => (
|
||||
<View className='flex-1 items-center justify-center px-8 py-16'>
|
||||
<Ionicons name='film-outline' size={48} color='#4b5563' />
|
||||
<Text className='text-neutral-400 text-center mt-4'>
|
||||
{t("watchlists.empty_watchlist")}
|
||||
</Text>
|
||||
{isOwner && (
|
||||
<Text className='text-neutral-500 text-center mt-2 text-sm'>
|
||||
{t("watchlists.empty_watchlist_hint")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
),
|
||||
[isOwner, t],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
if (watchlistLoading || itemsLoading) {
|
||||
return (
|
||||
<View className='flex-1 items-center justify-center'>
|
||||
<ActivityIndicator size='large' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!watchlist) {
|
||||
return (
|
||||
<View className='flex-1 items-center justify-center px-8'>
|
||||
<Text className='text-lg text-neutral-400'>
|
||||
{t("watchlists.not_found")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
key={orientation}
|
||||
data={items ?? []}
|
||||
numColumns={nrOfCols}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
ListHeaderComponent={ListHeader}
|
||||
ListEmptyComponent={EmptyComponent}
|
||||
extraData={[orientation, nrOfCols]}
|
||||
keyExtractor={keyExtractor}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 24,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||
}
|
||||
renderItem={renderItem}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
76
app/(auth)/(tabs)/(watchlists)/_layout.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
import { Pressable } from "react-native-gesture-handler";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
|
||||
|
||||
export default function WatchlistsLayout() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const streamystatsEnabled = useStreamystatsEnabled();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerTitle: t("watchlists.title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerRight: streamystatsEnabled
|
||||
? () => (
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
router.push("/(auth)/(tabs)/(watchlists)/create")
|
||||
}
|
||||
className='p-1.5'
|
||||
>
|
||||
<Ionicons name='add' size={24} color='white' />
|
||||
</Pressable>
|
||||
)
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='[watchlistId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='create'
|
||||
options={{
|
||||
title: t("watchlists.create_title"),
|
||||
presentation: "modal",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "#171717" },
|
||||
headerTintColor: "white",
|
||||
contentStyle: { backgroundColor: "#171717" },
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='edit/[watchlistId]'
|
||||
options={{
|
||||
title: t("watchlists.edit_title"),
|
||||
presentation: "modal",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "#171717" },
|
||||
headerTintColor: "white",
|
||||
contentStyle: { backgroundColor: "#171717" },
|
||||
}}
|
||||
/>
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
221
app/(auth)/(tabs)/(watchlists)/create.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
Switch,
|
||||
TextInput,
|
||||
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 { useCreateWatchlist } from "@/hooks/useWatchlistMutations";
|
||||
import type {
|
||||
StreamystatsWatchlistAllowedItemType,
|
||||
StreamystatsWatchlistSortOrder,
|
||||
} from "@/utils/streamystats/types";
|
||||
|
||||
const ITEM_TYPES: Array<{
|
||||
value: StreamystatsWatchlistAllowedItemType;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: null, label: "All Types" },
|
||||
{ value: "Movie", label: "Movies Only" },
|
||||
{ value: "Series", label: "Series Only" },
|
||||
{ value: "Episode", label: "Episodes Only" },
|
||||
];
|
||||
|
||||
const SORT_OPTIONS: Array<{
|
||||
value: StreamystatsWatchlistSortOrder;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "custom", label: "Custom Order" },
|
||||
{ value: "name", label: "Name" },
|
||||
{ value: "dateAdded", label: "Date Added" },
|
||||
{ value: "releaseDate", label: "Release Date" },
|
||||
];
|
||||
|
||||
export default function CreateWatchlistScreen() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const createWatchlist = useCreateWatchlist();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [isPublic, setIsPublic] = useState(false);
|
||||
const [allowedItemType, setAllowedItemType] =
|
||||
useState<StreamystatsWatchlistAllowedItemType>(null);
|
||||
const [defaultSortOrder, setDefaultSortOrder] =
|
||||
useState<StreamystatsWatchlistSortOrder>("custom");
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!name.trim()) return;
|
||||
|
||||
try {
|
||||
await createWatchlist.mutateAsync({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
isPublic,
|
||||
allowedItemType,
|
||||
defaultSortOrder,
|
||||
});
|
||||
router.back();
|
||||
} catch {
|
||||
// Error handled by mutation
|
||||
}
|
||||
}, [
|
||||
name,
|
||||
description,
|
||||
isPublic,
|
||||
allowedItemType,
|
||||
defaultSortOrder,
|
||||
createWatchlist,
|
||||
router,
|
||||
]);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
className='flex-1'
|
||||
style={{ backgroundColor: "#171717" }}
|
||||
>
|
||||
<ScrollView
|
||||
className='flex-1'
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 20,
|
||||
}}
|
||||
keyboardShouldPersistTaps='handled'
|
||||
>
|
||||
{/* Name */}
|
||||
<View className='px-4 py-4'>
|
||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||
{t("watchlists.name_label")} *
|
||||
</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder={t("watchlists.name_placeholder")}
|
||||
placeholderTextColor='#6b7280'
|
||||
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Description */}
|
||||
<View className='px-4 py-4'>
|
||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||
{t("watchlists.description_label")}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder={t("watchlists.description_placeholder")}
|
||||
placeholderTextColor='#6b7280'
|
||||
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
textAlignVertical='top'
|
||||
style={{ minHeight: 80 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Public Toggle */}
|
||||
<View className='px-4 py-4 flex-row items-center justify-between'>
|
||||
<View className='flex-1 mr-4'>
|
||||
<Text className='text-base font-medium text-white'>
|
||||
{t("watchlists.is_public_label")}
|
||||
</Text>
|
||||
<Text className='text-sm text-neutral-400 mt-1'>
|
||||
{t("watchlists.is_public_description")}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={isPublic}
|
||||
onValueChange={setIsPublic}
|
||||
trackColor={{ false: "#374151", true: "#7c3aed" }}
|
||||
thumbColor={isPublic ? "#a78bfa" : "#9ca3af"}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Content Type */}
|
||||
<View className='px-4 py-4'>
|
||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||
{t("watchlists.allowed_type_label")}
|
||||
</Text>
|
||||
<View className='flex-row flex-wrap gap-2'>
|
||||
{ITEM_TYPES.map((type) => (
|
||||
<TouchableOpacity
|
||||
key={type.value ?? "all"}
|
||||
onPress={() => setAllowedItemType(type.value)}
|
||||
className={`px-4 py-2 rounded-lg ${allowedItemType === type.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
||||
>
|
||||
<Text
|
||||
className={
|
||||
allowedItemType === type.value
|
||||
? "text-white font-medium"
|
||||
: "text-neutral-300"
|
||||
}
|
||||
>
|
||||
{type.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Sort Order */}
|
||||
<View className='px-4 py-4'>
|
||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||
{t("watchlists.sort_order_label")}
|
||||
</Text>
|
||||
<View className='flex-row flex-wrap gap-2'>
|
||||
{SORT_OPTIONS.map((sort) => (
|
||||
<TouchableOpacity
|
||||
key={sort.value}
|
||||
onPress={() => setDefaultSortOrder(sort.value)}
|
||||
className={`px-4 py-2 rounded-lg ${defaultSortOrder === sort.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
||||
>
|
||||
<Text
|
||||
className={
|
||||
defaultSortOrder === sort.value
|
||||
? "text-white font-medium"
|
||||
: "text-neutral-300"
|
||||
}
|
||||
>
|
||||
{sort.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Create Button */}
|
||||
<View className='px-4 pt-4'>
|
||||
<Button
|
||||
onPress={handleCreate}
|
||||
disabled={!name.trim() || createWatchlist.isPending}
|
||||
className={`py-3 ${!name.trim() ? "opacity-50" : ""}`}
|
||||
>
|
||||
{createWatchlist.isPending ? (
|
||||
<ActivityIndicator color='white' />
|
||||
) : (
|
||||
<View className='flex-row items-center'>
|
||||
<Ionicons name='add' size={20} color='white' />
|
||||
<Text className='text-white font-semibold text-base'>
|
||||
{t("watchlists.create_button")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
274
app/(auth)/(tabs)/(watchlists)/edit/[watchlistId].tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
Switch,
|
||||
TextInput,
|
||||
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 { useUpdateWatchlist } from "@/hooks/useWatchlistMutations";
|
||||
import { useWatchlistDetailQuery } from "@/hooks/useWatchlists";
|
||||
import type {
|
||||
StreamystatsWatchlistAllowedItemType,
|
||||
StreamystatsWatchlistSortOrder,
|
||||
} from "@/utils/streamystats/types";
|
||||
|
||||
const ITEM_TYPES: Array<{
|
||||
value: StreamystatsWatchlistAllowedItemType;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: null, label: "All Types" },
|
||||
{ value: "Movie", label: "Movies Only" },
|
||||
{ value: "Series", label: "Series Only" },
|
||||
{ value: "Episode", label: "Episodes Only" },
|
||||
];
|
||||
|
||||
const SORT_OPTIONS: Array<{
|
||||
value: StreamystatsWatchlistSortOrder;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "custom", label: "Custom Order" },
|
||||
{ value: "name", label: "Name" },
|
||||
{ value: "dateAdded", label: "Date Added" },
|
||||
{ value: "releaseDate", label: "Release Date" },
|
||||
];
|
||||
|
||||
export default function EditWatchlistScreen() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
|
||||
const watchlistIdNum = watchlistId
|
||||
? Number.parseInt(watchlistId, 10)
|
||||
: undefined;
|
||||
|
||||
const { data: watchlist, isLoading } =
|
||||
useWatchlistDetailQuery(watchlistIdNum);
|
||||
const updateWatchlist = useUpdateWatchlist();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [isPublic, setIsPublic] = useState(false);
|
||||
const [allowedItemType, setAllowedItemType] =
|
||||
useState<StreamystatsWatchlistAllowedItemType>(null);
|
||||
const [defaultSortOrder, setDefaultSortOrder] =
|
||||
useState<StreamystatsWatchlistSortOrder>("custom");
|
||||
|
||||
// Initialize form with watchlist data
|
||||
useEffect(() => {
|
||||
if (watchlist) {
|
||||
setName(watchlist.name);
|
||||
setDescription(watchlist.description ?? "");
|
||||
setIsPublic(watchlist.isPublic);
|
||||
setAllowedItemType(
|
||||
(watchlist.allowedItemType as StreamystatsWatchlistAllowedItemType) ??
|
||||
null,
|
||||
);
|
||||
setDefaultSortOrder(
|
||||
(watchlist.defaultSortOrder as StreamystatsWatchlistSortOrder) ??
|
||||
"custom",
|
||||
);
|
||||
}
|
||||
}, [watchlist]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!name.trim() || !watchlistIdNum) return;
|
||||
|
||||
try {
|
||||
await updateWatchlist.mutateAsync({
|
||||
watchlistId: watchlistIdNum,
|
||||
data: {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
isPublic,
|
||||
allowedItemType,
|
||||
defaultSortOrder,
|
||||
},
|
||||
});
|
||||
router.back();
|
||||
} catch {
|
||||
// Error handled by mutation
|
||||
}
|
||||
}, [
|
||||
name,
|
||||
description,
|
||||
isPublic,
|
||||
allowedItemType,
|
||||
defaultSortOrder,
|
||||
watchlistIdNum,
|
||||
updateWatchlist,
|
||||
router,
|
||||
]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
className='flex-1 items-center justify-center'
|
||||
style={{ backgroundColor: "#171717" }}
|
||||
>
|
||||
<ActivityIndicator size='large' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!watchlist) {
|
||||
return (
|
||||
<View
|
||||
className='flex-1 items-center justify-center px-8'
|
||||
style={{ backgroundColor: "#171717" }}
|
||||
>
|
||||
<Text className='text-lg text-neutral-400'>
|
||||
{t("watchlists.not_found")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
className='flex-1'
|
||||
style={{ backgroundColor: "#171717" }}
|
||||
>
|
||||
<ScrollView
|
||||
className='flex-1'
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 20,
|
||||
}}
|
||||
keyboardShouldPersistTaps='handled'
|
||||
>
|
||||
{/* Name */}
|
||||
<View className='px-4 py-4'>
|
||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||
{t("watchlists.name_label")} *
|
||||
</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder={t("watchlists.name_placeholder")}
|
||||
placeholderTextColor='#6b7280'
|
||||
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Description */}
|
||||
<View className='px-4 py-4'>
|
||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||
{t("watchlists.description_label")}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder={t("watchlists.description_placeholder")}
|
||||
placeholderTextColor='#6b7280'
|
||||
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
textAlignVertical='top'
|
||||
style={{ minHeight: 80 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Public Toggle */}
|
||||
<View className='px-4 py-4 flex-row items-center justify-between'>
|
||||
<View className='flex-1 mr-4'>
|
||||
<Text className='text-base font-medium text-white'>
|
||||
{t("watchlists.is_public_label")}
|
||||
</Text>
|
||||
<Text className='text-sm text-neutral-400 mt-1'>
|
||||
{t("watchlists.is_public_description")}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={isPublic}
|
||||
onValueChange={setIsPublic}
|
||||
trackColor={{ false: "#374151", true: "#7c3aed" }}
|
||||
thumbColor={isPublic ? "#a78bfa" : "#9ca3af"}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Content Type */}
|
||||
<View className='px-4 py-4'>
|
||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||
{t("watchlists.allowed_type_label")}
|
||||
</Text>
|
||||
<View className='flex-row flex-wrap gap-2'>
|
||||
{ITEM_TYPES.map((type) => (
|
||||
<TouchableOpacity
|
||||
key={type.value ?? "all"}
|
||||
onPress={() => setAllowedItemType(type.value)}
|
||||
className={`px-4 py-2 rounded-lg ${allowedItemType === type.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
||||
>
|
||||
<Text
|
||||
className={
|
||||
allowedItemType === type.value
|
||||
? "text-white font-medium"
|
||||
: "text-neutral-300"
|
||||
}
|
||||
>
|
||||
{type.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Sort Order */}
|
||||
<View className='px-4 py-4'>
|
||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||
{t("watchlists.sort_order_label")}
|
||||
</Text>
|
||||
<View className='flex-row flex-wrap gap-2'>
|
||||
{SORT_OPTIONS.map((sort) => (
|
||||
<TouchableOpacity
|
||||
key={sort.value}
|
||||
onPress={() => setDefaultSortOrder(sort.value)}
|
||||
className={`px-4 py-2 rounded-lg ${defaultSortOrder === sort.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
||||
>
|
||||
<Text
|
||||
className={
|
||||
defaultSortOrder === sort.value
|
||||
? "text-white font-medium"
|
||||
: "text-neutral-300"
|
||||
}
|
||||
>
|
||||
{sort.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Save Button */}
|
||||
<View className='px-4 pt-4'>
|
||||
<Button
|
||||
onPress={handleSave}
|
||||
disabled={!name.trim() || updateWatchlist.isPending}
|
||||
className={`py-3 ${!name.trim() ? "opacity-50" : ""}`}
|
||||
>
|
||||
{updateWatchlist.isPending ? (
|
||||
<ActivityIndicator color='white' />
|
||||
) : (
|
||||
<View className='flex-row items-center'>
|
||||
<Ionicons name='checkmark' size={20} color='white' />
|
||||
<Text className='text-white font-semibold text-base'>
|
||||
{t("watchlists.save_button")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
239
app/(auth)/(tabs)/(watchlists)/index.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, RefreshControl, 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 {
|
||||
useStreamystatsEnabled,
|
||||
useWatchlistsQuery,
|
||||
} from "@/hooks/useWatchlists";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
|
||||
|
||||
interface WatchlistCardProps {
|
||||
watchlist: StreamystatsWatchlist;
|
||||
isOwner: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const WatchlistCard: React.FC<WatchlistCardProps> = ({
|
||||
watchlist,
|
||||
isOwner,
|
||||
onPress,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
className='bg-neutral-900 rounded-xl p-4 mx-4 mb-3'
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View className='flex-row items-center justify-between mb-2'>
|
||||
<Text className='text-lg font-semibold flex-1' numberOfLines={1}>
|
||||
{watchlist.name}
|
||||
</Text>
|
||||
<View className='flex-row items-center gap-2'>
|
||||
{isOwner && (
|
||||
<View className='bg-purple-600/20 px-2 py-1 rounded'>
|
||||
<Text className='text-purple-400 text-xs'>
|
||||
{t("watchlists.you")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Ionicons
|
||||
name={watchlist.isPublic ? "globe-outline" : "lock-closed-outline"}
|
||||
size={16}
|
||||
color='#9ca3af'
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{watchlist.description && (
|
||||
<Text className='text-neutral-400 text-sm mb-2' numberOfLines={2}>
|
||||
{watchlist.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View className='flex-row items-center gap-4'>
|
||||
<View className='flex-row items-center gap-1'>
|
||||
<Ionicons name='film-outline' size={14} color='#9ca3af' />
|
||||
<Text className='text-neutral-400 text-sm'>
|
||||
{watchlist.itemCount ?? 0}{" "}
|
||||
{(watchlist.itemCount ?? 0) === 1
|
||||
? t("watchlists.item")
|
||||
: t("watchlists.items")}
|
||||
</Text>
|
||||
</View>
|
||||
{watchlist.allowedItemType && (
|
||||
<View className='bg-neutral-800 px-2 py-0.5 rounded'>
|
||||
<Text className='text-neutral-400 text-xs'>
|
||||
{watchlist.allowedItemType}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyState: React.FC<{ onCreatePress: () => void }> = ({
|
||||
onCreatePress: _onCreatePress,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View className='flex-1 items-center justify-center px-8'>
|
||||
<Ionicons name='list-outline' size={64} color='#4b5563' />
|
||||
<Text className='text-xl font-semibold mt-4 text-center'>
|
||||
{t("watchlists.empty_title")}
|
||||
</Text>
|
||||
<Text className='text-neutral-400 text-center mt-2 mb-6'>
|
||||
{t("watchlists.empty_description")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const NotConfiguredState: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<View className='flex-1 items-center justify-center px-8'>
|
||||
<Ionicons name='settings-outline' size={64} color='#4b5563' />
|
||||
<Text className='text-xl font-semibold mt-4 text-center'>
|
||||
{t("watchlists.not_configured_title")}
|
||||
</Text>
|
||||
<Text className='text-neutral-400 text-center mt-2 mb-6'>
|
||||
{t("watchlists.not_configured_description")}
|
||||
</Text>
|
||||
<Button
|
||||
onPress={() =>
|
||||
router.push(
|
||||
"/(auth)/(tabs)/(home)/settings/plugins/streamystats/page",
|
||||
)
|
||||
}
|
||||
className='px-6'
|
||||
>
|
||||
<Text className='font-semibold'>{t("watchlists.go_to_settings")}</Text>
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default function WatchlistsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const user = useAtomValue(userAtom);
|
||||
const streamystatsEnabled = useStreamystatsEnabled();
|
||||
const { data: watchlists, isLoading, refetch } = useWatchlistsQuery();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await refetch();
|
||||
setRefreshing(false);
|
||||
}, [refetch]);
|
||||
|
||||
const handleCreatePress = useCallback(() => {
|
||||
router.push("/(auth)/(tabs)/(watchlists)/create");
|
||||
}, [router]);
|
||||
|
||||
const handleWatchlistPress = useCallback(
|
||||
(watchlistId: number) => {
|
||||
router.push(`/(auth)/(tabs)/(watchlists)/${watchlistId}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
// Separate watchlists into "mine" and "public"
|
||||
const { myWatchlists, publicWatchlists } = useMemo(() => {
|
||||
if (!watchlists) return { myWatchlists: [], publicWatchlists: [] };
|
||||
|
||||
const mine: StreamystatsWatchlist[] = [];
|
||||
const pub: StreamystatsWatchlist[] = [];
|
||||
|
||||
for (const w of watchlists) {
|
||||
if (w.userId === user?.Id) {
|
||||
mine.push(w);
|
||||
} else {
|
||||
pub.push(w);
|
||||
}
|
||||
}
|
||||
|
||||
return { myWatchlists: mine, publicWatchlists: pub };
|
||||
}, [watchlists, user?.Id]);
|
||||
|
||||
// Combine into sections for FlashList
|
||||
const sections = useMemo(() => {
|
||||
const result: Array<
|
||||
| { type: "header"; title: string }
|
||||
| { type: "watchlist"; data: StreamystatsWatchlist; isOwner: boolean }
|
||||
> = [];
|
||||
|
||||
if (myWatchlists.length > 0) {
|
||||
result.push({ type: "header", title: t("watchlists.my_watchlists") });
|
||||
for (const w of myWatchlists) {
|
||||
result.push({ type: "watchlist", data: w, isOwner: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (publicWatchlists.length > 0) {
|
||||
result.push({ type: "header", title: t("watchlists.public_watchlists") });
|
||||
for (const w of publicWatchlists) {
|
||||
result.push({ type: "watchlist", data: w, isOwner: false });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [myWatchlists, publicWatchlists, t]);
|
||||
|
||||
if (!streamystatsEnabled) {
|
||||
return <NotConfiguredState />;
|
||||
}
|
||||
|
||||
if (!isLoading && (!watchlists || watchlists.length === 0)) {
|
||||
return <EmptyState onCreatePress={handleCreatePress} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
data={sections}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingTop: Platform.OS === "android" ? 10 : 0,
|
||||
paddingBottom: 100,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||
}
|
||||
renderItem={({ item }) => {
|
||||
if (item.type === "header") {
|
||||
return (
|
||||
<Text className='text-lg font-bold px-4 pt-4 pb-2'>
|
||||
{item.title}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WatchlistCard
|
||||
watchlist={item.data}
|
||||
isOwner={item.isOwner}
|
||||
onPress={() => handleWatchlistPress(item.data.id)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
getItemType={(item) => item.type}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,15 +7,15 @@ import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "@react-navigation/native";
|
||||
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { withLayoutContext } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
import { Platform, View } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
|
||||
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
const { Navigator } = createNativeBottomTabNavigator();
|
||||
|
||||
@@ -29,25 +29,9 @@ export const NativeTabs = withLayoutContext<
|
||||
export default function TabLayout() {
|
||||
const { settings } = useSettings();
|
||||
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 (
|
||||
<>
|
||||
<View style={{ flex: 1 }}>
|
||||
<SystemBars hidden={false} style='light' />
|
||||
<NativeTabs
|
||||
sidebarAdaptable={false}
|
||||
@@ -55,6 +39,7 @@ export default function TabLayout() {
|
||||
backgroundColor: "#121212",
|
||||
}}
|
||||
tabBarActiveTintColor={Colors.primary}
|
||||
activeIndicatorColor={"#392c3b"}
|
||||
scrollEdgeAppearance='default'
|
||||
>
|
||||
<NativeTabs.Screen redirect name='index' />
|
||||
@@ -70,10 +55,7 @@ export default function TabLayout() {
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/house.fill.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "house.fill" }
|
||||
: { sfSymbol: "house" },
|
||||
: (_e) => ({ sfSymbol: "house.fill" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
@@ -84,14 +66,12 @@ export default function TabLayout() {
|
||||
})}
|
||||
name='(search)'
|
||||
options={{
|
||||
role: "search",
|
||||
title: t("tabs.search"),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/magnifyingglass.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "magnifyingglass" }
|
||||
: { sfSymbol: "magnifyingglass" },
|
||||
: (_e) => ({ sfSymbol: "magnifyingglass" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
@@ -100,14 +80,20 @@ export default function TabLayout() {
|
||||
title: t("tabs.favorites"),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? ({ focused }) =>
|
||||
focused
|
||||
? require("@/assets/icons/heart.fill.png")
|
||||
: require("@/assets/icons/heart.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "heart.fill" }
|
||||
: { sfSymbol: "heart" },
|
||||
? (_e) => require("@/assets/icons/heart.fill.png")
|
||||
: (_e) => ({ sfSymbol: "heart.fill" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
name='(watchlists)'
|
||||
options={{
|
||||
title: t("watchlists.title"),
|
||||
tabBarItemHidden:
|
||||
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab,
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/list.png")
|
||||
: (_e) => ({ sfSymbol: "list.bullet.rectangle" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
@@ -117,10 +103,7 @@ export default function TabLayout() {
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/server.rack.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "rectangle.stack.fill" }
|
||||
: { sfSymbol: "rectangle.stack" },
|
||||
: (_e) => ({ sfSymbol: "rectangle.stack.fill" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
@@ -131,13 +114,12 @@ export default function TabLayout() {
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/list.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "list.dash.fill" }
|
||||
: { sfSymbol: "list.dash" },
|
||||
: (_e) => ({ sfSymbol: "list.dash.fill" }),
|
||||
}}
|
||||
/>
|
||||
</NativeTabs>
|
||||
</>
|
||||
<MiniPlayerBar />
|
||||
<MusicPlaybackEngine />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
845
app/(auth)/now-playing.tsx
Normal file
@@ -0,0 +1,845 @@
|
||||
import { ExpoAvRoutePickerView } from "@douglowder/expo-av-route-picker-view";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
Platform,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import DraggableFlatList, {
|
||||
type RenderItemParams,
|
||||
ScaleDecorator,
|
||||
} from "react-native-draggable-flatlist";
|
||||
import { CastButton, CastState } from "react-native-google-cast";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
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 { Text } from "@/components/common/Text";
|
||||
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 {
|
||||
type RepeatMode,
|
||||
useMusicPlayer,
|
||||
} from "@/providers/MusicPlayerProvider";
|
||||
import { formatBitrate } from "@/utils/bitrate";
|
||||
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) => {
|
||||
if (!bytes) return null;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
if (bytes === 0) return "0 B";
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const formatSampleRate = (sampleRate?: number | null) => {
|
||||
if (!sampleRate) return null;
|
||||
return `${(sampleRate / 1000).toFixed(1)} kHz`;
|
||||
};
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||
const ARTWORK_SIZE = SCREEN_WIDTH - 80;
|
||||
|
||||
type ViewMode = "player" | "queue";
|
||||
|
||||
export default function NowPlayingScreen() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
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 {
|
||||
currentTrack,
|
||||
queue,
|
||||
queueIndex,
|
||||
isPlaying,
|
||||
isLoading,
|
||||
progress,
|
||||
duration,
|
||||
repeatMode,
|
||||
shuffleEnabled,
|
||||
mediaSource,
|
||||
isTranscoding,
|
||||
togglePlayPause,
|
||||
next,
|
||||
previous,
|
||||
seek,
|
||||
setRepeatMode,
|
||||
toggleShuffle,
|
||||
jumpToIndex,
|
||||
removeFromQueue,
|
||||
reorderQueue,
|
||||
stop,
|
||||
pause,
|
||||
} = useMusicPlayer();
|
||||
|
||||
const { isFavorite, toggleFavorite } = useFavorite(
|
||||
currentTrack ?? ({ Id: "" } as BaseItemDto),
|
||||
);
|
||||
|
||||
const sliderProgress = useSharedValue(0);
|
||||
const sliderMin = useSharedValue(0);
|
||||
const sliderMax = useSharedValue(1);
|
||||
|
||||
useEffect(() => {
|
||||
sliderProgress.value = progress;
|
||||
}, [progress, sliderProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
sliderMax.value = duration > 0 ? duration : 1;
|
||||
}, [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(() => {
|
||||
if (!api || !currentTrack) return null;
|
||||
const albumId = currentTrack.AlbumId || currentTrack.ParentId;
|
||||
if (albumId) {
|
||||
return `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=600&maxWidth=600`;
|
||||
}
|
||||
return `${api.basePath}/Items/${currentTrack.Id}/Images/Primary?maxHeight=600&maxWidth=600`;
|
||||
}, [api, currentTrack]);
|
||||
|
||||
const progressText = useMemo(() => {
|
||||
const progressTicks = progress * 10000000;
|
||||
return formatDuration(progressTicks);
|
||||
}, [progress]);
|
||||
|
||||
const _durationText = useMemo(() => {
|
||||
const durationTicks = duration * 10000000;
|
||||
return formatDuration(durationTicks);
|
||||
}, [duration]);
|
||||
|
||||
const remainingText = useMemo(() => {
|
||||
const remaining = Math.max(0, duration - progress);
|
||||
const remainingTicks = remaining * 10000000;
|
||||
return `-${formatDuration(remainingTicks)}`;
|
||||
}, [duration, progress]);
|
||||
|
||||
const handleSliderComplete = useCallback(
|
||||
(value: number) => {
|
||||
seek(value);
|
||||
},
|
||||
[seek],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const _handleStop = useCallback(() => {
|
||||
stop();
|
||||
router.back();
|
||||
}, [stop, router]);
|
||||
|
||||
const cycleRepeatMode = useCallback(() => {
|
||||
const modes: RepeatMode[] = ["off", "all", "one"];
|
||||
const currentIndex = modes.indexOf(repeatMode);
|
||||
const nextMode = modes[(currentIndex + 1) % modes.length];
|
||||
setRepeatMode(nextMode);
|
||||
}, [repeatMode, setRepeatMode]);
|
||||
|
||||
const handleOptionsPress = useCallback(() => {
|
||||
setTrackOptionsOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleAddToPlaylist = useCallback(() => {
|
||||
setPlaylistPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateNewPlaylist = useCallback(() => {
|
||||
setCreatePlaylistOpen(true);
|
||||
}, []);
|
||||
|
||||
const getRepeatIcon = (): string => {
|
||||
switch (repeatMode) {
|
||||
case "one":
|
||||
return "repeat";
|
||||
case "all":
|
||||
return "repeat";
|
||||
default:
|
||||
return "repeat";
|
||||
}
|
||||
};
|
||||
|
||||
const canGoNext = queueIndex < queue.length - 1 || repeatMode === "all";
|
||||
const canGoPrevious = queueIndex > 0 || progress > 3 || repeatMode === "all";
|
||||
|
||||
if (!currentTrack) {
|
||||
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
|
||||
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
|
||||
onPress={() => setViewMode("player")}
|
||||
className='px-3 py-1'
|
||||
>
|
||||
<Text
|
||||
className={
|
||||
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>
|
||||
|
||||
{viewMode === "player" ? (
|
||||
<PlayerView
|
||||
api={api}
|
||||
currentTrack={currentTrack}
|
||||
imageUrl={imageUrl}
|
||||
sliderProgress={sliderProgress}
|
||||
sliderMin={sliderMin}
|
||||
sliderMax={sliderMax}
|
||||
progressText={progressText}
|
||||
remainingText={remainingText}
|
||||
isPlaying={isPlaying}
|
||||
isLoading={isLoading}
|
||||
repeatMode={repeatMode}
|
||||
shuffleEnabled={shuffleEnabled}
|
||||
canGoNext={canGoNext}
|
||||
canGoPrevious={canGoPrevious}
|
||||
onSliderComplete={handleSliderComplete}
|
||||
onTogglePlayPause={togglePlayPause}
|
||||
onNext={next}
|
||||
onPrevious={previous}
|
||||
onCycleRepeat={cycleRepeatMode}
|
||||
onToggleShuffle={toggleShuffle}
|
||||
getRepeatIcon={getRepeatIcon}
|
||||
mediaSource={mediaSource}
|
||||
isTranscoding={isTranscoding}
|
||||
isFavorite={isFavorite}
|
||||
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
|
||||
open={playlistPickerOpen}
|
||||
setOpen={setPlaylistPickerOpen}
|
||||
trackToAdd={currentTrack}
|
||||
onCreateNew={handleCreateNewPlaylist}
|
||||
/>
|
||||
<CreatePlaylistModal
|
||||
open={createPlaylistOpen}
|
||||
setOpen={setCreatePlaylistOpen}
|
||||
initialTrackId={currentTrack?.Id}
|
||||
/>
|
||||
</View>
|
||||
</BottomSheetModalProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface PlayerViewProps {
|
||||
api: any;
|
||||
currentTrack: BaseItemDto;
|
||||
imageUrl: string | null;
|
||||
sliderProgress: any;
|
||||
sliderMin: any;
|
||||
sliderMax: any;
|
||||
progressText: string;
|
||||
remainingText: string;
|
||||
isPlaying: boolean;
|
||||
isLoading: boolean;
|
||||
repeatMode: RepeatMode;
|
||||
shuffleEnabled: boolean;
|
||||
canGoNext: boolean;
|
||||
canGoPrevious: boolean;
|
||||
onSliderComplete: (value: number) => void;
|
||||
onTogglePlayPause: () => void;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
onCycleRepeat: () => void;
|
||||
onToggleShuffle: () => void;
|
||||
getRepeatIcon: () => string;
|
||||
mediaSource: MediaSourceInfo | null;
|
||||
isTranscoding: boolean;
|
||||
isFavorite: boolean | undefined;
|
||||
onToggleFavorite: () => void;
|
||||
onOptionsPress: () => void;
|
||||
isCastConnected: boolean;
|
||||
}
|
||||
|
||||
const PlayerView: React.FC<PlayerViewProps> = ({
|
||||
currentTrack,
|
||||
imageUrl,
|
||||
sliderProgress,
|
||||
sliderMin,
|
||||
sliderMax,
|
||||
progressText,
|
||||
remainingText,
|
||||
isPlaying,
|
||||
isLoading,
|
||||
repeatMode,
|
||||
shuffleEnabled,
|
||||
canGoNext,
|
||||
canGoPrevious,
|
||||
onSliderComplete,
|
||||
onTogglePlayPause,
|
||||
onNext,
|
||||
onPrevious,
|
||||
onCycleRepeat,
|
||||
onToggleShuffle,
|
||||
getRepeatIcon,
|
||||
mediaSource,
|
||||
isTranscoding,
|
||||
isFavorite,
|
||||
onToggleFavorite,
|
||||
onOptionsPress,
|
||||
isCastConnected,
|
||||
}) => {
|
||||
const audioStream = useMemo(() => {
|
||||
return mediaSource?.MediaStreams?.find((stream) => stream.Type === "Audio");
|
||||
}, [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 codec = audioStream?.Codec?.toUpperCase();
|
||||
const bitrate = formatBitrate(audioStream?.BitRate);
|
||||
const sampleRate = formatSampleRate(audioStream?.SampleRate);
|
||||
const playbackMethod = isTranscoding ? "Transcoding" : "Direct";
|
||||
|
||||
const hasAudioStats =
|
||||
mediaSource && (fileSize || codec || bitrate || sampleRate);
|
||||
return (
|
||||
<ScrollView className='flex-1 px-6' showsVerticalScrollIndicator={false}>
|
||||
{/* Album artwork */}
|
||||
<View
|
||||
className='self-center mb-8 mt-4'
|
||||
style={{
|
||||
width: ARTWORK_SIZE,
|
||||
height: ARTWORK_SIZE,
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 16,
|
||||
elevation: 10,
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Ionicons name='musical-note' size={80} color='#666' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Track info with actions */}
|
||||
<View className='mb-6'>
|
||||
<View className='flex-row items-start justify-between'>
|
||||
<View className='flex-1 mr-4'>
|
||||
<TextTicker
|
||||
style={{ color: "white", fontSize: 24, fontWeight: "bold" }}
|
||||
duration={Math.max(4000, (currentTrack.Name?.length || 0) * 250)}
|
||||
loop
|
||||
bounce={false}
|
||||
repeatSpacer={80}
|
||||
marqueeDelay={1500}
|
||||
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 */}
|
||||
{hasAudioStats && (
|
||||
<View className='flex-row flex-wrap gap-1.5 mt-3'>
|
||||
{fileSize && <Badge variant='gray' text={fileSize} />}
|
||||
{codec && <Badge variant='gray' text={codec} />}
|
||||
<Badge
|
||||
variant='gray'
|
||||
text={playbackMethod}
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name={isTranscoding ? "swap-horizontal" : "play"}
|
||||
size={12}
|
||||
color='white'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{bitrate && bitrate !== "N/A" && (
|
||||
<Badge variant='gray' text={bitrate} />
|
||||
)}
|
||||
{sampleRate && <Badge variant='gray' text={sampleRate} />}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Progress slider */}
|
||||
<View className='mb-4'>
|
||||
<Slider
|
||||
theme={{
|
||||
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||
minimumTrackTintColor: "#fff",
|
||||
bubbleBackgroundColor: "#fff",
|
||||
bubbleTextColor: "#666",
|
||||
}}
|
||||
progress={sliderProgress}
|
||||
minimumValue={sliderMin}
|
||||
maximumValue={sliderMax}
|
||||
onSlidingComplete={onSliderComplete}
|
||||
renderThumb={() => null}
|
||||
sliderHeight={8}
|
||||
containerStyle={{ borderRadius: 100 }}
|
||||
renderBubble={() => null}
|
||||
/>
|
||||
<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'>{remainingText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Main Controls with Shuffle & Repeat */}
|
||||
<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
|
||||
onPress={onPrevious}
|
||||
disabled={!canGoPrevious || isLoading}
|
||||
className='p-4'
|
||||
style={{ opacity: canGoPrevious && !isLoading ? 1 : 0.3 }}
|
||||
>
|
||||
<Ionicons name='play-skip-back' size={32} color='white' />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={onTogglePlayPause}
|
||||
disabled={isLoading}
|
||||
className='mx-4 bg-white rounded-full p-4'
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size={36} color='#121212' />
|
||||
) : (
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={36}
|
||||
color='#121212'
|
||||
style={isPlaying ? {} : { marginLeft: 4 }}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={onNext}
|
||||
disabled={!canGoNext || isLoading}
|
||||
className='p-4'
|
||||
style={{ opacity: canGoNext && !isLoading ? 1 : 0.3 }}
|
||||
>
|
||||
<Ionicons name='play-skip-forward' size={32} color='white' />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity onPress={onCycleRepeat} className='p-3 relative'>
|
||||
<Ionicons
|
||||
name={getRepeatIcon() as any}
|
||||
size={24}
|
||||
color={repeatMode !== "off" ? "#9334E9" : "#666"}
|
||||
/>
|
||||
{repeatMode === "one" && (
|
||||
<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>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Volume Slider */}
|
||||
{!isTv && VolumeManager && (
|
||||
<View className='flex-row items-center mb-6'>
|
||||
<Ionicons name='volume-low' size={20} color='#666' />
|
||||
<View className='flex-1 mx-3'>
|
||||
<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>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
interface QueueViewProps {
|
||||
api: any;
|
||||
queue: BaseItemDto[];
|
||||
queueIndex: number;
|
||||
onJumpToIndex: (index: number) => void;
|
||||
onRemoveFromQueue: (index: number) => void;
|
||||
onReorderQueue: (newQueue: BaseItemDto[]) => void;
|
||||
}
|
||||
|
||||
const QueueView: React.FC<QueueViewProps> = ({
|
||||
api,
|
||||
queue,
|
||||
queueIndex,
|
||||
onJumpToIndex,
|
||||
onRemoveFromQueue,
|
||||
onReorderQueue,
|
||||
}) => {
|
||||
const renderQueueItem = useCallback(
|
||||
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
||||
const index = getIndex() ?? 0;
|
||||
const isCurrentTrack = index === queueIndex;
|
||||
const isPast = index < queueIndex;
|
||||
|
||||
const albumId = item.AlbumId || item.ParentId;
|
||||
const imageUrl = api
|
||||
? albumId
|
||||
? `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=80&maxWidth=80`
|
||||
: `${api.basePath}/Items/${item.Id}/Images/Primary?maxHeight=80&maxWidth=80`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ScaleDecorator>
|
||||
<TouchableOpacity
|
||||
onPress={() => onJumpToIndex(index)}
|
||||
onLongPress={drag}
|
||||
disabled={isActive}
|
||||
className='flex-row items-center px-4 py-3'
|
||||
style={{
|
||||
opacity: isPast && !isActive ? 0.5 : 1,
|
||||
backgroundColor: isActive
|
||||
? "#2a2a2a"
|
||||
: isCurrentTrack
|
||||
? "rgba(147, 52, 233, 0.3)"
|
||||
: "#121212",
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<TouchableOpacity
|
||||
onPressIn={drag}
|
||||
disabled={isActive}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
className='pr-2'
|
||||
>
|
||||
<Ionicons
|
||||
name='reorder-three'
|
||||
size={20}
|
||||
color={isActive ? "#9334E9" : "#666"}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Album art */}
|
||||
<View className='w-12 h-12 rounded overflow-hidden bg-neutral-800 mr-3'>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center'>
|
||||
<Ionicons name='musical-note' size={16} color='#666' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Track info */}
|
||||
<View className='flex-1 mr-2'>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className={`text-base ${isCurrentTrack ? "text-purple-400 font-semibold" : "text-white"}`}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-neutral-500 text-sm'>
|
||||
{item.Artists?.join(", ") || item.AlbumArtist}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Now playing indicator */}
|
||||
{isCurrentTrack && (
|
||||
<Ionicons name='musical-note' size={16} color='#9334E9' />
|
||||
)}
|
||||
|
||||
{/* Remove button (not for current track) */}
|
||||
{!isCurrentTrack && (
|
||||
<TouchableOpacity
|
||||
onPress={() => onRemoveFromQueue(index)}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
className='p-2'
|
||||
>
|
||||
<Ionicons name='close' size={20} color='#666' />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</ScaleDecorator>
|
||||
);
|
||||
},
|
||||
[api, queueIndex, onJumpToIndex, onRemoveFromQueue],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
({ data }: { data: BaseItemDto[] }) => {
|
||||
onReorderQueue(data);
|
||||
},
|
||||
[onReorderQueue],
|
||||
);
|
||||
|
||||
const history = queue.slice(0, queueIndex);
|
||||
|
||||
return (
|
||||
<DraggableFlatList
|
||||
data={queue}
|
||||
keyExtractor={(item, index) => `${item.Id}-${index}`}
|
||||
renderItem={renderQueueItem}
|
||||
onDragEnd={handleDragEnd}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListHeaderComponent={
|
||||
<View className='px-4 py-2'>
|
||||
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
||||
{history.length > 0 ? "Playing from queue" : "Up next"}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<View className='flex-1 items-center justify-center py-20'>
|
||||
<Text className='text-neutral-500'>Queue is empty</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,33 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import { AppState } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function Layout() {
|
||||
const { settings } = useSettings();
|
||||
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.defaultVideoOrientation) {
|
||||
lockOrientation(settings.defaultVideoOrientation);
|
||||
}
|
||||
|
||||
// Re-apply orientation lock when app returns to foreground (iOS resets it)
|
||||
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||
if (nextAppState === "active" && settings?.defaultVideoOrientation) {
|
||||
lockOrientation(settings.defaultVideoOrientation);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
unlockOrientation();
|
||||
};
|
||||
}, [settings?.defaultVideoOrientation, lockOrientation, unlockOrientation]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SystemBars hidden />
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
type MediaSourceInfo,
|
||||
PlaybackOrder,
|
||||
PlaybackProgressInfo,
|
||||
PlaybackStartInfo,
|
||||
RepeatMode,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
@@ -11,61 +10,78 @@ import {
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
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 { BITRATES } from "@/components/BitrateSelector";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
|
||||
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
|
||||
import {
|
||||
OUTLINE_THICKNESS,
|
||||
OutlineThickness,
|
||||
VLC_COLORS,
|
||||
VLCColor,
|
||||
} from "@/constants/SubtitleConstants";
|
||||
PlaybackSpeedScope,
|
||||
updatePlaybackSpeedSettings,
|
||||
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { VlcPlayerView } from "@/modules";
|
||||
import type {
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
VlcPlayerViewRef,
|
||||
} from "@/modules/VlcPlayer.types";
|
||||
import {
|
||||
type MpvOnErrorEventPayload,
|
||||
type MpvOnPlaybackStateChangePayload,
|
||||
type MpvOnProgressEventPayload,
|
||||
MpvPlayerView,
|
||||
type MpvPlayerViewRef,
|
||||
type MpvVideoSource,
|
||||
} from "@/modules";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import {
|
||||
getMpvAudioId,
|
||||
getMpvSubtitleId,
|
||||
} from "@/utils/jellyfin/subtitleUtils";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
|
||||
export default function page() {
|
||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||
const videoRef = useRef<MpvPlayerViewRef>(null);
|
||||
const user = useAtomValue(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const router = useRouter();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
|
||||
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
|
||||
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, _setShowControls] = useState(true);
|
||||
const [aspectRatio, setAspectRatio] = useState<
|
||||
"default" | "16:9" | "4:3" | "1:1" | "21:9"
|
||||
>("default");
|
||||
const [scaleFactor, setScaleFactor] = useState<
|
||||
1.0 | 1.1 | 1.2 | 1.3 | 1.4 | 1.5 | 1.6 | 1.7 | 1.8 | 1.9 | 2.0
|
||||
>(1.0);
|
||||
const [isPipMode, setIsPipMode] = useState(false);
|
||||
const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">(
|
||||
"default",
|
||||
);
|
||||
const [isZoomedToFill, setIsZoomedToFill] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
const [tracksReady, setTracksReady] = useState(false);
|
||||
const [hasPlaybackStarted, setHasPlaybackStarted] = useState(false);
|
||||
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0);
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
@@ -75,6 +91,8 @@ export default function page() {
|
||||
: require("react-native-volume-manager");
|
||||
|
||||
const downloadUtils = useDownload();
|
||||
// Call directly instead of useMemo - the function reference doesn't change
|
||||
// when data updates, only when the provider initializes
|
||||
const downloadedFiles = downloadUtils.getDownloadedItems();
|
||||
|
||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||
@@ -94,7 +112,7 @@ export default function page() {
|
||||
bitrateValue: bitrateValueStr,
|
||||
offline: offlineStr,
|
||||
playbackPosition: playbackPositionFromUrl,
|
||||
} = useGlobalSearchParams<{
|
||||
} = useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
@@ -104,12 +122,14 @@ export default function page() {
|
||||
/** Playback position in ticks. */
|
||||
playbackPosition?: string;
|
||||
}>();
|
||||
const { settings } = useSettings();
|
||||
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||
|
||||
const offline = offlineStr === "true";
|
||||
const playbackManager = usePlaybackManager();
|
||||
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)
|
||||
: undefined;
|
||||
const subtitleIndex = subtitleIndexStr
|
||||
@@ -128,13 +148,49 @@ export default function page() {
|
||||
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
|
||||
const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed(
|
||||
item,
|
||||
settings,
|
||||
);
|
||||
|
||||
// Handler for changing playback speed
|
||||
const handleSetPlaybackSpeed = useCallback(
|
||||
async (speed: number, scope: PlaybackSpeedScope) => {
|
||||
// Update settings based on scope
|
||||
updatePlaybackSpeedSettings(
|
||||
speed,
|
||||
scope,
|
||||
item ?? undefined,
|
||||
settings,
|
||||
updateSettings,
|
||||
);
|
||||
|
||||
// Apply speed to the current player (MPV)
|
||||
setCurrentPlaybackSpeed(speed);
|
||||
await videoRef.current?.setSpeed?.(speed);
|
||||
},
|
||||
[item, settings, updateSettings],
|
||||
);
|
||||
|
||||
/** Gets the initial playback position from the URL. */
|
||||
const getInitialPlaybackTicks = useCallback((): number => {
|
||||
if (playbackPositionFromUrl) {
|
||||
return Number.parseInt(playbackPositionFromUrl, 10);
|
||||
}
|
||||
return item?.UserData?.PlaybackPositionTicks ?? 0;
|
||||
}, [playbackPositionFromUrl]);
|
||||
}, [playbackPositionFromUrl, item?.UserData?.PlaybackPositionTicks]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchItemData = async () => {
|
||||
@@ -167,6 +223,17 @@ export default function page() {
|
||||
}
|
||||
}, [itemId, offline, api, user?.Id]);
|
||||
|
||||
// Lock orientation based on user settings
|
||||
useEffect(() => {
|
||||
if (settings?.defaultVideoOrientation) {
|
||||
lockOrientation(settings.defaultVideoOrientation);
|
||||
}
|
||||
|
||||
return () => {
|
||||
unlockOrientation();
|
||||
};
|
||||
}, [settings?.defaultVideoOrientation, lockOrientation, unlockOrientation]);
|
||||
|
||||
interface Stream {
|
||||
mediaSource: MediaSourceInfo;
|
||||
sessionId: string;
|
||||
@@ -213,21 +280,25 @@ export default function page() {
|
||||
return;
|
||||
}
|
||||
|
||||
const native = generateDeviceProfile();
|
||||
const transcoding = generateDeviceProfile({ transcode: true });
|
||||
// Calculate start ticks directly from item to avoid stale closure
|
||||
const startTicks = playbackPositionFromUrl
|
||||
? Number.parseInt(playbackPositionFromUrl, 10)
|
||||
: (item?.UserData?.PlaybackPositionTicks ?? 0);
|
||||
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
startTimeTicks: getInitialPlaybackTicks(),
|
||||
startTimeTicks: startTicks,
|
||||
userId: user.Id,
|
||||
audioStreamIndex: audioIndex,
|
||||
maxStreamingBitrate: bitrateValue,
|
||||
mediaSourceId: mediaSourceId,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: bitrateValue ? transcoding : native,
|
||||
deviceProfile: generateDeviceProfile(),
|
||||
});
|
||||
if (!res) return;
|
||||
const { mediaSource, sessionId, url } = res;
|
||||
|
||||
if (!sessionId || !mediaSource || !url) {
|
||||
Alert.alert(
|
||||
t("player.error"),
|
||||
@@ -256,38 +327,47 @@ export default function page() {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stream || !api) return;
|
||||
if (!stream || !api || offline) return;
|
||||
const reportPlaybackStart = async () => {
|
||||
await getPlaystateApi(api).reportPlaybackStart({
|
||||
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
|
||||
});
|
||||
const progressInfo = currentPlayStateInfo();
|
||||
if (progressInfo) {
|
||||
await getPlaystateApi(api).reportPlaybackStart({
|
||||
playbackStartInfo: progressInfo,
|
||||
});
|
||||
}
|
||||
};
|
||||
reportPlaybackStart();
|
||||
}, [stream, api]);
|
||||
}, [stream, api, offline]);
|
||||
|
||||
const togglePlay = async () => {
|
||||
lightHapticFeedback();
|
||||
setIsPlaying(!isPlaying);
|
||||
if (isPlaying) {
|
||||
await videoRef.current?.pause();
|
||||
playbackManager.reportPlaybackProgress(
|
||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
||||
);
|
||||
const progressInfo = currentPlayStateInfo();
|
||||
if (progressInfo) {
|
||||
playbackManager.reportPlaybackProgress(progressInfo);
|
||||
}
|
||||
} else {
|
||||
videoRef.current?.play();
|
||||
await getPlaystateApi(api!).reportPlaybackStart({
|
||||
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
|
||||
});
|
||||
const progressInfo = currentPlayStateInfo();
|
||||
if (!offline && api) {
|
||||
await getPlaystateApi(api).reportPlaybackStart({
|
||||
playbackStartInfo: progressInfo,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reportPlaybackStopped = useCallback(async () => {
|
||||
if (!item?.Id || !stream?.sessionId || offline || !api) return;
|
||||
|
||||
const currentTimeInTicks = msToTicks(progress.get());
|
||||
await getPlaystateApi(api!).onPlaybackStopped({
|
||||
itemId: item?.Id!,
|
||||
await getPlaystateApi(api).onPlaybackStopped({
|
||||
itemId: item.Id,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: currentTimeInTicks,
|
||||
playSessionId: stream?.sessionId!,
|
||||
playSessionId: stream.sessionId,
|
||||
});
|
||||
}, [
|
||||
api,
|
||||
@@ -306,7 +386,7 @@ export default function page() {
|
||||
});
|
||||
reportPlaybackStopped();
|
||||
setIsPlaybackStopped(true);
|
||||
videoRef.current?.stop();
|
||||
videoRef.current?.pause();
|
||||
revalidateProgressCache();
|
||||
}, [videoRef, reportPlaybackStopped, progress]);
|
||||
|
||||
@@ -317,21 +397,24 @@ export default function page() {
|
||||
};
|
||||
}, [navigation, stop]);
|
||||
|
||||
const currentPlayStateInfo = useCallback(() => {
|
||||
if (!stream) return;
|
||||
const currentPlayStateInfo = useCallback(():
|
||||
| PlaybackProgressInfo
|
||||
| undefined => {
|
||||
if (!stream || !item?.Id) return;
|
||||
|
||||
return {
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: msToTicks(progress.get()),
|
||||
isPaused: !isPlaying,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream.sessionId,
|
||||
isMuted: isMuted,
|
||||
canSeek: true,
|
||||
repeatMode: RepeatMode.RepeatNone,
|
||||
playbackOrder: PlaybackOrder.Default,
|
||||
ItemId: item.Id,
|
||||
AudioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
SubtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
MediaSourceId: mediaSourceId,
|
||||
PositionTicks: msToTicks(progress.get()),
|
||||
IsPaused: !isPlaying,
|
||||
PlayMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
PlaySessionId: stream.sessionId,
|
||||
IsMuted: isMuted,
|
||||
CanSeek: true,
|
||||
RepeatMode: RepeatMode.RepeatNone,
|
||||
PlaybackOrder: PlaybackOrder.Default,
|
||||
};
|
||||
}, [
|
||||
stream,
|
||||
@@ -360,11 +443,15 @@ export default function page() {
|
||||
[],
|
||||
);
|
||||
|
||||
/** Progress handler for MPV - position in seconds */
|
||||
const onProgress = useCallback(
|
||||
async (data: ProgressUpdatePayload) => {
|
||||
async (data: { nativeEvent: MpvOnProgressEventPayload }) => {
|
||||
if (isSeeking.get() || isPlaybackStopped) return;
|
||||
|
||||
const { currentTime } = data.nativeEvent;
|
||||
const { position } = data.nativeEvent;
|
||||
// MPV reports position in seconds, convert to ms
|
||||
const currentTime = position * 1000;
|
||||
|
||||
if (isBuffering) {
|
||||
setIsBuffering(false);
|
||||
}
|
||||
@@ -406,10 +493,85 @@ export default function page() {
|
||||
);
|
||||
|
||||
/** Gets the initial playback position in seconds. */
|
||||
const startPosition = useMemo(() => {
|
||||
const _startPosition = useMemo(() => {
|
||||
return ticksToSeconds(getInitialPlaybackTicks());
|
||||
}, [getInitialPlaybackTicks]);
|
||||
|
||||
/** Build video source config for MPV */
|
||||
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
|
||||
if (!stream?.url) return undefined;
|
||||
|
||||
const mediaSource = stream.mediaSource;
|
||||
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||
|
||||
// Get external subtitle URLs
|
||||
// - Online: prepend API base path to server URLs
|
||||
// - Offline: use local file paths (stored in DeliveryUrl during download)
|
||||
let externalSubs: string[] | undefined;
|
||||
if (!offline && api?.basePath) {
|
||||
externalSubs = mediaSource?.MediaStreams?.filter(
|
||||
(s) =>
|
||||
s.Type === "Subtitle" &&
|
||||
s.DeliveryMethod === "External" &&
|
||||
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
|
||||
const initialSubtitleId = getMpvSubtitleId(
|
||||
mediaSource,
|
||||
subtitleIndex,
|
||||
isTranscoding,
|
||||
);
|
||||
const initialAudioId = getMpvAudioId(mediaSource, audioIndex);
|
||||
|
||||
// Calculate start position directly here to avoid timing issues
|
||||
const startTicks = playbackPositionFromUrl
|
||||
? Number.parseInt(playbackPositionFromUrl, 10)
|
||||
: (item?.UserData?.PlaybackPositionTicks ?? 0);
|
||||
const startPos = ticksToSeconds(startTicks);
|
||||
|
||||
// Build source config - headers only needed for online streaming
|
||||
const source: MpvVideoSource = {
|
||||
url: stream.url,
|
||||
startPosition: startPos,
|
||||
autoplay: true,
|
||||
initialSubtitleId,
|
||||
initialAudioId,
|
||||
};
|
||||
|
||||
// Add external subtitles only for online playback
|
||||
if (externalSubs && externalSubs.length > 0) {
|
||||
source.externalSubtitles = externalSubs;
|
||||
}
|
||||
|
||||
// Add auth headers only for online streaming (not for local file:// URLs)
|
||||
if (!offline && api?.accessToken) {
|
||||
source.headers = {
|
||||
Authorization: `MediaBrowser Token="${api.accessToken}"`,
|
||||
};
|
||||
}
|
||||
|
||||
return source;
|
||||
}, [
|
||||
stream?.url,
|
||||
stream?.mediaSource,
|
||||
item?.UserData?.PlaybackPositionTicks,
|
||||
playbackPositionFromUrl,
|
||||
api?.basePath,
|
||||
api?.accessToken,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
offline,
|
||||
]);
|
||||
|
||||
const volumeUpCb = useCallback(async () => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
@@ -489,11 +651,15 @@ export default function page() {
|
||||
setVolume: setVolumeCb,
|
||||
});
|
||||
|
||||
/** Playback state handler for MPV */
|
||||
const onPlaybackStateChanged = useCallback(
|
||||
async (e: PlaybackStatePayload) => {
|
||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||
if (state === "Playing") {
|
||||
async (e: { nativeEvent: MpvOnPlaybackStateChangePayload }) => {
|
||||
const { isPaused, isPlaying: playing, isLoading } = e.nativeEvent;
|
||||
|
||||
if (playing) {
|
||||
setIsPlaying(true);
|
||||
setIsBuffering(false);
|
||||
setHasPlaybackStarted(true);
|
||||
if (item?.Id) {
|
||||
playbackManager.reportPlaybackProgress(
|
||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
||||
@@ -503,7 +669,7 @@ export default function page() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === "Paused") {
|
||||
if (isPaused) {
|
||||
setIsPlaying(false);
|
||||
if (item?.Id) {
|
||||
playbackManager.reportPlaybackProgress(
|
||||
@@ -514,86 +680,25 @@ export default function page() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
setIsPlaying(true);
|
||||
setIsBuffering(false);
|
||||
} else if (isBuffering) {
|
||||
setIsBuffering(true);
|
||||
if (isLoading !== undefined) {
|
||||
setIsBuffering(isLoading);
|
||||
}
|
||||
},
|
||||
[playbackManager, item?.Id, progress],
|
||||
);
|
||||
|
||||
const allAudio =
|
||||
stream?.mediaSource.MediaStreams?.filter(
|
||||
(audio) => audio.Type === "Audio",
|
||||
) || [];
|
||||
|
||||
// Move all the external subtitles last, because vlc places them last.
|
||||
const allSubs =
|
||||
stream?.mediaSource.MediaStreams?.filter(
|
||||
(sub) => sub.Type === "Subtitle",
|
||||
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || [];
|
||||
|
||||
const externalSubtitles = allSubs
|
||||
.filter((sub: any) => sub.DeliveryMethod === "External")
|
||||
.map((sub: any) => ({
|
||||
name: sub.DisplayTitle,
|
||||
DeliveryUrl: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl,
|
||||
}));
|
||||
/** The text based subtitle tracks */
|
||||
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
||||
/** The user chosen subtitle track from the server */
|
||||
const chosenSubtitleTrack = allSubs.find(
|
||||
(sub) => sub.Index === subtitleIndex,
|
||||
/** PiP handler for MPV */
|
||||
const _onPictureInPictureChange = useCallback(
|
||||
(e: { nativeEvent: { isActive: boolean } }) => {
|
||||
const { isActive } = e.nativeEvent;
|
||||
setIsPipMode(isActive);
|
||||
// Hide controls when entering PiP
|
||||
if (isActive) {
|
||||
_setShowControls(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
/** The user chosen audio track from the server */
|
||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||
/** Whether the stream we're playing is not transcoding*/
|
||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||
/** The initial options to pass to the VLC Player */
|
||||
const initOptions = [``];
|
||||
if (
|
||||
chosenSubtitleTrack &&
|
||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
||||
) {
|
||||
// If not transcoding, we can the index as normal.
|
||||
// If transcoding, we need to reverse the text based subtitles, because VLC reverses the HLS subtitles.
|
||||
const finalIndex = notTranscoding
|
||||
? allSubs.indexOf(chosenSubtitleTrack)
|
||||
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
|
||||
initOptions.push(`--sub-track=${finalIndex}`);
|
||||
|
||||
// Add VLC subtitle styling options from settings
|
||||
const textColor = (settings.vlcTextColor ?? "White") as VLCColor;
|
||||
const backgroundColor = (settings.vlcBackgroundColor ??
|
||||
"Black") as VLCColor;
|
||||
const outlineColor = (settings.vlcOutlineColor ?? "Black") as VLCColor;
|
||||
const outlineThickness = (settings.vlcOutlineThickness ??
|
||||
"Normal") as OutlineThickness;
|
||||
const backgroundOpacity = settings.vlcBackgroundOpacity ?? 128;
|
||||
const outlineOpacity = settings.vlcOutlineOpacity ?? 255;
|
||||
const isBold = settings.vlcIsBold ?? false;
|
||||
// Add subtitle styling options
|
||||
initOptions.push(`--freetype-color=${VLC_COLORS[textColor]}`);
|
||||
initOptions.push(`--freetype-background-opacity=${backgroundOpacity}`);
|
||||
initOptions.push(
|
||||
`--freetype-background-color=${VLC_COLORS[backgroundColor]}`,
|
||||
);
|
||||
initOptions.push(`--freetype-outline-opacity=${outlineOpacity}`);
|
||||
initOptions.push(`--freetype-outline-color=${VLC_COLORS[outlineColor]}`);
|
||||
initOptions.push(
|
||||
`--freetype-outline-thickness=${OUTLINE_THICKNESS[outlineThickness]}`,
|
||||
);
|
||||
initOptions.push(`--sub-text-scale=${settings.subtitleSize}`);
|
||||
initOptions.push("--sub-margin=40");
|
||||
if (isBold) {
|
||||
initOptions.push("--freetype-bold");
|
||||
}
|
||||
}
|
||||
if (notTranscoding && chosenAudioTrack) {
|
||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||
}
|
||||
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
@@ -607,6 +712,7 @@ export default function page() {
|
||||
const startPictureInPicture = useCallback(async () => {
|
||||
return videoRef.current?.startPictureInPicture?.();
|
||||
}, []);
|
||||
|
||||
const play = useCallback(() => {
|
||||
videoRef.current?.play?.();
|
||||
}, []);
|
||||
@@ -616,46 +722,92 @@ export default function page() {
|
||||
}, []);
|
||||
|
||||
const seek = useCallback((position: number) => {
|
||||
videoRef.current?.seekTo?.(position);
|
||||
}, []);
|
||||
const getAudioTracks = useCallback(async () => {
|
||||
return videoRef.current?.getAudioTracks?.() || null;
|
||||
// MPV expects seconds, convert from ms
|
||||
videoRef.current?.seekTo?.(position / 1000);
|
||||
}, []);
|
||||
|
||||
const getSubtitleTracks = useCallback(async () => {
|
||||
return videoRef.current?.getSubtitleTracks?.() || null;
|
||||
}, []);
|
||||
const handleZoomToggle = useCallback(async () => {
|
||||
const newZoomState = !isZoomedToFill;
|
||||
await videoRef.current?.setZoomedToFill?.(newZoomState);
|
||||
setIsZoomedToFill(newZoomState);
|
||||
|
||||
const setSubtitleTrack = useCallback((index: number) => {
|
||||
videoRef.current?.setSubtitleTrack?.(index);
|
||||
}, []);
|
||||
|
||||
const setSubtitleURL = useCallback((url: string, _customName?: string) => {
|
||||
// Note: VlcPlayer type only expects url parameter
|
||||
videoRef.current?.setSubtitleURL?.(url);
|
||||
}, []);
|
||||
|
||||
const setAudioTrack = useCallback((index: number) => {
|
||||
videoRef.current?.setAudioTrack?.(index);
|
||||
}, []);
|
||||
|
||||
const setVideoAspectRatio = useCallback(
|
||||
async (aspectRatio: string | null) => {
|
||||
return (
|
||||
videoRef.current?.setVideoAspectRatio?.(aspectRatio) ||
|
||||
Promise.resolve()
|
||||
// Adjust subtitle position to compensate for video cropping when zoomed
|
||||
if (newZoomState) {
|
||||
// Get video dimensions from mediaSource
|
||||
const videoStream = stream?.mediaSource?.MediaStreams?.find(
|
||||
(s) => s.Type === "Video",
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const videoWidth = videoStream?.Width ?? 1920;
|
||||
const videoHeight = videoStream?.Height ?? 1080;
|
||||
|
||||
const setVideoScaleFactor = useCallback(async (scaleFactor: number) => {
|
||||
return (
|
||||
videoRef.current?.setVideoScaleFactor?.(scaleFactor) || Promise.resolve()
|
||||
);
|
||||
}, []);
|
||||
const videoAR = videoWidth / videoHeight;
|
||||
const screenAR = screenWidth / screenHeight;
|
||||
|
||||
console.log("Debug: component render"); // Uncomment to debug re-renders
|
||||
if (screenAR > videoAR) {
|
||||
// Screen is wider than video - video height extends beyond screen
|
||||
// Calculate how much of the video is cropped at the bottom (as % of video height)
|
||||
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);
|
||||
}
|
||||
}, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]);
|
||||
|
||||
// Apply subtitle settings when video loads
|
||||
useEffect(() => {
|
||||
if (!isVideoLoaded || !videoRef.current) return;
|
||||
|
||||
const applySubtitleSettings = async () => {
|
||||
if (settings.mpvSubtitleScale !== undefined) {
|
||||
await videoRef.current?.setSubtitleScale?.(settings.mpvSubtitleScale);
|
||||
}
|
||||
if (settings.mpvSubtitleMarginY !== undefined) {
|
||||
await videoRef.current?.setSubtitleMarginY?.(
|
||||
settings.mpvSubtitleMarginY,
|
||||
);
|
||||
}
|
||||
if (settings.mpvSubtitleAlignX !== undefined) {
|
||||
await videoRef.current?.setSubtitleAlignX?.(settings.mpvSubtitleAlignX);
|
||||
}
|
||||
if (settings.mpvSubtitleAlignY !== undefined) {
|
||||
await videoRef.current?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY);
|
||||
}
|
||||
if (settings.mpvSubtitleFontSize !== undefined) {
|
||||
await videoRef.current?.setSubtitleFontSize?.(
|
||||
settings.mpvSubtitleFontSize,
|
||||
);
|
||||
}
|
||||
// Apply subtitle size from general settings
|
||||
if (settings.subtitleSize) {
|
||||
await videoRef.current?.setSubtitleFontSize?.(settings.subtitleSize);
|
||||
}
|
||||
};
|
||||
|
||||
applySubtitleSettings();
|
||||
}, [isVideoLoaded, settings]);
|
||||
|
||||
// Apply initial playback speed when video loads
|
||||
useEffect(() => {
|
||||
if (!isVideoLoaded || !videoRef.current) return;
|
||||
|
||||
const applyInitialPlaybackSpeed = async () => {
|
||||
if (initialPlaybackSpeed !== 1.0) {
|
||||
setCurrentPlaybackSpeed(initialPlaybackSpeed);
|
||||
await videoRef.current?.setSpeed?.(initialPlaybackSpeed);
|
||||
}
|
||||
};
|
||||
|
||||
applyInitialPlaybackSpeed();
|
||||
}, [isVideoLoaded, initialPlaybackSpeed]);
|
||||
|
||||
// Show error UI first, before checking loading/missing‐data
|
||||
if (itemStatus.isError || streamStatus.isError) {
|
||||
@@ -666,7 +818,7 @@ export default function page() {
|
||||
);
|
||||
}
|
||||
|
||||
// Then show loader while either side is still fetching or data isn’t present
|
||||
// Then show loader while either side is still fetching or data isn't present
|
||||
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
|
||||
// …loader UI…
|
||||
return (
|
||||
@@ -684,86 +836,99 @@ export default function page() {
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "black",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
<OfflineModeProvider isOffline={offline}>
|
||||
<PlayerProvider
|
||||
playerRef={videoRef}
|
||||
item={item}
|
||||
mediaSource={stream?.mediaSource}
|
||||
isVideoLoaded={isVideoLoaded}
|
||||
tracksReady={tracksReady}
|
||||
downloadedItem={downloadedItem}
|
||||
>
|
||||
<VlcPlayerView
|
||||
ref={videoRef}
|
||||
source={{
|
||||
uri: stream?.url || "",
|
||||
autoplay: true,
|
||||
isNetwork: !offline,
|
||||
startPosition,
|
||||
externalSubtitles,
|
||||
initOptions,
|
||||
}}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
onVideoProgress={onProgress}
|
||||
progressUpdateInterval={1000}
|
||||
onVideoStateChange={onPlaybackStateChanged}
|
||||
onVideoLoadEnd={() => {
|
||||
setIsVideoLoaded(true);
|
||||
}}
|
||||
onVideoError={(e) => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
{isMounted === true && item && (
|
||||
<Controls
|
||||
mediaSource={stream?.mediaSource}
|
||||
item={item}
|
||||
videoRef={videoRef}
|
||||
togglePlay={togglePlay}
|
||||
isPlaying={isPlaying}
|
||||
isSeeking={isSeeking}
|
||||
progress={progress}
|
||||
cacheProgress={cacheProgress}
|
||||
isBuffering={isBuffering}
|
||||
showControls={showControls}
|
||||
setShowControls={setShowControls}
|
||||
isVideoLoaded={isVideoLoaded}
|
||||
startPictureInPicture={startPictureInPicture}
|
||||
play={play}
|
||||
pause={pause}
|
||||
seek={seek}
|
||||
enableTrickplay={true}
|
||||
getAudioTracks={getAudioTracks}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
offline={offline}
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
setAudioTrack={setAudioTrack}
|
||||
setVideoAspectRatio={setVideoAspectRatio}
|
||||
setVideoScaleFactor={setVideoScaleFactor}
|
||||
aspectRatio={aspectRatio}
|
||||
scaleFactor={scaleFactor}
|
||||
setAspectRatio={setAspectRatio}
|
||||
setScaleFactor={setScaleFactor}
|
||||
isVlc
|
||||
downloadedFiles={downloadedFiles}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<VideoProvider>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "black",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<MpvPlayerView
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
onProgress={onProgress}
|
||||
onPlaybackStateChange={onPlaybackStateChanged}
|
||||
onLoad={() => setIsVideoLoaded(true)}
|
||||
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
|
||||
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);
|
||||
}}
|
||||
onTracksReady={() => {
|
||||
setTracksReady(true);
|
||||
}}
|
||||
/>
|
||||
{!hasPlaybackStarted && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "black",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Loader />
|
||||
</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}
|
||||
aspectRatio={aspectRatio}
|
||||
isZoomedToFill={isZoomedToFill}
|
||||
onZoomToggle={handleZoomToggle}
|
||||
api={api}
|
||||
downloadedFiles={downloadedFiles}
|
||||
playbackSpeed={currentPlaybackSpeed}
|
||||
setPlaybackSpeed={handleSetPlaybackSpeed}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</VideoProvider>
|
||||
</PlayerProvider>
|
||||
</OfflineModeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
351
app/_layout.tsx
@@ -1,18 +1,30 @@
|
||||
import "@/augmentations";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||
import { onlineManager, QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import * as BackgroundTask from "expo-background-task";
|
||||
import * as Device from "expo-device";
|
||||
import { Platform } from "react-native";
|
||||
import { GlobalModal } from "@/components/GlobalModal";
|
||||
import i18n from "@/i18n";
|
||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
|
||||
import { IntroSheetProvider } from "@/providers/IntroSheetProvider";
|
||||
import {
|
||||
apiAtom,
|
||||
getOrSetDeviceId,
|
||||
getTokenFromStorage,
|
||||
JellyfinProvider,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
|
||||
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
import { type Settings, useSettings } from "@/utils/atoms/settings";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
BACKGROUND_FETCH_TASK,
|
||||
BACKGROUND_FETCH_TASK_SESSIONS,
|
||||
@@ -26,44 +38,30 @@ import {
|
||||
} from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
const BackGroundDownloader = !Platform.isTV
|
||||
? require("@kesha-antonov/react-native-background-downloader")
|
||||
: null;
|
||||
|
||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import * as BackgroundTask from "expo-background-task";
|
||||
|
||||
import * as Device from "expo-device";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
|
||||
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||
|
||||
import { getLocales } from "expo-localization";
|
||||
import { router, Stack, useSegments } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
|
||||
import * as TaskManager from "expo-task-manager";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { Appearance, AppState } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import "react-native-reanimated";
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
import { getLocales } from "expo-localization";
|
||||
import type { EventSubscription } from "expo-modules-core";
|
||||
import type {
|
||||
Notification,
|
||||
NotificationResponse,
|
||||
} from "expo-notifications/build/Notifications.types";
|
||||
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
||||
import { useAtom } from "jotai";
|
||||
import { Toaster } from "sonner-native";
|
||||
import { Stack, useSegments } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import * as TaskManager from "expo-task-manager";
|
||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { Appearance } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { store } from "@/utils/store";
|
||||
import "react-native-reanimated";
|
||||
import { Toaster } from "sonner-native";
|
||||
|
||||
if (!Platform.isTV) {
|
||||
Notifications.setNotificationHandler({
|
||||
@@ -84,14 +82,9 @@ SplashScreen.setOptions({
|
||||
fade: true,
|
||||
});
|
||||
|
||||
function redirect(notification: typeof Notifications.Notification) {
|
||||
const url = notification.request.content.data?.url;
|
||||
if (url) {
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
function useNotificationObserver() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
@@ -102,14 +95,17 @@ function useNotificationObserver() {
|
||||
if (!isMounted || !response?.notification) {
|
||||
return;
|
||||
}
|
||||
redirect(response?.notification);
|
||||
const url = response?.notification.request.content.data?.url;
|
||||
if (url) {
|
||||
router.push(url);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
}, [router]);
|
||||
}
|
||||
|
||||
if (!Platform.isTV) {
|
||||
@@ -131,24 +127,7 @@ if (!Platform.isTV) {
|
||||
|
||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||
console.log("TaskManager ~ trigger");
|
||||
|
||||
const settingsData = storage.getString("settings");
|
||||
|
||||
if (!settingsData) return BackgroundTask.BackgroundTaskResult.Failed;
|
||||
|
||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||
|
||||
if (!settings?.autoDownload)
|
||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
||||
|
||||
const token = getTokenFromStorage();
|
||||
const deviceId = getOrSetDeviceId();
|
||||
const baseDirectory = FileSystem.documentDirectory;
|
||||
|
||||
if (!token || !deviceId || !baseDirectory)
|
||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
||||
|
||||
// Be sure to return the successful result type!
|
||||
// Background fetch task placeholder - currently unused
|
||||
return BackgroundTask.BackgroundTaskResult.Success;
|
||||
});
|
||||
}
|
||||
@@ -210,15 +189,39 @@ 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({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
retryOnMount: true,
|
||||
staleTime: 0, // Always stale - triggers background refetch on mount
|
||||
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
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create MMKV-based persister for offline support
|
||||
const mmkvPersister = createSyncStoragePersister({
|
||||
storage: {
|
||||
getItem: (key) => storage.getString(key) ?? null,
|
||||
setItem: (key, value) => storage.set(key, value),
|
||||
removeItem: (key) => storage.remove(key),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -226,8 +229,8 @@ function Layout() {
|
||||
const { settings } = useSettings();
|
||||
const [user] = useAtom(userAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
const appState = useRef(AppState.currentState);
|
||||
const segments = useSegments();
|
||||
const _segments = useSegments();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(
|
||||
@@ -256,7 +259,7 @@ function Layout() {
|
||||
} else console.log("No token available");
|
||||
}, [api, expoPushToken, user]);
|
||||
|
||||
async function registerNotifications() {
|
||||
const registerNotifications = useCallback(async () => {
|
||||
if (Platform.OS === "android") {
|
||||
console.log("Setting android notification channel 'default'");
|
||||
await Notifications?.setNotificationChannelAsync("default", {
|
||||
@@ -287,11 +290,21 @@ function Layout() {
|
||||
|
||||
// only create push token for real devices (pointless for emulators)
|
||||
if (Device.isDevice) {
|
||||
Notifications?.getExpoPushTokenAsync()
|
||||
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
|
||||
.catch((reason: any) => console.log("Failed to get token", reason));
|
||||
Notifications?.getExpoPushTokenAsync({
|
||||
projectId: "e79219d1-797f-4fbe-9fa1-cfd360690a68",
|
||||
})
|
||||
.then((token: ExpoPushToken) => {
|
||||
if (token) {
|
||||
console.log("Expo push token obtained:", token.data);
|
||||
setExpoPushToken(token);
|
||||
}
|
||||
})
|
||||
.catch((reason: any) => {
|
||||
console.error("Failed to get push token:", reason);
|
||||
writeErrorLog("Failed to get Expo push token", reason);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV) {
|
||||
@@ -310,9 +323,6 @@ function Layout() {
|
||||
responseListener.current =
|
||||
Notifications?.addNotificationResponseReceivedListener(
|
||||
(response: NotificationResponse) => {
|
||||
// redirect if internal notification
|
||||
redirect(response?.notification);
|
||||
|
||||
// Currently the notifications supported by the plugin will send data for deep links.
|
||||
const { title, data } = response.notification.request.content;
|
||||
writeInfoLog(`Notification ${title} opened`, data);
|
||||
@@ -355,120 +365,97 @@ function Layout() {
|
||||
responseListener.current?.remove();
|
||||
};
|
||||
}
|
||||
}, [user, api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (segments.includes("direct-player" as never)) {
|
||||
if (
|
||||
!settings.followDeviceOrientation &&
|
||||
settings.defaultVideoOrientation
|
||||
) {
|
||||
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.followDeviceOrientation === true) {
|
||||
ScreenOrientation.unlockAsync();
|
||||
} else {
|
||||
ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
settings.followDeviceOrientation,
|
||||
settings.defaultVideoOrientation,
|
||||
segments,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||
if (
|
||||
appState.current.match(/inactive|background/) &&
|
||||
nextAppState === "active"
|
||||
) {
|
||||
BackGroundDownloader.checkForExistingDownloads().catch(
|
||||
(error: unknown) => {
|
||||
writeErrorLog("Failed to resume background downloads", error);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
BackGroundDownloader.checkForExistingDownloads().catch((error: unknown) => {
|
||||
writeErrorLog("Failed to resume background downloads", error);
|
||||
});
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister: mmkvPersister,
|
||||
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
|
||||
dehydrateOptions: {
|
||||
shouldDehydrateQuery: (query) => {
|
||||
// Only persist successful queries
|
||||
return query.state.status === "success";
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<JellyfinProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/player'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='login'
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name='+not-found' />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
<ServerUrlProvider>
|
||||
<NetworkStatusProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<MusicPlayerProvider>
|
||||
<GlobalModalProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<IntroSheetProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/player'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/now-playing'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "modal",
|
||||
gestureEnabled: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='login'
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name='+not-found' />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
<GlobalModal />
|
||||
</ThemeProvider>
|
||||
</IntroSheetProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
</MusicPlayerProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</NetworkStatusProvider>
|
||||
</ServerUrlProvider>
|
||||
</JellyfinProvider>
|
||||
</QueryClientProvider>
|
||||
</PersistQueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
211
app/login.tsx
@@ -4,25 +4,30 @@ import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
SafeAreaView,
|
||||
Switch,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import type {
|
||||
AccountSecurityType,
|
||||
SavedServer,
|
||||
} from "@/utils/secureCredentials";
|
||||
|
||||
const CredentialsSchema = z.object({
|
||||
username: z.string().min(1, t("login.username_required")),
|
||||
@@ -32,8 +37,14 @@ const Login: React.FC = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const navigation = useNavigation();
|
||||
const params = useLocalSearchParams();
|
||||
const { setServer, login, removeServer, initiateQuickConnect } =
|
||||
useJellyfin();
|
||||
const {
|
||||
setServer,
|
||||
login,
|
||||
removeServer,
|
||||
initiateQuickConnect,
|
||||
loginWithSavedCredential,
|
||||
loginWithPassword,
|
||||
} = useJellyfin();
|
||||
|
||||
const {
|
||||
apiUrl: _apiUrl,
|
||||
@@ -43,16 +54,24 @@ const Login: React.FC = () => {
|
||||
|
||||
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
||||
const [serverURL, setServerURL] = useState<string>(_apiUrl || "");
|
||||
const [serverName, setServerName] = useState<string>("");
|
||||
const [credentials, setCredentials] = useState<{
|
||||
username: string;
|
||||
password: string;
|
||||
}>({
|
||||
username: _username,
|
||||
password: _password,
|
||||
username: _username || "",
|
||||
password: _password || "",
|
||||
});
|
||||
|
||||
// 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
|
||||
*/
|
||||
@@ -63,12 +82,13 @@ const Login: React.FC = () => {
|
||||
address: _apiUrl,
|
||||
});
|
||||
|
||||
// Wait for server setup and state updates to complete
|
||||
setTimeout(() => {
|
||||
if (_username && _password) {
|
||||
setCredentials({ username: _username, password: _password });
|
||||
login(_username, _password);
|
||||
}
|
||||
}, 300);
|
||||
}, 0);
|
||||
}
|
||||
})();
|
||||
}, [_apiUrl, _username, _password]);
|
||||
@@ -82,10 +102,10 @@ const Login: React.FC = () => {
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
}}
|
||||
className='flex flex-row items-center'
|
||||
className='flex flex-row items-center pr-2 pl-1'
|
||||
>
|
||||
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
||||
<Text className='ml-2 text-purple-600'>
|
||||
<Text className=' ml-1 text-purple-600'>
|
||||
{t("login.change_server")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -96,12 +116,34 @@ const Login: React.FC = () => {
|
||||
const handleLogin = async () => {
|
||||
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);
|
||||
try {
|
||||
const result = CredentialsSchema.safeParse(credentials);
|
||||
if (result.success) {
|
||||
await login(credentials.username, credentials.password);
|
||||
}
|
||||
await login(username, password, serverName, options);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
Alert.alert(t("login.connection_failed"), error.message);
|
||||
@@ -113,6 +155,44 @@ const Login: React.FC = () => {
|
||||
}
|
||||
} finally {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -262,24 +342,39 @@ const Login: React.FC = () => {
|
||||
<Input
|
||||
placeholder={t("login.username_placeholder")}
|
||||
onChangeText={(text: string) =>
|
||||
setCredentials({ ...credentials, username: text })
|
||||
setCredentials((prev) => ({ ...prev, username: text }))
|
||||
}
|
||||
onEndEditing={(e) => {
|
||||
const newValue = e.nativeEvent.text;
|
||||
if (newValue && newValue !== credentials.username) {
|
||||
setCredentials((prev) => ({ ...prev, username: newValue }));
|
||||
}
|
||||
}}
|
||||
value={credentials.username}
|
||||
keyboardType='default'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='oneTimeCode'
|
||||
autoCorrect={false}
|
||||
textContentType='username'
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
extraClassName='mb-4'
|
||||
autoFocus={false}
|
||||
blurOnSubmit={true}
|
||||
/>
|
||||
|
||||
{/* Password */}
|
||||
<Input
|
||||
placeholder={t("login.password_placeholder")}
|
||||
onChangeText={(text: string) =>
|
||||
setCredentials({ ...credentials, password: text })
|
||||
setCredentials((prev) => ({ ...prev, password: text }))
|
||||
}
|
||||
onEndEditing={(e) => {
|
||||
const newValue = e.nativeEvent.text;
|
||||
if (newValue && newValue !== credentials.password) {
|
||||
setCredentials((prev) => ({ ...prev, password: newValue }));
|
||||
}
|
||||
}}
|
||||
value={credentials.password}
|
||||
secureTextEntry
|
||||
keyboardType='default'
|
||||
@@ -289,10 +384,17 @@ const Login: React.FC = () => {
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
extraClassName='mb-4'
|
||||
autoFocus={false}
|
||||
blurOnSubmit={true}
|
||||
/>
|
||||
|
||||
<View className='mt-4'>
|
||||
<Button onPress={handleLogin}>{t("login.login_button")}</Button>
|
||||
<Button
|
||||
onPress={handleLogin}
|
||||
disabled={!credentials.username.trim()}
|
||||
>
|
||||
{t("login.login_button")}
|
||||
</Button>
|
||||
</View>
|
||||
<View className='mt-3'>
|
||||
<Button
|
||||
@@ -334,6 +436,8 @@ const Login: React.FC = () => {
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
maxLength={500}
|
||||
autoFocus={false}
|
||||
blurOnSubmit={true}
|
||||
/>
|
||||
|
||||
{/* Full-width primary button */}
|
||||
@@ -357,9 +461,12 @@ const Login: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
<PreviousServersList
|
||||
onServerSelect={async (s: any) => {
|
||||
onServerSelect={async (s) => {
|
||||
await handleConnect(s.address);
|
||||
}}
|
||||
onQuickLogin={handleQuickLoginWithSavedCredential}
|
||||
onPasswordLogin={handlePasswordLogin}
|
||||
onAddAccount={handleAddAccount}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -371,11 +478,12 @@ const Login: React.FC = () => {
|
||||
// Mobile layout
|
||||
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{api?.basePath ? (
|
||||
<View className='flex flex-col h-full relative items-center justify-center'>
|
||||
<View className='px-4 -mt-20 w-full'>
|
||||
<View className='flex flex-col flex-1 justify-center'>
|
||||
<View className='px-4 w-full'>
|
||||
<View className='flex flex-col space-y-2'>
|
||||
<Text className='text-2xl font-bold -mb-2'>
|
||||
{serverName ? (
|
||||
@@ -391,15 +499,23 @@ const Login: React.FC = () => {
|
||||
<Input
|
||||
placeholder={t("login.username_placeholder")}
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, username: text })
|
||||
setCredentials((prev) => ({ ...prev, username: text }))
|
||||
}
|
||||
onEndEditing={(e) => {
|
||||
const newValue = e.nativeEvent.text;
|
||||
if (newValue && newValue !== credentials.username) {
|
||||
setCredentials((prev) => ({
|
||||
...prev,
|
||||
username: newValue,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
value={credentials.username}
|
||||
keyboardType='default'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
// Changed from username to oneTimeCode because it is a known issue in RN
|
||||
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
|
||||
textContentType='oneTimeCode'
|
||||
autoCorrect={false}
|
||||
textContentType='username'
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
/>
|
||||
@@ -407,8 +523,17 @@ const Login: React.FC = () => {
|
||||
<Input
|
||||
placeholder={t("login.password_placeholder")}
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, password: text })
|
||||
setCredentials((prev) => ({ ...prev, password: text }))
|
||||
}
|
||||
onEndEditing={(e) => {
|
||||
const newValue = e.nativeEvent.text;
|
||||
if (newValue && newValue !== credentials.password) {
|
||||
setCredentials((prev) => ({
|
||||
...prev,
|
||||
password: newValue,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
value={credentials.password}
|
||||
secureTextEntry
|
||||
keyboardType='default'
|
||||
@@ -418,10 +543,26 @@ const Login: React.FC = () => {
|
||||
clearButtonMode='while-editing'
|
||||
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'>
|
||||
<Button
|
||||
onPress={handleLogin}
|
||||
loading={loading}
|
||||
disabled={!credentials.username.trim()}
|
||||
className='flex-1 mr-2'
|
||||
>
|
||||
{t("login.login_button")}
|
||||
@@ -443,7 +584,7 @@ const Login: React.FC = () => {
|
||||
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
||||
</View>
|
||||
) : (
|
||||
<View className='flex flex-col h-full items-center justify-center w-full'>
|
||||
<View className='flex flex-col flex-1 items-center justify-center w-full'>
|
||||
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
||||
<Image
|
||||
style={{
|
||||
@@ -492,11 +633,25 @@ const Login: React.FC = () => {
|
||||
onServerSelect={async (s) => {
|
||||
await handleConnect(s.address);
|
||||
}}
|
||||
onQuickLogin={handleQuickLoginWithSavedCredential}
|
||||
onPasswordLogin={handlePasswordLogin}
|
||||
onAddAccount={handleAddAccount}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* Save Account Modal */}
|
||||
<SaveAccountModal
|
||||
visible={showSaveModal}
|
||||
onClose={() => {
|
||||
setShowSaveModal(false);
|
||||
setPendingLogin(null);
|
||||
}}
|
||||
onSave={handleSaveAccountConfirm}
|
||||
username={pendingLogin?.username || credentials.username}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
BIN
assets/Download_on_Github_.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
assets/Download_with_Obtainium.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
@@ -115,4 +115,4 @@
|
||||
<path id="path259-2-6-4-6-7-0-1-0-5-9-4-7-1-5-7-6-2" class="cls-11" d="M46.97,39.46c5.94,0,10.75,4.81,10.75,10.75s-4.81,10.75-10.75,10.75-10.75-4.81-10.75-10.75c0-1.1.16-2.16.47-3.17.84,1.87,2.72,3.17,4.9,3.17,2.97,0,5.37-2.41,5.37-5.37,0-2.18-1.3-4.06-3.17-4.9,1-.31,2.06-.47,3.17-.47h.01Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
|
||||
<g id="g10">
|
||||
<path id="path88" d="M3547.01,1831.49C3493.38,1822.66 3262.53,1779.28 2992.01,1820.24C2424.16,1906.21 2154.85,2275.8 1882,2420.24C1473.31,2636.6 1060.97,2644.95 832,2592.03L832,1445.92C832,1321.76 863.078,1198.06 925.307,1090.27C1057.09,862.011 1323.38,718.405 1586.6,736.145C1695.48,743.482 1801.3,777.735 1895.64,832.199L3357.51,1676.21C3424.47,1714.87 3482.92,1761.76 3532.01,1815.41L3547.01,1831.49Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(149,41,235);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(98,22,247);stop-opacity:1"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
|
||||
<g id="g10">
|
||||
<path id="path66" d="M3357.51,2903.64L1895.64,3747.65C1670.29,3877.76 1412.33,3877.76 1186.98,3747.65C961.629,3617.55 832.648,3394.14 832.648,3133.93L832.648,1445.92C832.648,1185.71 961.629,962.305 1186.98,832.199C1412.33,702.094 1670.29,702.094 1895.64,832.199L3357.51,1676.21C3582.86,1806.31 3711.84,2029.71 3711.84,2289.93C3711.84,2550.14 3582.86,2773.54 3357.51,2903.64ZM1721.48,3213.68L3098.31,2454.7C3163.9,2418.55 3193.45,2364.85 3193.45,2289.93C3193.45,2215 3163.93,2161.32 3098.31,2125.15L1721.48,1366.18C1655.87,1330.01 1596.09,1328.72 1531.21,1366.18C1466.34,1403.63 1436.08,1456.03 1436.08,1530.96L1436.08,3048.89C1436.08,3123.77 1466.35,3176.23 1531.21,3213.68C1596.08,3251.11 1655.89,3249.83 1721.48,3213.68" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(188,74,241);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(227,105,219);stop-opacity:1"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g>
|
||||
<g id="g10">
|
||||
<path id="path88" d="M0,319.909L0,234C17.667,234.844 138.649,236.708 195,190C220.441,168.912 271.21,169.515 294.001,178.788C332.576,194.487 378.643,259.549 360,270.644C353.455,277.797 345.662,284.049 336.734,289.204L141.818,401.738C129.24,409 115.13,413.567 100.613,414.546C65.517,416.911 30.012,397.763 12.441,367.329C4.144,352.957 0,336.464 0,319.909Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(225,102,222);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(204,88,233);stop-opacity:1"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
|
||||
<g id="g10">
|
||||
<path id="path28" d="M1427.29,1523.37C1427.29,1447.7 1457.85,1394.77 1523.38,1356.94C1588.91,1319.11 1649.28,1320.41 1715.55,1356.94L3106.14,2123.5C3172.42,2160.03 3202.24,2214.25 3202.24,2289.93C3202.24,2365.6 3172.39,2419.83 3106.14,2456.35L1715.55,3222.91C1649.31,3259.43 1588.89,3260.73 1523.38,3222.91C1457.87,3185.1 1427.29,3132.11 1427.29,3056.48L1427.29,1523.37" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.17673e-13,-1921.74,1921.74,1.17673e-13,2314.76,3250.79)"><stop offset="0" style="stop-color:rgb(93,17,250);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(143,40,236);stop-opacity:1"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
184
assets/images/icon-ios-liquid-glass.icon/icon.json
Normal file
@@ -0,0 +1,184 @@
|
||||
{
|
||||
"fill": {
|
||||
"solid": "display-p3:0.18039,0.18039,0.18039,1.00000"
|
||||
},
|
||||
"groups": [
|
||||
{
|
||||
"blur-material": 0.3,
|
||||
"layers": [
|
||||
{
|
||||
"fill-specializations": [
|
||||
{
|
||||
"value": "none"
|
||||
},
|
||||
{
|
||||
"appearance": "tinted",
|
||||
"value": {
|
||||
"automatic-gradient": "display-p3:0.76482,0.76482,0.76482,0.84903"
|
||||
}
|
||||
}
|
||||
],
|
||||
"glass": true,
|
||||
"hidden": false,
|
||||
"image-name": "streamyfin_logo_layer1.svg",
|
||||
"name": "streamyfin_logo_layer1"
|
||||
}
|
||||
],
|
||||
"opacity": 1,
|
||||
"position": {
|
||||
"scale": 1.7,
|
||||
"translation-in-points": [30, 0]
|
||||
},
|
||||
"shadow": {
|
||||
"kind": "none",
|
||||
"opacity": 1
|
||||
},
|
||||
"specular": true,
|
||||
"translucency": {
|
||||
"enabled": true,
|
||||
"value": 0.6
|
||||
}
|
||||
},
|
||||
{
|
||||
"blend-mode": "normal",
|
||||
"blur-material": 0.8,
|
||||
"hidden": false,
|
||||
"layers": [
|
||||
{
|
||||
"blend-mode": "normal",
|
||||
"fill-specializations": [
|
||||
{
|
||||
"value": "none"
|
||||
},
|
||||
{
|
||||
"appearance": "tinted",
|
||||
"value": {
|
||||
"automatic-gradient": "gray:0.75000,1.00000"
|
||||
}
|
||||
}
|
||||
],
|
||||
"hidden": false,
|
||||
"image-name": "streamyfin_logo_layer2.svg",
|
||||
"name": "streamyfin_logo_layer2",
|
||||
"opacity": 1,
|
||||
"position": {
|
||||
"scale": 1,
|
||||
"translation-in-points": [0, 0]
|
||||
}
|
||||
}
|
||||
],
|
||||
"lighting": "individual",
|
||||
"name": "Group",
|
||||
"opacity": 1,
|
||||
"position": {
|
||||
"scale": 1.7,
|
||||
"translation-in-points": [30, -0.01613253252572302]
|
||||
},
|
||||
"shadow": {
|
||||
"kind": "layer-color",
|
||||
"opacity": 0.35
|
||||
},
|
||||
"specular": true,
|
||||
"translucency-specializations": [
|
||||
{
|
||||
"value": {
|
||||
"enabled": true,
|
||||
"value": 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance": "tinted",
|
||||
"value": {
|
||||
"enabled": true,
|
||||
"value": 0.8
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"blend-mode": "normal",
|
||||
"blur-material": 0.5,
|
||||
"layers": [
|
||||
{
|
||||
"fill-specializations": [
|
||||
{
|
||||
"appearance": "tinted",
|
||||
"value": {
|
||||
"automatic-gradient": "gray:0.29000,1.00000"
|
||||
}
|
||||
}
|
||||
],
|
||||
"glass": true,
|
||||
"hidden": false,
|
||||
"image-name": "streamyfin_logo_layer3.svg",
|
||||
"name": "streamyfin_logo_layer3",
|
||||
"opacity": 0.9
|
||||
}
|
||||
],
|
||||
"name": "Group",
|
||||
"opacity": 0.8,
|
||||
"position": {
|
||||
"scale": 1.7,
|
||||
"translation-in-points": [30, 0]
|
||||
},
|
||||
"shadow": {
|
||||
"kind": "none",
|
||||
"opacity": 0.5
|
||||
},
|
||||
"specular": true,
|
||||
"translucency": {
|
||||
"enabled": true,
|
||||
"value": 0.7
|
||||
}
|
||||
},
|
||||
{
|
||||
"blur-material": 0.5,
|
||||
"hidden": false,
|
||||
"layers": [
|
||||
{
|
||||
"glass": true,
|
||||
"hidden-specializations": [
|
||||
{
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"appearance": "tinted",
|
||||
"value": true
|
||||
}
|
||||
],
|
||||
"image-name": "streamyfin_logo_layer4.svg",
|
||||
"name": "streamyfin_logo_layer4",
|
||||
"opacity-specializations": [
|
||||
{
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"appearance": "tinted",
|
||||
"value": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"lighting": "combined",
|
||||
"name": "Group",
|
||||
"opacity": 0.9,
|
||||
"position": {
|
||||
"scale": 1.7,
|
||||
"translation-in-points": [30, 0]
|
||||
},
|
||||
"shadow": {
|
||||
"kind": "neutral",
|
||||
"opacity": 0.5
|
||||
},
|
||||
"specular": false,
|
||||
"translucency": {
|
||||
"enabled": true,
|
||||
"value": 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms": {
|
||||
"circles": ["watchOS"],
|
||||
"squares": "shared"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MMKV } from "react-native-mmkv";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
declare module "react-native-mmkv" {
|
||||
interface MMKV {
|
||||
@@ -9,7 +9,7 @@ declare module "react-native-mmkv" {
|
||||
|
||||
// Add the augmentation methods directly to the MMKV prototype
|
||||
// This follows the recommended pattern while adding the helper methods your app uses
|
||||
MMKV.prototype.get = function <T>(key: string): T | undefined {
|
||||
(storage as any).get = function <T>(key: string): T | undefined {
|
||||
try {
|
||||
const serializedItem = this.getString(key);
|
||||
if (!serializedItem) return undefined;
|
||||
@@ -20,10 +20,10 @@ MMKV.prototype.get = function <T>(key: string): T | undefined {
|
||||
}
|
||||
};
|
||||
|
||||
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
||||
(storage as any).setAny = function (key: string, value: any | undefined): void {
|
||||
try {
|
||||
if (value === undefined) {
|
||||
this.delete(key);
|
||||
this.remove(key);
|
||||
} else {
|
||||
this.set(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ module.exports = (api) => {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
plugins: ["nativewind/babel", "react-native-reanimated/plugin"],
|
||||
plugins: ["nativewind/babel", "react-native-worklets/plugin"],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
|
||||
"files": {
|
||||
"includes": [
|
||||
"**/*",
|
||||
@@ -8,7 +8,8 @@
|
||||
"!android",
|
||||
"!Streamyfin.app",
|
||||
"!utils/jellyseerr",
|
||||
"!.expo"
|
||||
"!.expo",
|
||||
"!docs/jellyfin-openapi-stable.json"
|
||||
]
|
||||
},
|
||||
"linter": {
|
||||
|
||||