diff --git a/.claude/agents/tv-validator.md b/.claude/agents/tv-validator.md
new file mode 100644
index 000000000..a38dd751a
--- /dev/null
+++ b/.claude/agents/tv-validator.md
@@ -0,0 +1,103 @@
+---
+name: tv-validator
+description: Use this agent to review TV platform code for correct patterns and conventions. Use proactively after writing or modifying TV components. Validates focus handling, modal patterns, typography, list components, and other TV-specific requirements.
+tools: Read, Glob, Grep
+model: haiku
+color: blue
+---
+
+You are a TV platform code reviewer for Streamyfin, a React Native app with Apple TV and Android TV support. Review code for correct TV patterns and flag violations.
+
+## Critical Rules to Check
+
+### 1. No .tv.tsx File Suffix
+The `.tv.tsx` suffix does NOT work in this project. Metro bundler doesn't resolve it.
+
+**Violation**: Creating files like `MyComponent.tv.tsx` expecting auto-resolution
+**Correct**: Use `Platform.isTV` conditional rendering in the main file:
+```typescript
+if (Platform.isTV) {
+ return ;
+}
+return ;
+```
+
+### 2. No FlashList on TV
+FlashList has focus issues on TV. Use FlatList instead.
+
+**Violation**: `
+) : (
+
+)}
+```
+
+### 3. Modal Pattern
+Never use overlay/absolute-positioned modals on TV. They break back button handling.
+
+**Violation**: `position: "absolute"` or `Modal` component for TV overlays
+**Correct**: Use navigation-based pattern:
+- Create Jotai atom for state
+- Hook that sets atom and calls `router.push()`
+- Page in `app/(auth)/` that reads atom
+- `Stack.Screen` with `presentation: "transparentModal"`
+
+### 4. Typography
+All TV text must use `TVTypography` component.
+
+**Violation**: Raw `` in TV components
+**Correct**: `...`
+
+### 5. No Purple Accent Colors
+TV uses white for focus states, not purple.
+
+**Violation**: Purple/violet colors in TV focused states
+**Correct**: White (`#fff`, `white`) for focused states with `expo-blur` for backgrounds
+
+### 6. Focus Handling
+- Only ONE element should have `hasTVPreferredFocus={true}`
+- Focusable items need `disabled={isModalOpen}` when overlays are visible
+- Use `onFocus`/`onBlur` with scale animations
+- Add padding for scale animations (focus scale clips without it)
+
+### 7. List Configuration
+TV lists need:
+- `removeClippedSubviews={false}`
+- `overflow: "visible"` on containers
+- Sufficient padding for focus scale animations
+
+### 8. Horizontal Padding
+Use `TV_HORIZONTAL_PADDING` constant (60), not old `TV_SCALE_PADDING` (20).
+
+### 9. Focus Guide Navigation
+For non-adjacent sections, use `TVFocusGuideView` with `destinations` prop.
+Use `useState` for refs (not `useRef`) to trigger re-renders.
+
+## Review Process
+
+1. Read the file(s) to review
+2. Check each rule above
+3. Report violations with:
+ - Line number
+ - What's wrong
+ - How to fix it
+4. If no violations, confirm the code follows TV patterns
+
+## Output Format
+
+```
+## TV Validation Results
+
+### ✓ Passes
+- [List of rules that pass]
+
+### ✗ Violations
+- **[Rule Name]** (line X): [Description]
+ Fix: [How to correct it]
+
+### Recommendations
+- [Optional suggestions for improvement]
+```
diff --git a/.claude/commands/reflect.md b/.claude/commands/reflect.md
new file mode 100644
index 000000000..deedf8d4f
--- /dev/null
+++ b/.claude/commands/reflect.md
@@ -0,0 +1,70 @@
+---
+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 Learned Facts Index section in `CLAUDE.md` and scan existing files in `.claude/learned-facts/` to understand what's already recorded
+2. Review this conversation for learnings worth preserving
+3. For each new fact:
+ - Create a new file in `.claude/learned-facts/[kebab-case-name].md` using the template below
+ - Append a new entry to the appropriate category in the **Learned Facts Index** section of `CLAUDE.md`
+4. Skip facts that duplicate existing entries
+5. If a new category is needed, add it to the index in `CLAUDE.md`
+
+## Fact File Template
+
+Create each file at `.claude/learned-facts/[kebab-case-name].md`:
+
+```markdown
+# [Title]
+
+**Date**: YYYY-MM-DD
+**Category**: navigation | tv | native-modules | state-management | ui
+**Key files**: `relevant/paths.ts`
+
+## Detail
+
+[Full description of the fact, including context for why it matters]
+```
+
+## Index Entry Format
+
+Append to the appropriate category in the Learned Facts Index section of `CLAUDE.md`:
+
+```
+- `kebab-case-name` | Brief one-line summary of the fact
+```
+
+Categories: Navigation, UI/Headers, State/Data, Native Modules, TV Platform
+
+## Example
+
+File `.claude/learned-facts/state-management-pattern.md`:
+```markdown
+# State Management Pattern
+
+**Date**: 2025-01-09
+**Category**: state-management
+**Key files**: `utils/atoms/`
+
+## Detail
+
+Use Jotai atoms for global state, NOT React Context. Atoms are defined in `utils/atoms/`.
+```
+
+Index entry in `CLAUDE.md`:
+```
+State/Data:
+- `state-management-pattern` | Use Jotai atoms for global state, not React Context
+```
+
+After updating, summarize what facts you added (or note if nothing new was learned this session).
diff --git a/.claude/learned-facts.md b/.claude/learned-facts.md
new file mode 100644
index 000000000..ab5d6eecc
--- /dev/null
+++ b/.claude/learned-facts.md
@@ -0,0 +1,48 @@
+# Learned Facts (DEPRECATED)
+
+> **DEPRECATED**: This file has been replaced by individual fact files in `.claude/learned-facts/`.
+> The compressed index is now inline in `CLAUDE.md` under "Learned Facts Index".
+> New facts should be added as individual files using the `/reflect` command.
+> This file is kept for reference only and is no longer auto-imported.
+
+This file previously contained facts about the codebase learned from past sessions.
+
+## Facts
+
+
+
+- **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)_
+
+- **MPV tvOS player exit freeze**: On tvOS, `mpv_terminate_destroy` can deadlock if called while blocking the main thread (e.g., via `queue.sync`). The fix is to run `mpv_terminate_destroy` on `DispatchQueue.global()` asynchronously, allowing it to access main thread for AVFoundation/GPU cleanup. Send `quit` command and drain events first. Located in `modules/mpv-player/ios/MPVLayerRenderer.swift`. _(2026-01-22)_
+
+- **MPV avfoundation-composite-osd ordering**: On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support). _(2026-01-22)_
+
+- **Thread-safe state for stop flags**: When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. _(2026-01-22)_
+
+- **TV modals must use navigation pattern**: On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page. _(2026-01-24)_
+
+- **TV grid layout pattern**: For TV grids, use ScrollView with flexWrap instead of FlatList/FlashList with numColumns. FlatList's numColumns divides width evenly among columns which causes inconsistent item sizing. Use `flexDirection: "row"`, `flexWrap: "wrap"`, `justifyContent: "center"`, and `gap` for spacing. _(2026-01-25)_
+
+- **TV horizontal padding standard**: TV pages should use `TV_HORIZONTAL_PADDING = 60` to match other TV pages like Home, Search, etc. The old `TV_SCALE_PADDING = 20` was too small. _(2026-01-25)_
+
+- **Native SwiftUI view sizing**: When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing. _(2026-01-25)_
+
+- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_
+
+- **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_
\ No newline at end of file
diff --git a/.claude/learned-facts/header-button-locations.md b/.claude/learned-facts/header-button-locations.md
new file mode 100644
index 000000000..269b51f15
--- /dev/null
+++ b/.claude/learned-facts/header-button-locations.md
@@ -0,0 +1,9 @@
+# Header Button Locations
+
+**Date**: 2026-01-10
+**Category**: ui
+**Key files**: `app/(auth)/(tabs)/(home)/_layout.tsx`, `components/common/HeaderBackButton.tsx`, `components/Chromecast.tsx`, `components/RoundButton.tsx`, `components/home/Home.tsx`, `app/(auth)/(tabs)/(home)/downloads/index.tsx`
+
+## Detail
+
+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`.
diff --git a/.claude/learned-facts/intro-modal-trigger-location.md b/.claude/learned-facts/intro-modal-trigger-location.md
new file mode 100644
index 000000000..4409db06a
--- /dev/null
+++ b/.claude/learned-facts/intro-modal-trigger-location.md
@@ -0,0 +1,9 @@
+# Intro Modal Trigger Location
+
+**Date**: 2025-01-09
+**Category**: navigation
+**Key files**: `components/home/Home.tsx`, `app/(auth)/(tabs)/_layout.tsx`
+
+## Detail
+
+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.
diff --git a/.claude/learned-facts/introsheet-rendering-location.md b/.claude/learned-facts/introsheet-rendering-location.md
new file mode 100644
index 000000000..b9575cd72
--- /dev/null
+++ b/.claude/learned-facts/introsheet-rendering-location.md
@@ -0,0 +1,9 @@
+# IntroSheet Rendering Location
+
+**Date**: 2025-01-09
+**Category**: navigation
+**Key files**: `providers/IntroSheetProvider`, `components/IntroSheet`
+
+## Detail
+
+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.
diff --git a/.claude/learned-facts/macos-header-buttons-fix.md b/.claude/learned-facts/macos-header-buttons-fix.md
new file mode 100644
index 000000000..45d5f31a5
--- /dev/null
+++ b/.claude/learned-facts/macos-header-buttons-fix.md
@@ -0,0 +1,9 @@
+# macOS Header Buttons Fix
+
+**Date**: 2026-01-10
+**Category**: ui
+**Key files**: `components/common/HeaderBackButton.tsx`, `app/(auth)/(tabs)/(home)/_layout.tsx`
+
+## Detail
+
+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.
diff --git a/.claude/learned-facts/mark-as-played-flow.md b/.claude/learned-facts/mark-as-played-flow.md
new file mode 100644
index 000000000..48603cd0c
--- /dev/null
+++ b/.claude/learned-facts/mark-as-played-flow.md
@@ -0,0 +1,9 @@
+# Mark as Played Flow
+
+**Date**: 2026-01-10
+**Category**: state-management
+**Key files**: `components/PlayedStatus.tsx`, `hooks/useMarkAsPlayed.ts`
+
+## Detail
+
+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`.
diff --git a/.claude/learned-facts/mpv-avfoundation-composite-osd-ordering.md b/.claude/learned-facts/mpv-avfoundation-composite-osd-ordering.md
new file mode 100644
index 000000000..418f862ae
--- /dev/null
+++ b/.claude/learned-facts/mpv-avfoundation-composite-osd-ordering.md
@@ -0,0 +1,9 @@
+# MPV avfoundation-composite-osd Ordering
+
+**Date**: 2026-01-22
+**Category**: native-modules
+**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift`
+
+## Detail
+
+On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support).
diff --git a/.claude/learned-facts/mpv-tvos-player-exit-freeze.md b/.claude/learned-facts/mpv-tvos-player-exit-freeze.md
new file mode 100644
index 000000000..7dfb20178
--- /dev/null
+++ b/.claude/learned-facts/mpv-tvos-player-exit-freeze.md
@@ -0,0 +1,9 @@
+# MPV tvOS Player Exit Freeze
+
+**Date**: 2026-01-22
+**Category**: native-modules
+**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift`
+
+## Detail
+
+On tvOS, `mpv_terminate_destroy` can deadlock if called while blocking the main thread (e.g., via `queue.sync`). The fix is to run `mpv_terminate_destroy` on `DispatchQueue.global()` asynchronously, allowing it to access main thread for AVFoundation/GPU cleanup. Send `quit` command and drain events first.
diff --git a/.claude/learned-facts/native-bottom-tabs-userouter-conflict.md b/.claude/learned-facts/native-bottom-tabs-userouter-conflict.md
new file mode 100644
index 000000000..eda49ef05
--- /dev/null
+++ b/.claude/learned-facts/native-bottom-tabs-userouter-conflict.md
@@ -0,0 +1,9 @@
+# Native Bottom Tabs + useRouter Conflict
+
+**Date**: 2025-01-09
+**Category**: navigation
+**Key files**: `providers/`, `app/_layout.tsx`
+
+## Detail
+
+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.
diff --git a/.claude/learned-facts/native-swiftui-view-sizing.md b/.claude/learned-facts/native-swiftui-view-sizing.md
new file mode 100644
index 000000000..f36a18374
--- /dev/null
+++ b/.claude/learned-facts/native-swiftui-view-sizing.md
@@ -0,0 +1,9 @@
+# Native SwiftUI View Sizing
+
+**Date**: 2026-01-25
+**Category**: native-modules
+**Key files**: `modules/`
+
+## Detail
+
+When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing.
diff --git a/.claude/learned-facts/platform-specific-file-suffix-does-not-work.md b/.claude/learned-facts/platform-specific-file-suffix-does-not-work.md
new file mode 100644
index 000000000..d52dca9b9
--- /dev/null
+++ b/.claude/learned-facts/platform-specific-file-suffix-does-not-work.md
@@ -0,0 +1,9 @@
+# Platform-Specific File Suffix (.tv.tsx) Does NOT Work
+
+**Date**: 2026-01-26
+**Category**: tv
+**Key files**: `app/`, `components/`
+
+## Detail
+
+The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render.
diff --git a/.claude/learned-facts/stack-screen-header-configuration.md b/.claude/learned-facts/stack-screen-header-configuration.md
new file mode 100644
index 000000000..24ca01fcd
--- /dev/null
+++ b/.claude/learned-facts/stack-screen-header-configuration.md
@@ -0,0 +1,9 @@
+# Stack Screen Header Configuration
+
+**Date**: 2026-01-10
+**Category**: ui
+**Key files**: `app/(auth)/(tabs)/(home)/_layout.tsx`
+
+## Detail
+
+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.
diff --git a/.claude/learned-facts/streamystats-components-location.md b/.claude/learned-facts/streamystats-components-location.md
new file mode 100644
index 000000000..41652a528
--- /dev/null
+++ b/.claude/learned-facts/streamystats-components-location.md
@@ -0,0 +1,9 @@
+# Streamystats Components Location
+
+**Date**: 2026-01-25
+**Category**: tv
+**Key files**: `components/home/StreamystatsRecommendations.tv.tsx`, `components/home/StreamystatsPromotedWatchlists.tv.tsx`, `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`
+
+## Detail
+
+Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`.
diff --git a/.claude/learned-facts/tab-folder-naming.md b/.claude/learned-facts/tab-folder-naming.md
new file mode 100644
index 000000000..7663a6099
--- /dev/null
+++ b/.claude/learned-facts/tab-folder-naming.md
@@ -0,0 +1,9 @@
+# Tab Folder Naming
+
+**Date**: 2025-01-09
+**Category**: navigation
+**Key files**: `app/(auth)/(tabs)/`
+
+## Detail
+
+The tab folders use underscore prefix naming like `(_home)` instead of just `(home)` based on the project's file structure conventions.
diff --git a/.claude/learned-facts/thread-safe-state-for-stop-flags.md b/.claude/learned-facts/thread-safe-state-for-stop-flags.md
new file mode 100644
index 000000000..eaa0d84d0
--- /dev/null
+++ b/.claude/learned-facts/thread-safe-state-for-stop-flags.md
@@ -0,0 +1,9 @@
+# Thread-Safe State for Stop Flags
+
+**Date**: 2026-01-22
+**Category**: native-modules
+**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift`
+
+## Detail
+
+When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time.
diff --git a/.claude/learned-facts/tv-grid-layout-pattern.md b/.claude/learned-facts/tv-grid-layout-pattern.md
new file mode 100644
index 000000000..6f9b234a2
--- /dev/null
+++ b/.claude/learned-facts/tv-grid-layout-pattern.md
@@ -0,0 +1,9 @@
+# TV Grid Layout Pattern
+
+**Date**: 2026-01-25
+**Category**: tv
+**Key files**: `components/tv/`
+
+## Detail
+
+For TV grids, use ScrollView with flexWrap instead of FlatList/FlashList with numColumns. FlatList's numColumns divides width evenly among columns which causes inconsistent item sizing. Use `flexDirection: "row"`, `flexWrap: "wrap"`, `justifyContent: "center"`, and `gap` for spacing.
diff --git a/.claude/learned-facts/tv-horizontal-padding-standard.md b/.claude/learned-facts/tv-horizontal-padding-standard.md
new file mode 100644
index 000000000..e9ddc0c88
--- /dev/null
+++ b/.claude/learned-facts/tv-horizontal-padding-standard.md
@@ -0,0 +1,9 @@
+# TV Horizontal Padding Standard
+
+**Date**: 2026-01-25
+**Category**: tv
+**Key files**: `components/tv/`, `app/(auth)/(tabs)/`
+
+## Detail
+
+TV pages should use `TV_HORIZONTAL_PADDING = 60` to match other TV pages like Home, Search, etc. The old `TV_SCALE_PADDING = 20` was too small.
diff --git a/.claude/learned-facts/tv-modals-must-use-navigation-pattern.md b/.claude/learned-facts/tv-modals-must-use-navigation-pattern.md
new file mode 100644
index 000000000..c6c837d5a
--- /dev/null
+++ b/.claude/learned-facts/tv-modals-must-use-navigation-pattern.md
@@ -0,0 +1,9 @@
+# TV Modals Must Use Navigation Pattern
+
+**Date**: 2026-01-24
+**Category**: tv
+**Key files**: `hooks/useTVOptionModal.ts`, `app/(auth)/tv-option-modal.tsx`
+
+## Detail
+
+On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page.
diff --git a/.claude/learned-facts/use-network-aware-query-client-limitations.md b/.claude/learned-facts/use-network-aware-query-client-limitations.md
new file mode 100644
index 000000000..36e8f2d82
--- /dev/null
+++ b/.claude/learned-facts/use-network-aware-query-client-limitations.md
@@ -0,0 +1,9 @@
+# useNetworkAwareQueryClient Limitations
+
+**Date**: 2026-01-10
+**Category**: state-management
+**Key files**: `hooks/useNetworkAwareQueryClient.ts`
+
+## Detail
+
+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`.
diff --git a/.eas/build/android-production-apk.yml b/.eas/build/android-production-apk.yml
new file mode 100644
index 000000000..757192bb6
--- /dev/null
+++ b/.eas/build/android-production-apk.yml
@@ -0,0 +1,25 @@
+# Custom EAS Build config for Android phone APK (downloadable artifact).
+# Same bun-forcing flow as android-production.yml, but builds an APK
+# (assembleRelease) instead of an AAB — for sideloading / GitHub artifact.
+# Referenced from eas.json: build.production-apk.android.config
+build:
+ name: Android phone APK (bun)
+ steps:
+ - eas/checkout
+
+ - run:
+ name: Install dependencies (bun, frozen)
+ command: bun install --frozen-lockfile
+
+ - run:
+ name: Prebuild (Android, bun)
+ command: bunx expo prebuild --platform android --no-install
+
+ - eas/configure_android_version
+ - eas/inject_android_credentials
+
+ - eas/run_gradle:
+ inputs:
+ command: :app:assembleRelease
+
+ - eas/find_and_upload_build_artifacts
diff --git a/.eas/build/android-production-tv.yml b/.eas/build/android-production-tv.yml
new file mode 100644
index 000000000..a3fc9effe
--- /dev/null
+++ b/.eas/build/android-production-tv.yml
@@ -0,0 +1,27 @@
+# Custom EAS Build config for Android TV APK (downloadable artifact).
+# Same bun-forcing flow, with EXPO_TV=1 (set via the profile env in
+# eas.json) so prebuild generates the TV variant. Builds an APK for
+# sideloading onto Android TV devices.
+# Referenced from eas.json: build.production-apk-tv.android.config
+build:
+ name: Android TV APK (bun)
+ steps:
+ - eas/checkout
+
+ - run:
+ name: Install dependencies (bun, frozen)
+ command: bun install --frozen-lockfile
+
+ # EXPO_TV=1 comes from the profile env, so prebuild targets Android TV.
+ - run:
+ name: Prebuild (Android TV, bun)
+ command: bunx expo prebuild --platform android --no-install
+
+ - eas/configure_android_version
+ - eas/inject_android_credentials
+
+ - eas/run_gradle:
+ inputs:
+ command: :app:assembleRelease
+
+ - eas/find_and_upload_build_artifacts
diff --git a/.eas/build/android-production.yml b/.eas/build/android-production.yml
new file mode 100644
index 000000000..651ff2b66
--- /dev/null
+++ b/.eas/build/android-production.yml
@@ -0,0 +1,38 @@
+# Custom EAS Build config for Android (production AAB).
+#
+# Why this exists: EAS's managed build can't detect Bun's text lockfile
+# (bun.lock) and falls back to yarn, which breaks our install. The managed
+# steps `eas/install_node_modules` and `eas/prebuild` both use "the package
+# manager detected based on your project", so we replace them with explicit
+# `bun` commands. Everything else uses EAS's built-in functions so we still
+# get remote versioning, credentials, and artifact upload.
+#
+# Referenced from eas.json: build.production.android.config = android-production.yml
+build:
+ name: Android production (bun)
+ steps:
+ - eas/checkout
+
+ - run:
+ name: Install dependencies (bun, frozen)
+ command: bun install --frozen-lockfile
+
+ # android/ is gitignored, so generate native code fresh. --no-install
+ # because deps are already installed above; bunx keeps it on bun.
+ - run:
+ name: Prebuild (Android, bun)
+ command: bunx expo prebuild --platform android --no-install
+
+ # Applies the EAS-resolved remote versionCode/versionName (autoIncrement
+ # in eas.json) into the freshly prebuilt android/ project.
+ - eas/configure_android_version
+
+ # Injects the remote Android keystore / signing config.
+ - eas/inject_android_credentials
+
+ # Build the Play Store app bundle (.aab).
+ - eas/run_gradle:
+ inputs:
+ command: :app:bundleRelease
+
+ - eas/find_and_upload_build_artifacts
diff --git a/.eas/build/ios-production.yml b/.eas/build/ios-production.yml
new file mode 100644
index 000000000..f9e228f72
--- /dev/null
+++ b/.eas/build/ios-production.yml
@@ -0,0 +1,44 @@
+# Custom EAS Build config for iOS + tvOS (App Store), forcing bun.
+#
+# Shared by both the iPhone profile (production) and the tvOS profile
+# (production_tv). The profile decides the rest:
+# - production_tv sets EXPO_TV=1 (env) so prebuild targets tvOS, and
+# credentialsSource: local (EAS can't manage tvOS creds remotely).
+# - production uses remote-managed iOS credentials.
+#
+# Like the Android configs, this replaces eas/install_node_modules and
+# eas/prebuild (both auto-detect the wrong package manager) with explicit
+# bun commands, and keeps EAS built-ins for credentials/version/fastlane.
+build:
+ name: iOS/tvOS App Store (bun)
+ steps:
+ - eas/checkout
+
+ - run:
+ name: Install dependencies (bun, frozen)
+ command: bun install --frozen-lockfile
+
+ - eas/resolve_apple_team_id_from_credentials:
+ id: resolve_team
+
+ # android/ + ios/ are gitignored, so generate native code fresh.
+ # EXPO_TV (from the profile env) selects iPhone vs tvOS. --no-install
+ # skips JS + pod install; we install pods explicitly below with bun deps.
+ - run:
+ name: Prebuild (iOS/tvOS, bun)
+ command: bunx expo prebuild --platform ios --no-install
+
+ - run:
+ name: Install CocoaPods
+ working_directory: ./ios
+ command: pod install
+
+ - eas/configure_ios_credentials
+ - eas/configure_ios_version
+
+ - eas/generate_gymfile_from_template:
+ inputs:
+ credentials: ${ eas.job.secrets.buildCredentials }
+
+ - eas/run_fastlane
+ - eas/find_and_upload_build_artifacts
diff --git a/.gitattributes b/.gitattributes
index 56dea9663..4d651aeb4 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1,28 @@
-.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text
+# Normalise line endings to LF for everyone. Files are stored as LF in git and
+# checked out as LF on every OS, so Windows clones stop producing CRLF churn
+# (no more "LF will be replaced by CRLF" warnings) regardless of core.autocrlf.
+* text=auto eol=lf
+
+# Windows-only scripts must stay CRLF
+*.bat text eol=crlf
+*.cmd text eol=crlf
+
+# Binary assets — never touched / never normalised
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+*.webp binary
+*.ico binary
+*.icns binary
+*.ttf binary
+*.otf binary
+*.woff binary
+*.woff2 binary
+*.mp3 binary
+*.mp4 binary
+*.mov binary
+*.pdf binary
+*.keystore binary
+*.jks binary
+*.p12 binary
diff --git a/.github/ISSUE_TEMPLATE/issue_report.yml b/.github/ISSUE_TEMPLATE/issue_report.yml
index 29904bd3c..365afca23 100644
--- a/.github/ISSUE_TEMPLATE/issue_report.yml
+++ b/.github/ISSUE_TEMPLATE/issue_report.yml
@@ -1,5 +1,5 @@
name: "🐛 Bug Report"
-description: Create a report to help us improve
+description: Create a report to help Streamyfin improve
title: "[Bug]: "
labels:
- "🐛 bug"
@@ -36,7 +36,7 @@ body:
attributes:
label: What happened?
description: A clear and concise description of what the bug is.
- placeholder: Describe what happened in detail.
+ placeholder: Describe what happened in detail, the more precise the better.
validations:
required: true
@@ -67,7 +67,7 @@ body:
attributes:
label: Which device and operating system are you using?
description: Please provide your device model and OS version
- placeholder: e.g. iPhone 15 Pro, iOS 18.1.1 or Samsung Galaxy S24, Android 14
+ placeholder: e.g. iPhone 17 Pro / iOS 26.5.1, Samsung Galaxy S25 / Android 16, Apple TV / tvOS 26.5
validations:
required: true
@@ -75,16 +75,14 @@ body:
id: version
attributes:
label: Streamyfin Version
- description: What version of Streamyfin are you running?
+ description: What version of Streamyfin are you running? On a TestFlight or development build, choose "TestFlight/Development build" and include the exact version string shown in the app's Settings.
options:
+ - 0.54.1
+ - 0.51.0
+ - 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
+ - Older
- TestFlight/Development build
validations:
required: true
@@ -95,9 +93,9 @@ body:
label: Jellyfin Server Information
description: Please provide details about your Jellyfin server
placeholder: |
- - Jellyfin Server Version: e.g. 10.10.7
- - Server OS: e.g. Ubuntu 22.04, Windows 11, Docker
- - Connection: e.g. Local network, Remote via domain, VPN
+ - Jellyfin Server Version: e.g. 10.11.10
+ - Server OS: e.g. Ubuntu 26.04, Windows 11, Docker, Proxmox
+ - Connection: e.g. Local network, remote via domain, VPN
- type: textarea
id: screenshots
@@ -109,11 +107,11 @@ body:
id: logs
attributes:
label: Relevant logs (if available)
- description: If you have access to app logs or crash reports, please include them here. **Remember to remove any personal information like server URLs or usernames.**
+ description: If you have access to app logs or crash reports, please include them here. **Remember to remove any personal information like server URL, API keys or usernames.**
render: shell
- type: textarea
id: additional-info
attributes:
label: Additional information
- description: Any additional context that might help us understand and reproduce the issue.
\ No newline at end of file
+ description: Any additional context that might help us understand and reproduce the issue.
diff --git a/.github/actions/refresh-pr-comment/action.yml b/.github/actions/refresh-pr-comment/action.yml
new file mode 100644
index 000000000..3149ea420
--- /dev/null
+++ b/.github/actions/refresh-pr-comment/action.yml
@@ -0,0 +1,21 @@
+name: Refresh PR build comment
+description: >-
+ Nudge artifact-comment.yml (via workflow_dispatch) so the PR build-status
+ comment reflects live per-platform progress as each build job finishes.
+
+runs:
+ using: composite
+ steps:
+ # workflow_dispatch fires even when triggered by the GITHUB_TOKEN, and
+ # artifact-comment's concurrency group collapses simultaneous nudges, so
+ # this can't spam the comment. Skipped on forks (their read-only token
+ # cannot dispatch). github.token is used because composite actions cannot
+ # read the secrets context.
+ - if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
+ continue-on-error: true
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ HEAD_REF: ${{ github.head_ref }}
+ REPO: ${{ github.repository }}
+ run: gh workflow run artifact-comment.yml --ref "$HEAD_REF" -R "$REPO"
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 2111dd2e6..d3b750c28 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
## Coding Standards
-- Use TypeScript for ALL files (no .js files)
+- Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
- Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks
- Use Jotai atoms for global state management
diff --git a/.github/crowdin.yml b/.github/crowdin.yml
deleted file mode 100644
index 4acc32089..000000000
--- a/.github/crowdin.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-"project_id_env": "CROWDIN_PROJECT_ID"
-"api_token_env": "CROWDIN_PERSONAL_TOKEN"
-"base_path": "."
-
-"preserve_hierarchy": true
-
-"files": [
- {
- "source": "translations/en.json",
- "translation": "translations/%two_letters_code%.json"
- }
-]
\ No newline at end of file
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index e76ebb70d..95978bab3 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,91 +1,54 @@
-
# 📦 Pull Request
-## 🔖 Summary
+
+
+
+## 📝 Description
## 🏷️ Ticket / Issue
-## 🛠️ What’s Changed
-
-
-- Type: feat | fix | docs | style | refactor | perf | test | chore | build | ci | revert
-- Scope (optional): e.g., auth, billing, mobile
-- Short summary: what changed and why (1–2 lines)
--->
-
-## 📋 Details
-
-
-### ⚠️ Breaking Changes
-
-
-### 🔐 Security & Privacy Impact
-
-
-### ⚡ Performance Impact
-
-
### 🖼️ Screenshots / GIFs (if UI)
-
+
## ✅ Checklist
- [ ] I’ve read the [contribution guidelines](CONTRIBUTING.md)
-- [ ] Code follows project style and passes lint/format (`npm|pnpm|yarn|bun` scripts)
-- [ ] Type checks pass (tsc/biome/etc.)
-- [ ] Docs updated (README/ADR/usage/API)
-- [ ] No secrets/credentials included; env vars documented
-- [ ] Release notes/CHANGELOG entry added (if applicable)
-- [ ] Verified locally that changes behave as expected
+- [ ] Verified that changes behave as expected for all platforms
+- [ ] Code passes lint/formatting and type checks (`tsc`/`biome`)
+- [ ] No secrets, hardcoded credentials, or private config files are included
+- [ ] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
## 🔍 Testing Instructions
-## ⚙️ Deployment Notes
-
-
-## 📝 Additional Notes
-
\ No newline at end of file
diff --git a/.github/renovate.json b/.github/renovate.json
index 364842311..21c5b931b 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -25,22 +25,69 @@
"osvVulnerabilityAlerts": true,
"configMigration": true,
"separateMinorPatch": true,
- "lockFileMaintenance": {
- "vulnerabilityAlerts": {
- "enabled": true,
- "addLabels": ["security", "vulnerability"],
- "assigneesFromCodeOwners": true,
- "commitMessageSuffix": " [SECURITY]"
+ "customManagers": [
+ {
+ "customType": "regex",
+ "managerFilePatterns": ["/\\.ya?ml$/"],
+ "matchStrings": [
+ "# renovate: datasource=(?\\S+) depName=(?\\S+)(?: versioning=(?\\S+))?\\s+[A-Za-z0-9._-]+:\\s*[\"']?(?[^\"'\\s]+)"
+ ],
+ "versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}loose{{/if}}"
},
- "packageRules": [
- {
- "description": "Group minor and patch GitHub Action updates into a single PR",
- "matchManagers": ["github-actions"],
- "groupName": "CI dependencies",
- "groupSlug": "ci-deps",
- "matchUpdateTypes": ["minor", "patch", "digest", "pin"],
- "automerge": true
- }
- ]
- }
+ {
+ "customType": "regex",
+ "description": "Track the Bun version pinned in eas.json build profiles (strict JSON can't hold inline annotations)",
+ "managerFilePatterns": ["/(^|/)eas\\.json$/"],
+ "matchStrings": ["\"bun\"\\s*:\\s*\"(?[^\"]+)\""],
+ "datasourceTemplate": "npm",
+ "depNameTemplate": "bun"
+ }
+ ],
+ "customDatasources": {
+ "xcode": {
+ "defaultRegistryUrlTemplate": "https://xcodereleases.com/data.json",
+ "format": "json",
+ "transformTemplates": [
+ "{ \"releases\": [$[version.release.release=true].{\"version\": version.number}] }"
+ ]
+ }
+ },
+ "vulnerabilityAlerts": {
+ "enabled": true,
+ "addLabels": ["security", "vulnerability"],
+ "assigneesFromCodeOwners": true,
+ "commitMessageSuffix": " [SECURITY]"
+ },
+ "packageRules": [
+ {
+ "description": "Expo SDK coherence: expo, react, react-native and Expo-managed modules are pinned by the Expo SDK and must move together (via `expo install --fix`), so do not raise individual update PRs — group them and require manual approval from the Dependency Dashboard",
+ "matchPackageNames": [
+ "expo",
+ "react",
+ "react-dom",
+ "react-native",
+ "react-native-web",
+ "expo-*",
+ "@expo/*"
+ ],
+ "groupName": "Expo SDK",
+ "dependencyDashboardApproval": true
+ },
+ {
+ "description": "Group minor and patch GitHub Action updates into a single PR",
+ "matchManagers": ["github-actions"],
+ "groupName": "CI dependencies",
+ "groupSlug": "ci-deps",
+ "matchUpdateTypes": ["minor", "patch", "digest", "pin"],
+ "automerge": true
+ },
+ {
+ "description": "androidx and other Google-hosted Maven packages resolve from Google's Maven repository (not Maven Central)",
+ "matchDatasources": ["maven"],
+ "registryUrls": [
+ "https://dl.google.com/dl/android/maven2/",
+ "https://repo.maven.apache.org/maven2/"
+ ]
+ }
+ ]
}
diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml
index 8528a95bd..80c7119e1 100644
--- a/.github/workflows/artifact-comment.yml
+++ b/.github/workflows/artifact-comment.yml
@@ -18,7 +18,7 @@ jobs:
comment-artifacts:
if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request')
name: 📦 Post Build Artifacts
- runs-on: ubuntu-latest
+ runs-on: ubuntu-26.04
permissions:
contents: read
pull-requests: write
@@ -26,7 +26,7 @@ jobs:
steps:
- name: 🔍 Get PR and Artifacts
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
// Check if we're running from a fork (more precise detection)
@@ -144,7 +144,7 @@ jobs:
)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
- console.log(`Found ${buildRuns.length} non-cancelled build workflow runs for this commit`);
+ console.log(`Found ${buildRuns.length} build workflow runs for this commit`);
// Log current status of each build for debugging
buildRuns.forEach(run => {
@@ -184,6 +184,31 @@ jobs:
const latestAndroidRun = findBestRun('Android APK Build');
const latestIOSRun = findBestRun('iOS IPA Build');
+ // Map our build targets to their job display names. Exact name is
+ // tried first so a signed target never collides with its
+ // "(Unsigned)" sibling (whose name contains the signed name).
+ const jobMappings = {
+ 'Android Phone': ['🤖 Build Android APK (Phone)'],
+ 'Android TV': ['🤖 Build Android APK (TV)'],
+ 'iOS': ['🍎 Build iOS IPA (Phone)'],
+ 'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)'],
+ 'tvOS': ['🍎 Build tvOS IPA'],
+ 'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)']
+ };
+
+ // Prefer an exact name match over a substring match so
+ // '...(Phone)' doesn't swallow '...(Phone - Unsigned)'.
+ const findJobForTarget = (jobs, jobNames) =>
+ jobs.find(j => jobNames.some(name => j.name === name)) ||
+ jobs.find(j => jobNames.some(name => j.name.includes(name)));
+
+ // Format a millisecond duration as "Xm Ys".
+ const fmtDuration = (ms) => {
+ const min = Math.floor(ms / 60000);
+ const sec = Math.floor((ms % 60000) / 1000);
+ return `${min}m ${sec}s`;
+ };
+
// For the consolidated workflow, get individual job statuses
if (latestAppsRun) {
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
@@ -216,19 +241,10 @@ jobs:
return; // Exit early
}
- // Map job names to our build targets
- const jobMappings = {
- 'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
- 'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
- 'iOS Phone': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone']
- };
-
// Create individual status for each job
for (const [platform, jobNames] of Object.entries(jobMappings)) {
- const job = jobs.jobs.find(j =>
- jobNames.some(name => j.name.includes(name) || j.name === name)
- );
-
+ const job = findJobForTarget(jobs.jobs, jobNames);
+
if (job) {
buildStatuses[platform] = {
name: job.name,
@@ -236,7 +252,9 @@ jobs:
conclusion: job.conclusion,
url: job.html_url,
runId: latestAppsRun.id,
- created_at: job.started_at || latestAppsRun.created_at
+ created_at: job.started_at || latestAppsRun.created_at,
+ started_at: job.started_at,
+ completed_at: job.completed_at
};
console.log(`Mapped ${platform} to job: ${job.name} (${job.status}/${job.conclusion || 'none'})`);
} else {
@@ -247,22 +265,30 @@ jobs:
conclusion: latestAppsRun.conclusion,
url: latestAppsRun.html_url,
runId: latestAppsRun.id,
- created_at: latestAppsRun.created_at
+ created_at: latestAppsRun.created_at,
+ started_at: latestAppsRun.run_started_at,
+ completed_at: latestAppsRun.updated_at
};
}
}
} catch (error) {
console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message);
- // Fallback to workflow-level status
- buildStatuses['Android Phone'] = buildStatuses['Android TV'] = buildStatuses['iOS Phone'] = {
+ // Fallback to workflow-level status for every build target.
+ // Keys must match jobMappings / buildTargets statusKey values.
+ const fallbackStatus = {
name: latestAppsRun.name,
status: latestAppsRun.status,
conclusion: latestAppsRun.conclusion,
url: latestAppsRun.html_url,
runId: latestAppsRun.id,
- created_at: latestAppsRun.created_at
+ created_at: latestAppsRun.created_at,
+ started_at: latestAppsRun.run_started_at,
+ completed_at: latestAppsRun.updated_at
};
+ for (const platform of Object.keys(jobMappings)) {
+ buildStatuses[platform] = fallbackStatus;
+ }
}
// Collect artifacts if any job has completed successfully
@@ -344,6 +370,43 @@ jobs:
console.log(`- Artifact: ${artifact.name} (from run ${artifact.workflow_run.id})`);
});
+ // Pull per-job durations from the latest successful develop build so
+ // in-progress / queued targets can show a realistic ETA instead of
+ // an open-ended spinner. Best-effort: any failure just drops the ETA.
+ let referenceDurations = {};
+ try {
+ const { data: devRuns } = await github.rest.actions.listWorkflowRuns({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ workflow_id: 'build-apps.yml',
+ branch: 'develop',
+ status: 'success',
+ per_page: 1
+ });
+
+ if (devRuns.workflow_runs.length > 0) {
+ const refRun = devRuns.workflow_runs[0];
+ const { data: refJobs } = await github.rest.actions.listJobsForWorkflowRun({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ run_id: refRun.id
+ });
+
+ for (const [platform, jobNames] of Object.entries(jobMappings)) {
+ const job = findJobForTarget(refJobs.jobs, jobNames);
+ if (job && job.conclusion === 'success' && job.started_at && job.completed_at) {
+ referenceDurations[platform] = new Date(job.completed_at) - new Date(job.started_at);
+ }
+ }
+ console.log(`Reference durations from develop run ${refRun.id}:`,
+ Object.fromEntries(Object.entries(referenceDurations).map(([k, v]) => [k, fmtDuration(v)])));
+ } else {
+ console.log('No successful develop build found for ETA reference');
+ }
+ } catch (error) {
+ console.log('Failed to fetch develop reference durations:', error.message);
+ }
+
// Build comment body with progressive status for individual builds
let commentBody = `## 🔧 Build Status for PR #${pr.number}\n\n`;
commentBody += `🔗 **Commit**: [\`${targetCommitSha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${targetCommitSha})\n\n`; // Progressive build status and downloads table
@@ -353,10 +416,12 @@ jobs:
// Process each expected build target individually
const buildTargets = [
- { name: 'Android Phone', platform: '🤖', device: '📱', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
- { name: 'Android TV', platform: '🤖', device: '📺', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
- { name: 'iOS Phone', platform: '🍎', device: '📱', statusKey: 'iOS Phone', artifactPattern: /ios.*phone/i },
- { name: 'iOS TV', platform: '🍎', device: '📺', statusKey: 'iOS TV', artifactPattern: /ios.*tv/i }
+ { name: 'Android Phone', platform: '🤖', device: '📱 Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
+ { name: 'Android TV', platform: '🤖', device: '📺 TV', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
+ { name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /^(?!.*unsigned).*ios.*phone.*ipa/i },
+ { name: 'iOS Unsigned', platform: '🍎', device: '📱 Phone Unsigned', statusKey: 'iOS Unsigned', artifactPattern: /ios.*phone.*unsigned/i },
+ { name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /^(?!.*unsigned).*ios.*tv.*ipa/i },
+ { name: 'tvOS Unsigned', platform: '🍎', device: '📺 TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i }
];
for (const target of buildTargets) {
@@ -371,16 +436,30 @@ jobs:
let status = '⏳ Pending';
let downloadLink = '*Waiting for build...*';
- // Special case for iOS TV - show as disabled
- if (target.name === 'iOS TV') {
+ // Signed tvOS stays disabled until EAS has tvOS provisioning
+ // profiles (app + TopShelf targets); non-interactive builds can't
+ // create them. Unsigned tvOS builds, so it flows through normally.
+ if (target.name === 'tvOS') {
status = '💤 Disabled';
- downloadLink = '*Disabled for now*';
+ downloadLink = '*Disabled — signed tvOS needs EAS provisioning profiles*';
} else if (matchingStatus) {
if (matchingStatus.conclusion === 'success' && matchingArtifact) {
status = '✅ Complete';
const directLink = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/artifacts/${matchingArtifact.id}`;
const fileType = target.name.includes('Android') ? 'APK' : 'IPA';
- downloadLink = `[📥 Download ${fileType}](${directLink})`;
+
+ // Format file size
+ const sizeInMB = (matchingArtifact.size_in_bytes / (1024 * 1024)).toFixed(1);
+ const sizeInfo = `(${sizeInMB} MB)`;
+
+ // Calculate build duration
+ let durationInfo = '';
+ if (matchingStatus.started_at && matchingStatus.completed_at) {
+ const durationMs = new Date(matchingStatus.completed_at) - new Date(matchingStatus.started_at);
+ durationInfo = ` - ${fmtDuration(durationMs)}`;
+ }
+
+ downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`;
} else if (matchingStatus.conclusion === 'failure') {
status = `❌ [Failed](${matchingStatus.url})`;
downloadLink = '*Build failed*';
@@ -389,10 +468,16 @@ jobs:
downloadLink = '*Build cancelled*';
} else if (matchingStatus.status === 'in_progress') {
status = `🔄 [Building...](${matchingStatus.url})`;
- downloadLink = '*Build in progress...*';
+ const ref = referenceDurations[target.statusKey];
+ downloadLink = ref
+ ? `*Building… ~${fmtDuration(ref)} (avg on develop)*`
+ : '*Build in progress...*';
} else if (matchingStatus.status === 'queued') {
status = `⏳ [Queued](${matchingStatus.url})`;
- downloadLink = '*Waiting to start...*';
+ const ref = referenceDurations[target.statusKey];
+ downloadLink = ref
+ ? `*Waiting to start… ~${fmtDuration(ref)} once running (avg on develop)*`
+ : '*Waiting to start...*';
} else if (matchingStatus.status === 'completed' && !matchingStatus.conclusion) {
// Workflow completed but conclusion not yet available (rare edge case)
status = `🔄 [Finishing...](${matchingStatus.url})`;
@@ -408,12 +493,27 @@ jobs:
}
}
- commentBody += `| ${target.platform} ${target.name.split(' ')[0]} | ${target.device} ${target.name.split(' ')[1]} | ${status} | ${downloadLink} |\n`;
+ commentBody += `| ${target.platform} ${target.name} | ${target.device} | ${status} | ${downloadLink} |\n`;
}
commentBody += `\n`;
- // Show installation instructions if we have any artifacts
+ // Static rundown of the build optimisations + what each artifact
+ // installs on. Always shown (even mid-build) so testers know what
+ // to expect before downloads are ready.
+ commentBody += `\n`;
+ commentBody += `📦 Build details & device compatibility
\n\n`;
+ commentBody += `These CI builds are trimmed for size and speed. What that means for installing them:\n\n`;
+ commentBody += `| Artifact | Architectures | Installs on |\n`;
+ commentBody += `|---|---|---|\n`;
+ commentBody += `| 🤖 Android Phone APK | \`arm64-v8a\` | Every 64-bit Android phone (all since ~2017). **Not** an x86_64 emulator or a 32-bit device. |\n`;
+ commentBody += `| 📺 Android TV APK | \`arm64-v8a\` + \`armeabi-v7a\` | Modern boxes **and** older / cheap 32-bit Android TV sticks. No x86_64. |\n`;
+ commentBody += `| 🍎 iOS / tvOS IPA | \`arm64\` | iPhone / Apple TV (all current devices). |\n\n`;
+ commentBody += `**Why no x86_64?** That slice only runs on Android emulators / Chromebooks, never a real phone or TV box — dropping it shrinks the APK and speeds up the build. Local \`bun run android\` is unaffected (it still builds x86_64 from \`app.json\`).\n\n`;
+ commentBody += `**Runners:** Android on \`ubuntu-26.04\`; iOS / tvOS on Apple Silicon (\`macos-26\`). The size/speed win comes from the ABI trim above, not the runner.\n`;
+ commentBody += ` \n\n`;
+
+ // Installation instructions only matter once something is downloadable.
if (allArtifacts.length > 0) {
commentBody += `### 🔧 Installation Instructions\n\n`;
commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\n`;
diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml
index c0aae509e..0be3084f6 100644
--- a/.github/workflows/build-apps.yml
+++ b/.github/workflows/build-apps.yml
@@ -11,177 +11,263 @@ on:
push:
branches: [develop, master]
+# Exposed to `expo prebuild` (app.config.ts → extra.build) so Settings can show the
+# branch + commit + Actions run a CI build was made from. EAS cloud builds use
+# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
+# run (artifacts + logs) without needing Expo access.
+env:
+ EXPO_PUBLIC_GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
+ EXPO_PUBLIC_GIT_COMMIT: ${{ github.sha }}
+ EXPO_PUBLIC_GIT_RUN_NUMBER: ${{ github.run_number }}
+
jobs:
build-android-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
- runs-on: ubuntu-24.04
+ runs-on: ubuntu-26.04
name: 🤖 Build Android APK (Phone)
permissions:
contents: read
+ actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- - name: 📥 Checkout code
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- with:
- ref: ${{ github.event.pull_request.head.sha || github.sha }}
- fetch-depth: 0
- submodules: recursive
- show-progress: false
+ - parallel:
+ - name: 🗑️ Free Disk Space
+ uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
+ with:
+ tool-cache: false
+ mandb: true
+ android: false
+ dotnet: true
+ haskell: true
+ large-packages: false
+ docker-images: true
+ swap-storage: false
- - name: 🍞 Setup Bun
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
- with:
- bun-version: latest
+ - name: 📥 Checkout code
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ fetch-depth: 0
+ submodules: recursive
+ show-progress: false
- - name: 💾 Cache Bun dependencies
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
- with:
- path: ~/.bun/install/cache
- key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
- restore-keys: |
- ${{ runner.os }}-${{ runner.arch }}-bun-develop
- ${{ runner.os }}-bun-develop
+ - name: 🍞 Setup Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ # renovate: datasource=npm depName=bun
+ bun-version: "1.3.14"
+
+ - name: ☕ Set up JDK 17
+ # ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
+ # (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
+ # fail). Pin Temurin 17 for a deterministic Android build.
+ uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
+ with:
+ distribution: temurin
+ java-version: "17"
+
+ - parallel:
+ - name: 💾 Cache Bun dependencies
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
+ with:
+ path: ~/.bun/install/cache
+ key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-${{ runner.arch }}-bun-
+
+ - name: 💾 Cache Gradle global
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
+ with:
+ path: |
+ ~/.gradle/caches/modules-2
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: |
+ ${{ runner.os }}-${{ runner.arch }}-gradle-
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- - name: 💾 Cache Gradle global
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
- with:
- path: |
- ~/.gradle/caches
- ~/.gradle/wrapper
- key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
- restore-keys: |
- ${{ runner.os }}-gradle-
-
- name: 🛠️ Generate project files
run: bun run prebuild
- name: 💾 Cache project Gradle (.gradle)
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: android/.gradle
- key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
- restore-keys: ${{ runner.os }}-android-gradle-develop
+ key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
+ restore-keys: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop
- name: 🚀 Build APK
env:
EXPO_TV: 0
+ # CI artifact ships arm64 only (phones; emulators/Chromebooks not a
+ # sideload target). Overrides app.json buildArchs for this build only,
+ # so local `bun run android` (x86_64 emulator) is unaffected.
+ ORG_GRADLE_PROJECT_reactNativeArchitectures: arm64-v8a
run: bun run build:android:local
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
path: |
android/app/build/outputs/apk/release/*.apk
retention-days: 7
+ - name: 🔄 Refresh PR build comment
+ uses: ./.github/actions/refresh-pr-comment
+
build-android-tv:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
- runs-on: ubuntu-24.04
+ runs-on: ubuntu-26.04
name: 🤖 Build Android APK (TV)
permissions:
contents: read
+ actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- - name: 📥 Checkout code
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- with:
- ref: ${{ github.event.pull_request.head.sha || github.sha }}
- fetch-depth: 0
- submodules: recursive
- show-progress: false
+ - parallel:
+ - name: 🗑️ Free Disk Space
+ uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
+ with:
+ tool-cache: false
+ mandb: true
+ android: false
+ dotnet: true
+ haskell: true
+ large-packages: false
+ docker-images: true
+ swap-storage: false
- - name: 🍞 Setup Bun
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
- with:
- bun-version: latest
+ - name: 📥 Checkout code
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ fetch-depth: 0
+ submodules: recursive
+ show-progress: false
- - name: 💾 Cache Bun dependencies
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
- with:
- path: ~/.bun/install/cache
- key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
- restore-keys: |
- ${{ runner.os }}-${{ runner.arch }}-bun-develop
- ${{ runner.os }}-bun-develop
+ - name: 🍞 Setup Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ # renovate: datasource=npm depName=bun
+ bun-version: "1.3.14"
+
+ - name: ☕ Set up JDK 17
+ # ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
+ # (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
+ # fail). Pin Temurin 17 for a deterministic Android build.
+ uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
+ with:
+ distribution: temurin
+ java-version: "17"
+
+ - parallel:
+ - name: 💾 Cache Bun dependencies
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
+ with:
+ path: ~/.bun/install/cache
+ key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-${{ runner.arch }}-bun-
+
+ - name: 💾 Cache Gradle global
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
+ with:
+ path: |
+ ~/.gradle/caches/modules-2
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: |
+ ${{ runner.os }}-${{ runner.arch }}-gradle-
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- - name: 💾 Cache Gradle global
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
- with:
- path: |
- ~/.gradle/caches
- ~/.gradle/wrapper
- key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
- restore-keys: |
- ${{ runner.os }}-gradle-
-
- name: 🛠️ Generate project files
run: bun run prebuild:tv
- name: 💾 Cache project Gradle (.gradle)
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: android/.gradle
- key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
- restore-keys: ${{ runner.os }}-android-gradle-develop
+ key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
+ restore-keys: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop
- name: 🚀 Build APK
env:
EXPO_TV: 1
+ # TV artifact keeps armeabi-v7a too: many older/cheap Android TV boxes
+ # and sticks are still 32-bit ARM. Drops only x86_64. CI build only.
+ ORG_GRADLE_PROJECT_reactNativeArchitectures: arm64-v8a,armeabi-v7a
run: bun run build:android:local
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
path: |
android/app/build/outputs/apk/release/*.apk
retention-days: 7
+ - name: 🔄 Refresh PR build comment
+ uses: ./.github/actions/refresh-pr-comment
+
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-26
name: 🍎 Build iOS IPA (Phone)
permissions:
contents: read
+ actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- - name: 📥 Checkout code
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- with:
- ref: ${{ github.event.pull_request.head.sha || github.sha }}
- fetch-depth: 0
- submodules: recursive
- show-progress: false
+ - parallel:
+ - name: 📥 Checkout code
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ fetch-depth: 0
+ submodules: recursive
+ show-progress: false
- - name: 🍞 Setup Bun
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
- with:
- bun-version: latest
+ - name: 🍞 Setup Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ # renovate: datasource=npm depName=bun
+ bun-version: "1.3.14"
+
+ - name: 🔧 Setup Xcode
+ uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
+ with:
+ # renovate: datasource=custom.xcode depName=xcode versioning=loose
+ xcode-version: "26.6"
+
+ - name: 🏗️ Setup EAS
+ uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
+ with:
+ eas-version: latest
+ token: ${{ secrets.EXPO_TOKEN }}
+ eas-cache: true
- name: 💾 Cache Bun dependencies
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
- key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
+ key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
- ${{ runner.os }}-bun-cache
+ ${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies and reload submodules
run: |
@@ -191,100 +277,225 @@ 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:
- eas-version: latest
- token: ${{ secrets.EXPO_TOKEN }}
- eas-cache: true
-
- - name: ⚙️ Ensure iOS SDKs installed
- run: xcodebuild -downloadPlatform iOS
-
- name: 🚀 Build iOS app
env:
EXPO_TV: 0
- run: eas build -p ios --local --non-interactive
+ # `ci` profile (extends production, autoIncrement off): keeps CI builds out of
+ # the production version tier and stops them inflating the store build counter.
+ run: eas build -p ios --local --non-interactive --profile ci
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
path: build-*.ipa
retention-days: 7
- # 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-26
- # name: 🍎 Build iOS IPA (TV)
- # permissions:
- # contents: read
- #
- # steps:
- # - name: 📥 Checkout code
- # uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- # with:
- # ref: ${{ github.event.pull_request.head.sha || github.sha }}
- # fetch-depth: 0
- # submodules: recursive
- # show-progress: false
- #
- # - name: 🍞 Setup Bun
- # uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
- # with:
- # bun-version: latest
- #
- # - name: 💾 Cache Bun dependencies
- # uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
- # with:
- # path: ~/.bun/install/cache
- # key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
- # restore-keys: |
- # ${{ runner.os }}-bun-cache
- #
- # - name: 📦 Install dependencies and reload submodules
- # run: |
- # bun install --frozen-lockfile
- # bun run submodule-reload
- #
- # - name: 🛠️ Generate project files
- # run: bun run prebuild: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:
- # eas-version: latest
- # token: ${{ secrets.EXPO_TOKEN }}
- # eas-cache: true
- #
- # - name: ⚙️ Ensure tvOS SDKs installed
- # run: xcodebuild -downloadPlatform tvOS
- #
- # - name: 🚀 Build iOS app
- # env:
- # EXPO_TV: 1
- # run: eas build -p ios --local --non-interactive
- #
- # - name: 📅 Set date tag
- # run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- #
- # - name: 📤 Upload IPA artifact
- # uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- # with:
- # name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
- # path: build-*.ipa
- # retention-days: 7
+ - name: 🔄 Refresh PR build comment
+ uses: ./.github/actions/refresh-pr-comment
+
+ build-ios-phone-unsigned:
+ if: (!contains(github.event.head_commit.message, '[skip ci]'))
+ runs-on: macos-26
+ name: 🍎 Build iOS IPA (Phone - Unsigned)
+ permissions:
+ contents: read
+ actions: write # dispatch artifact-comment.yml to refresh the PR comment
+
+ steps:
+ - parallel:
+ - name: 📥 Checkout code
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ fetch-depth: 0
+ submodules: recursive
+ show-progress: false
+
+ - name: 🍞 Setup Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ # renovate: datasource=npm depName=bun
+ bun-version: "1.3.14"
+
+ - name: 🔧 Setup Xcode
+ uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
+ with:
+ # renovate: datasource=custom.xcode depName=xcode versioning=loose
+ xcode-version: "26.6"
+
+ - name: 💾 Cache Bun dependencies
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
+ with:
+ path: ~/.bun/install/cache
+ key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-${{ runner.arch }}-bun-
+
+ - name: 📦 Install dependencies and reload submodules
+ run: |
+ bun install --frozen-lockfile
+ bun run submodule-reload
+
+ - name: 🛠️ Generate project files
+ run: bun run prebuild
+
+ - name: 🚀 Build iOS app
+ env:
+ EXPO_TV: 0
+ run: bun run ios:unsigned-build ${{ github.event_name == 'pull_request' && '-- --verbose' || '' }}
+
+ - name: 📅 Set date tag
+ run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
+
+ - name: 📤 Upload IPA artifact
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }}
+ path: build/*.ipa
+ retention-days: 7
+
+ - name: 🔄 Refresh PR build comment
+ uses: ./.github/actions/refresh-pr-comment
+
+ build-ios-tv:
+ # Disabled: EAS has no provisioning profiles / distribution cert for the tvOS
+ # targets (app + StreamyfinTopShelf extension), so non-interactive signed
+ # builds fail. Set up tvOS credentials in EAS (`eas credentials`), then remove
+ # the `false &&` prefix below. Unsigned tvOS builds run (see job below).
+ if: false && (!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-26
+ name: 🍎 Build tvOS IPA
+ permissions:
+ contents: read
+ actions: write # dispatch artifact-comment.yml to refresh the PR comment
+
+ steps:
+ - parallel:
+ - name: 📥 Checkout code
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ fetch-depth: 0
+ submodules: recursive
+ show-progress: false
+
+ - name: 🍞 Setup Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ # renovate: datasource=npm depName=bun
+ bun-version: "1.3.14"
+
+ - name: 🔧 Setup Xcode
+ uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
+ with:
+ # renovate: datasource=custom.xcode depName=xcode versioning=loose
+ xcode-version: "26.6"
+
+ - name: 🏗️ Setup EAS
+ uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
+ with:
+ eas-version: latest
+ token: ${{ secrets.EXPO_TOKEN }}
+ eas-cache: true
+
+ - name: 💾 Cache Bun dependencies
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
+ with:
+ path: ~/.bun/install/cache
+ key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-${{ runner.arch }}-bun-
+
+ - name: 📦 Install dependencies and reload submodules
+ run: |
+ bun install --frozen-lockfile
+ bun run submodule-reload
+
+ - name: 🛠️ Generate project files
+ run: bun run prebuild:tv
+
+ - name: 🚀 Build iOS app
+ env:
+ EXPO_TV: 1
+ run: eas build -p ios --local --non-interactive --profile ci_tv
+
+ - name: 📅 Set date tag
+ run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
+
+ - name: 📤 Upload IPA artifact
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
+ path: build-*.ipa
+ retention-days: 7
+
+ build-ios-tv-unsigned:
+ # Unsigned tvOS build is enabled (compiles without Apple credentials).
+ # The signed tvOS job above stays disabled until tvOS provisioning
+ # profiles are set up in EAS (app + TopShelf targets).
+ if: (!contains(github.event.head_commit.message, '[skip ci]'))
+ runs-on: macos-26
+ name: 🍎 Build tvOS IPA (Unsigned)
+ permissions:
+ contents: read
+ actions: write # dispatch artifact-comment.yml to refresh the PR comment
+
+ steps:
+ - parallel:
+ - name: 📥 Checkout code
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ fetch-depth: 0
+ submodules: recursive
+ show-progress: false
+
+ - name: 🍞 Setup Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ # renovate: datasource=npm depName=bun
+ bun-version: "1.3.14"
+
+ - name: 🔧 Setup Xcode
+ uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
+ with:
+ # renovate: datasource=custom.xcode depName=xcode versioning=loose
+ xcode-version: "26.6"
+
+ - name: 💾 Cache Bun dependencies
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
+ with:
+ path: ~/.bun/install/cache
+ key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-${{ runner.arch }}-bun-
+
+ - name: 📦 Install dependencies and reload submodules
+ run: |
+ bun install --frozen-lockfile
+ bun run submodule-reload
+
+ - name: 🛠️ Generate project files
+ run: bun run prebuild:tv
+
+ - name: 🚀 Build iOS app
+ env:
+ EXPO_TV: 1
+ run: bun run ios:unsigned-build:tv ${{ github.event_name == 'pull_request' && '-- --verbose' || '' }}
+
+ - name: 📅 Set date tag
+ run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
+
+ - name: 📤 Upload IPA artifact
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
+ path: build/*.ipa
+ retention-days: 7
+
+ - name: 🔄 Refresh PR build comment
+ uses: ./.github/actions/refresh-pr-comment
diff --git a/.github/workflows/check-lockfile.yml b/.github/workflows/check-lockfile.yml
index cc606b196..d06699306 100644
--- a/.github/workflows/check-lockfile.yml
+++ b/.github/workflows/check-lockfile.yml
@@ -13,30 +13,33 @@ concurrency:
jobs:
check-lockfile:
name: 🔍 Check bun.lock and package.json consistency
- runs-on: ubuntu-24.04
+ runs-on: ubuntu-26.04
permissions:
contents: read
steps:
- - name: 📥 Checkout repository
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- with:
- ref: ${{ github.event.pull_request.head.sha || github.sha }}
- show-progress: false
- submodules: recursive
- fetch-depth: 0
+ - parallel:
+ - name: 📥 Checkout repository
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ show-progress: false
+ submodules: recursive
- - name: 🍞 Setup Bun
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
- with:
- bun-version: latest
+ - name: 🍞 Setup Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ # renovate: datasource=npm depName=bun
+ bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.bun/install/cache
- key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
+ key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-${{ runner.arch }}-bun-
- name: 🛡️ Verify lockfile consistency
run: |
diff --git a/.github/workflows/ci-codeql.yml b/.github/workflows/ci-codeql.yml
index d1a442395..b9921780d 100644
--- a/.github/workflows/ci-codeql.yml
+++ b/.github/workflows/ci-codeql.yml
@@ -8,11 +8,14 @@ on:
schedule:
- cron: '24 2 * * *'
+concurrency:
+ group: codeql-${{ github.ref }}
+ cancel-in-progress: true
jobs:
analyze:
name: 🔎 Analyze with CodeQL
- runs-on: ubuntu-24.04
+ runs-on: ubuntu-26.04
permissions:
contents: read
security-events: write
@@ -24,20 +27,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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: 🏁 Initialize CodeQL
- uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
+ uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
- uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
+ uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
- name: 🧪 Perform CodeQL Analysis
- uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
+ uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
diff --git a/.github/workflows/conflict.yml b/.github/workflows/conflict.yml
index 7793851c2..125ad7717 100644
--- a/.github/workflows/conflict.yml
+++ b/.github/workflows/conflict.yml
@@ -10,14 +10,14 @@ on:
jobs:
label:
name: 🏷️ Labeling Merge Conflicts
- runs-on: ubuntu-24.04
+ runs-on: ubuntu-26.04
if: ${{ github.repository == 'streamyfin/streamyfin' }}
permissions:
contents: read
pull-requests: write
steps:
- name: 🚩 Apply merge conflict label
- uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
+ uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
with:
dirtyLabel: '⚔️ merge-conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml
index a49669ad8..c14fe48fa 100644
--- a/.github/workflows/crowdin.yml
+++ b/.github/workflows/crowdin.yml
@@ -1,50 +1,51 @@
-name: 🌐 Translation Sync
-
-on:
- push:
- 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:
- sync-translations:
- runs-on: ubuntu-latest
-
- steps:
- - name: 📥 Checkout Repository
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- with:
- fetch-depth: 0
-
- - name: 🌐 Sync Translations with Crowdin
- uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0
- with:
- upload_sources: true
- upload_translations: true
- download_translations: true
- 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_labels: "🌐 translation"
- # Quality control options
- skip_untranslated_strings: true
- export_only_approved: false
- # Commit customization
- commit_message: "feat(i18n): update translations from Crowdin"
- env:
- GITHUB_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
- CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
- CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
+name: 🌐 Translation Sync
+
+on:
+ push:
+ 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:
+ sync-translations:
+ runs-on: ubuntu-26.04
+
+ steps:
+ - name: 📥 Checkout Repository
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ fetch-depth: 0
+
+ - name: 🌐 Sync Translations with Crowdin
+ uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
+ with:
+ upload_sources: true
+ upload_translations: true
+ download_translations: true
+ 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_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:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
+ CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
diff --git a/.github/workflows/detect-duplicate.yml b/.github/workflows/detect-duplicate.yml
new file mode 100644
index 000000000..a70b6ffb3
--- /dev/null
+++ b/.github/workflows/detect-duplicate.yml
@@ -0,0 +1,40 @@
+name: 🔁 Detect Duplicate Issues
+
+on:
+ issues:
+ types: [opened]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: detect-duplicate-${{ github.event.issue.number }}
+ cancel-in-progress: true
+
+jobs:
+ detect:
+ name: 🔍 Find similar issues
+ if: github.actor != 'github-actions[bot]'
+ runs-on: ubuntu-26.04
+ permissions:
+ issues: write
+ contents: read
+ steps:
+ - parallel:
+ - name: 📥 Checkout repository
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+
+ - name: 🍞 Setup Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ # renovate: datasource=npm depName=bun
+ bun-version: "1.3.14"
+
+ - name: 🔍 Detect duplicate issues
+ run: bun scripts/detect-duplicate-issue.ts
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
+ ISSUE_TITLE: ${{ github.event.issue.title }}
+ ISSUE_BODY: ${{ github.event.issue.body }}
diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml
index 550019587..992455862 100644
--- a/.github/workflows/linting.yml
+++ b/.github/workflows/linting.yml
@@ -2,7 +2,7 @@ name: 🚦 Security & Quality Gate
on:
pull_request:
- types: [opened, edited, synchronize, reopened]
+ types: [opened, synchronize, reopened]
branches: [develop, master]
workflow_dispatch:
push:
@@ -11,53 +11,25 @@ on:
permissions:
contents: read
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
- validate_pr_title:
- name: "📝 Validate PR Title"
- if: github.event_name == 'pull_request'
- runs-on: ubuntu-24.04
- permissions:
- pull-requests: write
- contents: read
- steps:
- - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
- id: lint_pr_title
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
- if: always() && (steps.lint_pr_title.outputs.error_message != null)
- with:
- header: pr-title-lint-error
- message: |
- Hey there and thank you for opening this pull request! 👋🏼
- We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
-
- **Error details:**
- ```
- ${{ steps.lint_pr_title.outputs.error_message }}
- ```
-
- - if: ${{ steps.lint_pr_title.outputs.error_message == null }}
- uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
- with:
- header: pr-title-lint-error
- delete: true
-
dependency-review:
name: 🔍 Vulnerable Dependencies
- runs-on: ubuntu-24.04
+ runs-on: ubuntu-26.04
permissions:
contents: read
steps:
- name: Checkout Repository
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- name: Dependency Review
- uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
+ uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
fail-on-severity: high
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
@@ -65,30 +37,41 @@ jobs:
expo-doctor:
name: 🚑 Expo Doctor Check
- if: false
- runs-on: ubuntu-24.04
+ runs-on: ubuntu-26.04
steps:
- - name: 🛒 Checkout repository
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- with:
- ref: ${{ github.event.pull_request.head.sha || github.sha }}
- submodules: recursive
- fetch-depth: 0
+ - parallel:
+ - name: 🛒 Checkout repository
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ submodules: recursive
- - name: 🍞 Setup Bun
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
+ - name: 🍞 Setup Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ # renovate: datasource=npm depName=bun
+ bun-version: "1.3.14"
+
+ - name: 💾 Cache Bun dependencies
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
- bun-version: latest
+ path: ~/.bun/install/cache
+ key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies (bun)
run: bun install --frozen-lockfile
- name: 🚑 Run Expo Doctor
+ # Re-enabled but non-blocking: surfaces doctor warnings in the logs
+ # without failing the gate (some checks are known-noisy for this setup).
+ continue-on-error: true
run: bun expo-doctor
code_quality:
name: "🔍 Lint & Test (${{ matrix.command }})"
- runs-on: ubuntu-24.04
+ runs-on: ubuntu-26.04
strategy:
fail-fast: false
matrix:
@@ -97,24 +80,29 @@ jobs:
- "check"
- "format"
- "typecheck"
+ - "i18n:check"
steps:
- - name: "📥 Checkout PR code"
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- with:
- ref: ${{ github.event.pull_request.head.sha || github.sha }}
- submodules: recursive
- fetch-depth: 0
+ - parallel:
+ - name: "📥 Checkout PR code"
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ submodules: recursive
- - name: "🟢 Setup Node.js"
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
- with:
- node-version: '24.x'
+ - name: "🍞 Setup Bun"
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ # renovate: datasource=npm depName=bun
+ bun-version: "1.3.14"
- - name: "🍞 Setup Bun"
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
+ - name: 💾 Cache Bun dependencies
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
- bun-version: latest
+ path: ~/.bun/install/cache
+ key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-${{ runner.arch }}-bun-
- name: "📦 Install dependencies"
run: bun install --frozen-lockfile
diff --git a/.github/workflows/notification.yml b/.github/workflows/notification.yml
index 92bb4a5db..df9e4fa56 100644
--- a/.github/workflows/notification.yml
+++ b/.github/workflows/notification.yml
@@ -1,4 +1,5 @@
name: 🛎️ Discord Notification
+permissions: {}
on:
pull_request:
@@ -11,7 +12,7 @@ on:
jobs:
notify:
- runs-on: ubuntu-24.04
+ runs-on: ubuntu-26.04
if: github.event_name == 'pull_request'
steps:
- name: 🛎️ Notify Discord
@@ -28,7 +29,7 @@ jobs:
🔗 ${{ github.event.pull_request.html_url }}
notify-on-failure:
- runs-on: ubuntu-24.04
+ runs-on: ubuntu-26.04
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'failure'
steps:
- name: 🚨 Notify Discord on Failure
diff --git a/.github/workflows/pr-title-comment.yml b/.github/workflows/pr-title-comment.yml
new file mode 100644
index 000000000..648eb1c44
--- /dev/null
+++ b/.github/workflows/pr-title-comment.yml
@@ -0,0 +1,81 @@
+name: 📝 PR Title Comment
+
+# Posts / updates / deletes the PR-title lint sticky comment with a write token,
+# so it works on FORK PRs too (the pull_request-triggered pr-title.yml only has a
+# read-only token on forks). workflow_run runs from the base branch with the base
+# repo's token. This is comment-only — the required check stays pr-title.yml.
+on:
+ workflow_run:
+ workflows: ["📝 PR Title"]
+ types: [completed]
+
+permissions:
+ contents: read
+ actions: read # download the artifact from the triggering run
+ pull-requests: write # post/update/delete the sticky comment
+
+concurrency:
+ group: pr-title-comment-${{ github.event.workflow_run.id }}
+ cancel-in-progress: true
+
+jobs:
+ comment:
+ # Only run when the lint run actually produced a result artifact: a run
+ # cancelled by concurrency (rapid pushes to the same PR) completes with
+ # conclusion "cancelled" before uploading it, and would fail the download.
+ if: >
+ github.event.workflow_run.event == 'pull_request' &&
+ contains(fromJSON('["success", "failure"]'), github.event.workflow_run.conclusion)
+ runs-on: ubuntu-26.04
+ steps:
+ - name: ⬇️ Download lint result
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh run download "${{ github.event.workflow_run.id }}" \
+ --repo "${{ github.repository }}" \
+ --name pr-title-result \
+ --dir result
+
+ - name: 🔎 Read result
+ id: result
+ run: |
+ echo "number=$(cat result/number)" >> "$GITHUB_OUTPUT"
+ if [ -s result/error ]; then
+ echo "has_error=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "has_error=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: 📥 Load error message
+ if: steps.result.outputs.has_error == 'true'
+ run: |
+ {
+ echo "LINT_ERROR<> "$GITHUB_ENV"
+
+ - name: 💬 Post title error
+ if: steps.result.outputs.has_error == 'true'
+ uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
+ with:
+ number: ${{ steps.result.outputs.number }}
+ header: pr-title-lint-error
+ message: |
+ Hey there and thank you for opening this pull request! 👋🏼
+ We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
+
+ **Error details:**
+ ```
+ ${{ env.LINT_ERROR }}
+ ```
+
+ - name: 🧹 Clear title error
+ if: steps.result.outputs.has_error == 'false'
+ uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
+ with:
+ number: ${{ steps.result.outputs.number }}
+ header: pr-title-lint-error
+ delete: true
diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml
new file mode 100644
index 000000000..68c503d67
--- /dev/null
+++ b/.github/workflows/pr-title.yml
@@ -0,0 +1,42 @@
+name: 📝 PR Title
+
+on:
+ pull_request:
+ types: [opened, edited, synchronize, reopened]
+ branches: [develop, master]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ validate_pr_title:
+ name: "📝 Validate PR Title"
+ runs-on: ubuntu-26.04
+ steps:
+ - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
+ id: lint_pr_title
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # Fork PRs only get a read-only GITHUB_TOKEN here, so the sticky comment
+ # is posted from the privileged pr-title-comment.yml (workflow_run). Hand
+ # off the result (PR number + lint error) as an artifact for it to read.
+ - if: always()
+ env:
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ LINT_ERROR: ${{ steps.lint_pr_title.outputs.error_message }}
+ run: |
+ mkdir -p pr-title-result
+ printf '%s' "$PR_NUMBER" > pr-title-result/number
+ printf '%s' "$LINT_ERROR" > pr-title-result/error
+
+ - if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: pr-title-result
+ path: pr-title-result/
+ retention-days: 1
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 000000000..9860d6f53
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,221 @@
+name: 🚀 Release (EAS build + submit)
+
+# On merge to main (gated by the `production` GitHub Environment approval),
+# build all targets on EAS in parallel via custom bun build configs:
+# 1. iOS phone → App Store (auto-submit)
+# 2. tvOS → App Store (auto-submit)
+# 3. Android AAB → Google Play (auto-submit)
+# 4. Android phone APK→ downloadable artifact
+# 5. Android TV APK → downloadable artifact
+# Note: EAS queues builds based on your plan's concurrency; parallel jobs
+# here just submit them — EAS may still run them serially.
+
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
+ # Queue successive releases in order instead of dropping the extra pending run.
+ queue: max
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+jobs:
+ approve:
+ name: 🔐 Approve release
+ runs-on: ubuntu-26.04
+ environment: production
+ permissions: {}
+ steps:
+ - name: ✅ Release approved
+ run: echo "Release approved for ${{ github.sha }}"
+
+ build:
+ name: 🚀 ${{ matrix.name }}
+ needs: approve
+ runs-on: ubuntu-26.04
+ permissions:
+ contents: read
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - name: 🍎 iOS
+ platform: ios
+ profile: production
+ submit: true
+ - name: 📺 tvOS
+ platform: ios
+ profile: production_tv
+ submit: true
+ - name: 🤖 Android AAB
+ platform: android
+ profile: production
+ submit: true
+ - name: 🤖 Android APK
+ platform: android
+ profile: production-apk
+ submit: false
+ artifact_name: streamyfin-android-phone-apk
+ - name: 📺 Android TV APK
+ platform: android
+ profile: production-apk-tv
+ submit: false
+ artifact_name: streamyfin-android-tv-apk
+
+ steps:
+ - parallel:
+ - name: 📥 Checkout code
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ fetch-depth: 0
+ submodules: recursive
+ show-progress: false
+
+ - name: 🍞 Setup Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ # renovate: datasource=npm depName=bun
+ bun-version: "1.3.14"
+
+ - name: 🏗️ Setup EAS
+ uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
+ with:
+ eas-version: latest
+ token: ${{ secrets.EXPO_TOKEN }}
+ eas-cache: true
+
+ - name: 💾 Cache Bun dependencies
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
+ with:
+ path: ~/.bun/install/cache
+ key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-${{ runner.arch }}-bun-
+
+ - name: 📦 Install dependencies and reload submodules
+ run: |
+ bun install --frozen-lockfile
+ bun run submodule-reload
+
+ # tvOS uses credentialsSource: local — restore the gitignored
+ # credentials.json + cert + provisioning profiles from secrets.
+ - name: 🔐 Restore tvOS signing credentials
+ if: matrix.profile == 'production_tv'
+ env:
+ EAS_CREDENTIALS_JSON: ${{ secrets.EAS_CREDENTIALS_JSON }}
+ TVOS_DIST_CERT_P12_BASE64: ${{ secrets.TVOS_DIST_CERT_P12_BASE64 }}
+ TVOS_APP_PROFILE_BASE64: ${{ secrets.TVOS_APP_PROFILE_BASE64 }}
+ TVOS_TOPSHELF_PROFILE_BASE64: ${{ secrets.TVOS_TOPSHELF_PROFILE_BASE64 }}
+ run: |
+ mkdir -p certs profiles
+ printf '%s' "$EAS_CREDENTIALS_JSON" > credentials.json
+ echo "$TVOS_DIST_CERT_P12_BASE64" | base64 -d > certs/distribution.p12
+ echo "$TVOS_APP_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_tvOS_App_Store.mobileprovision
+ echo "$TVOS_TOPSHELF_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_TopShelf_tvOS_App_Store.mobileprovision
+
+ # Android Play submit needs the Google Play service account JSON.
+ - name: 🔐 Restore Google Play service account
+ if: matrix.platform == 'android' && matrix.submit
+ env:
+ GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
+ run: printf '%s' "$GOOGLE_SERVICE_ACCOUNT_KEY" > google-service-account.json
+
+ # App Store Connect API key for iOS/tvOS submit (raw-PEM or base64).
+ - name: 🔐 Restore App Store Connect API key
+ if: matrix.platform == 'ios'
+ env:
+ APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }}
+ run: |
+ if printf '%s' "$APPLE_KEY_CONTENT" | grep -q "BEGIN PRIVATE KEY"; then
+ printf '%s' "$APPLE_KEY_CONTENT" > "$RUNNER_TEMP/asc_api_key.p8"
+ else
+ printf '%s' "$APPLE_KEY_CONTENT" | base64 -d > "$RUNNER_TEMP/asc_api_key.p8"
+ fi
+
+ # ── Submit builds: cloud build + auto-submit to the store ──
+ - name: 🚀 Build & submit (${{ matrix.name }})
+ if: matrix.submit
+ env:
+ EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
+ EXPO_ASC_API_KEY_PATH: ${{ runner.temp }}/asc_api_key.p8
+ EXPO_ASC_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
+ EXPO_ASC_ISSUER_ID: ${{ secrets.APPLE_KEY_ISSUER_ID }}
+ run: |
+ eas build \
+ --platform ${{ matrix.platform }} \
+ --profile ${{ matrix.profile }} \
+ --auto-submit \
+ --non-interactive \
+ --wait
+
+ # ── Artifact builds: cloud build, then download + upload the APK ──
+ - name: 🏗️ Build artifact (${{ matrix.name }})
+ if: ${{ !matrix.submit }}
+ env:
+ EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
+ run: |
+ eas build \
+ --platform ${{ matrix.platform }} \
+ --profile ${{ matrix.profile }} \
+ --non-interactive \
+ --wait \
+ --json > build-result.json
+ URL=$(node -e "const b=require('./build-result.json'); const x=Array.isArray(b)?b[0]:b; console.log(x.artifacts.applicationArchiveUrl)")
+ echo "Downloading artifact: $URL"
+ curl -fL "$URL" -o "${{ matrix.artifact_name }}.apk"
+
+ - name: 📤 Upload APK artifact (${{ matrix.name }})
+ if: ${{ !matrix.submit }}
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: ${{ matrix.artifact_name }}
+ path: ${{ matrix.artifact_name }}.apk
+ retention-days: 14
+
+ # Draft a GitHub Release with the two APKs attached. The tag comes from the
+ # merged-in app version (app.json → expo.version), NOT the auto-incremented
+ # build number — so cutting a release is a deliberate version bump via PR.
+ github-release:
+ name: 📦 Draft GitHub Release
+ needs: build
+ if: ${{ !cancelled() }}
+ runs-on: ubuntu-26.04
+ permissions:
+ contents: write
+ actions: read # required for `gh run download` to list/fetch this run's artifacts
+ steps:
+ - name: 📥 Checkout code
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ fetch-depth: 0
+ show-progress: false
+
+ - name: 📦 Download APK artifacts from this run
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ mkdir -p apks
+ gh run download ${{ github.run_id }} --name streamyfin-android-phone-apk --dir apks
+ gh run download ${{ github.run_id }} --name streamyfin-android-tv-apk --dir apks
+ ls -la apks
+
+ - name: 📝 Draft release (tag = app.json version, not auto-bumped)
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ VERSION=$(node -e "console.log(require('./app.json').expo.version)")
+ TAG="v$VERSION"
+ echo "Release tag from merged app version: $TAG"
+ if gh release view "$TAG" >/dev/null 2>&1; then
+ echo "Release $TAG exists — updating APK assets"
+ gh release upload "$TAG" apks/*.apk --clobber
+ else
+ echo "Creating draft release $TAG"
+ gh release create "$TAG" \
+ --draft \
+ --generate-notes \
+ --title "$TAG" \
+ apks/*.apk
+ fi
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
deleted file mode 100644
index 345370973..000000000
--- a/.github/workflows/stale.yml
+++ /dev/null
@@ -1,49 +0,0 @@
-name: 🕒 Handle Stale Issues
-
-on:
- schedule:
- # Runs daily at 1:30 AM UTC (3:30 AM CEST - France time)
- - cron: "30 1 * * *"
-
-jobs:
- stale-issues:
- name: 🗑️ Cleanup Stale Issues
- runs-on: ubuntu-24.04
- permissions:
- issues: write
- pull-requests: write
-
- steps:
- - name: 🔄 Mark/Close Stale Issues
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
- with:
- # Global settings
- repo-token: ${{ secrets.GITHUB_TOKEN }}
- operations-per-run: 500 # Increase if you have >1000 issues
- enable-statistics: true
-
- # Issue configuration
- days-before-issue-stale: 90
- days-before-issue-close: 7
- stale-issue-label: "🕰️ stale"
- exempt-issue-labels: "Roadmap v1,help needed,enhancement"
-
- # Notifications messages
- stale-issue-message: |
- ⏳ This issue has been automatically marked as **stale** because it has had no activity for 90 days.
-
- **Next steps:**
- - If this is still relevant, add a comment to keep it open
- - Otherwise, it will be closed in 7 days
-
- Thank you for your contributions! 🙌
-
- close-issue-message: |
- 🚮 This issue has been automatically closed due to inactivity (7 days since being marked stale).
-
- **Need to reopen?**
- Click "Reopen" and add a comment explaining why this should stay open.
-
- # Disable PR handling
- days-before-pr-stale: -1
- days-before-pr-close: -1
diff --git a/.github/workflows/trivy-scan.yml b/.github/workflows/trivy-scan.yml
new file mode 100644
index 000000000..2e0f307b4
--- /dev/null
+++ b/.github/workflows/trivy-scan.yml
@@ -0,0 +1,50 @@
+name: 🛡️ Trivy Security Scan
+
+# Filesystem scan (Streamyfin ships no container image): finds vulnerable dependencies,
+# leaked secrets and misconfigurations, and reports them to GitHub code scanning.
+# Runs post-merge + weekly (not on PRs — dependency-review already gates PRs, and SARIF
+# upload needs a write token that fork PRs don't get).
+on:
+ push:
+ branches: [develop, master]
+ schedule:
+ - cron: "50 7 * * 5" # Weekly, Friday 07:50 UTC
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: trivy-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ trivy:
+ name: 🔎 Filesystem scan
+ runs-on: ubuntu-26.04
+ permissions:
+ contents: read
+ security-events: write # upload SARIF to code scanning
+ steps:
+ - name: 📥 Checkout repository
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+
+ # Trivy's own action caches the vulnerability DB + binary internally
+ # (cache-trivy-* / trivy-binary-* entries), so no manual ~/.cache/trivy
+ # step is needed — it only duplicated the cache.
+ - name: 🔎 Run Trivy filesystem scan
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
+ with:
+ scan-type: fs
+ scan-ref: .
+ scanners: vuln,secret,misconfig
+ ignore-unfixed: true
+ severity: CRITICAL,HIGH
+ format: sarif
+ output: trivy-results.sarif
+
+ - name: 📤 Upload results to code scanning
+ uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
+ with:
+ sarif_file: trivy-results.sarif
+ category: trivy-fs
diff --git a/.github/workflows/update-issue-form.yml b/.github/workflows/update-issue-form.yml
index dc76a0741..db111d560 100644
--- a/.github/workflows/update-issue-form.yml
+++ b/.github/workflows/update-issue-form.yml
@@ -1,67 +1,104 @@
-name: 🐛 Update Bug Report Template
+name: 🐛 Update Issue Form Versions
on:
release:
- types: [published] # Run on every published release on any branch
+ # Only full releases populate the dropdown (no drafts/prereleases).
+ types: [released]
+ schedule:
+ - cron: "0 3 * * 1" # Weekly safety net (Mondays 03:00 UTC) in case a release event was missed
+ workflow_dispatch:
+# Fixed group so a release event and the weekly cron can't race on the same
+# ci/update-issue-form branch — runs queue instead of force-pushing over each other.
concurrency:
- group: update-issue-form-${{ github.event.release.tag_name || github.run_id }}
- cancel-in-progress: true
+ group: update-issue-form
+ cancel-in-progress: false
+
+permissions:
+ contents: read
jobs:
- update-bug-report:
+ update-issue-form:
+ name: 🔢 Populate version dropdown
+ runs-on: ubuntu-26.04
permissions:
contents: write
pull-requests: write
- issues: write
- runs-on: ubuntu-24.04
-
steps:
- - name: 📥 Checkout repository
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ - parallel:
+ - name: 📥 Checkout repository
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ # On `release` events GITHUB_SHA is the tagged commit — without this the
+ # script would regenerate the form from the tag's (stale) copy and the bot
+ # PR would revert any form edits made on develop since that release.
+ ref: develop
- - name: "🟢 Setup Node.js"
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
- with:
- node-version: '24.x'
- cache: 'npm'
+ - name: 🍞 Setup Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ # renovate: datasource=npm depName=bun
+ bun-version: "1.3.14"
- - name: 🔍 Extract minor version from app.json
- id: minor
- uses: actions/github-script@main
- with:
- result-encoding: string
- script: |
- const fs = require('fs-extra');
- const semver = require('semver');
- const content = fs.readJsonSync('./app.json');
- const version = content.expo.version;
- const minorVersion = semver.minor(version);
- return minorVersion.toString();
+ - name: 🔢 Populate version dropdown from GitHub releases
+ id: populate
+ run: bun scripts/update-issue-form.mjs
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
- - name: 📝 Update bug report version
- uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
+ - name: 📬 Create pull request
+ id: cpr
+ uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
- semver: '^0.${{ steps.minor.outputs.result }}.0'
- dry_run: no-push
-
- - name: ⚙️ Update bug report node version dropdown
- uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
- with:
- dropdown: _node_version
- package: node
- semver: '>=24.0.0'
- dry_run: no-push
-
- - name: 📬 Commit and create pull request
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8
- with:
- add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
- branch: ci-update-bug-report
+ add-paths: .github/ISSUE_TEMPLATE/issue_report.yml
+ branch: ci/update-issue-form
base: develop
delete-branch: true
labels: ⚙️ ci, 🤖 github-actions
- title: 'chore(): Update bug report template to match release version'
+ commit-message: "chore: update issue form version dropdown"
+ title: "chore: update issue form version dropdown"
+ # Follows .github/pull_request_template.md so the bot PR isn't flagged by PR validation.
body: |
- Automated update to `.github/ISSUE_TEMPLATE/bug_report.yml`
- Triggered by workflow run [${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
+ # 📦 Pull Request
+
+ ## 📝 Description
+
+ Automated update of the **Streamyfin Version** dropdown in `.github/ISSUE_TEMPLATE/issue_report.yml`, populated from the latest published GitHub releases by `scripts/update-issue-form.mjs`.
+
+ **Version dropdown now lists:** ${{ steps.populate.outputs.versions }}
+
+ Triggered by `${{ github.event_name }}`${{ github.event.release.tag_name && format(' — release {0}', github.event.release.tag_name) || '' }} · [run ${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}).
+
+ ## 🏷️ Ticket / Issue
+
+ N/A — automated maintenance.
+
+ ### 🖼️ Screenshots / GIFs (if UI)
+
+ N/A — issue-template metadata only, no app UI.
+
+ ## ✅ Checklist
+
+ - [x] I’ve read the [contribution guidelines](CONTRIBUTING.md)
+ - [x] Verified that changes behave as expected for all platforms
+ - [x] Code passes lint/formatting and type checks (`tsc`/`biome`)
+ - [x] No secrets, hardcoded credentials, or private config files are included
+ - [x] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
+
+ ## 🔍 Testing Instructions
+
+ N/A — generated by CI from published releases; review the dropdown diff in `issue_report.yml`.
+
+ - name: 🔀 Enable auto-merge
+ if: steps.cpr.outputs.pull-request-operation == 'created'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ # Known limitation: PRs created with GITHUB_TOKEN don't trigger CI workflows
+ # (GitHub anti-recursion), so the required checks stay "Expected" until a
+ # maintainer kicks them (close/reopen the PR, or push an empty commit).
+ # Auto-merge is still worth enabling: once checks run and reviews land,
+ # the PR merges itself.
+ run: |
+ gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}" \
+ || echo "::warning::Could not enable auto-merge — enable 'Allow auto-merge' in repo settings (and branch protection); merge the PR manually for now."
diff --git a/.gitignore b/.gitignore
index 2813f7706..92bdf316d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,5 @@
# Dependencies and Package Managers
node_modules/
-bun.lock
bun.lockb
package-lock.json
@@ -13,15 +12,12 @@ web-build/
# Platform-specific Build Directories
/ios
/android
-/iostv
-/iosmobile
-/androidmobile
-/androidtv
-# Module-specific Builds
-modules/vlc-player/android/build
-modules/player/android
-modules/hls-downloader/android/build
+# Gradle caches (top-level + per-module native projects)
+**/.gradle/
+
+# Native module build outputs (any module)
+modules/*/android/build/
# Generated Applications
Streamyfin.app
@@ -50,7 +46,6 @@ npm-debug.*
.idea/
.ruby-lsp
.cursor/
-.claude/
# Environment and Configuration
expo-env.d.ts
@@ -62,7 +57,20 @@ expo-env.d.ts
pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json
streamyfin-4fec1-firebase-adminsdk.json
+/profiles/
+certs/
# Version and Backup Files
/version-backup-*
-modules/background-downloader/android/build/*
+
+# ios:unsigned-build Artifacts
+build/
+# but keep EAS custom build configs (the generic build/ rule above matches .eas/build/)
+!.eas/build/
+!.eas/build/**
+.claude/
+.agents/skills/**
+skills-lock.json
+
+# CI-injected Google Play service account key (written at build time)
+google-service-account.json
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..6b3ef5e52
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,290 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Learned Facts Index
+
+IMPORTANT: When encountering issues related to these topics, or when implementing new features that touch these areas, prefer retrieval-led reasoning -- read the relevant fact file in `.claude/learned-facts/` before relying on assumptions.
+
+Navigation:
+- `native-bottom-tabs-userouter-conflict` | useRouter() at provider level causes tab switches; use static router import
+- `introsheet-rendering-location` | IntroSheet in IntroSheetProvider affects native bottom tabs via nav state hooks
+- `intro-modal-trigger-location` | Trigger in Home.tsx, not tabs _layout.tsx
+- `tab-folder-naming` | Use underscore prefix: (_home) not (home)
+
+UI/Headers:
+- `macos-header-buttons-fix` | macOS Catalyst: use RNGH Pressable, not RN TouchableOpacity
+- `header-button-locations` | Defined in _layout.tsx, HeaderBackButton, Chromecast, RoundButton, etc.
+- `stack-screen-header-configuration` | Sub-pages need explicit Stack.Screen with headerTransparent + back button
+
+State/Data:
+- `use-network-aware-query-client-limitations` | Object.create breaks private fields; only for invalidateQueries
+- `mark-as-played-flow` | PlayedStatus→useMarkAsPlayed→playbackManager with optimistic updates
+
+Native Modules:
+- `mpv-tvos-player-exit-freeze` | mpv_terminate_destroy deadlocks main thread; use DispatchQueue.global()
+- `mpv-avfoundation-composite-osd-ordering` | MUST follow vo=avfoundation, before hwdec options
+- `thread-safe-state-for-stop-flags` | Stop flags need synchronous setter (stateQueue.sync not async)
+- `native-swiftui-view-sizing` | Need explicit frame + intrinsicContentSize override in ExpoView
+
+TV Platform:
+- `tv-modals-must-use-navigation-pattern` | Use atom+router.push(), never overlay/absolute modals
+- `tv-grid-layout-pattern` | ScrollView+flexWrap, not FlatList numColumns
+- `tv-horizontal-padding-standard` | TV_HORIZONTAL_PADDING=60, not old TV_SCALE_PADDING=20
+- `streamystats-components-location` | components/home/Streamystats*.tv.tsx, watchlists/[watchlistId].tsx
+- `platform-specific-file-suffix-does-not-work` | .tv.tsx doesn't work; use Platform.isTV conditional rendering
+
+## 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
+ - **IMPORTANT**: When adding a setting to the settings atom, ensure it's toggleable in the settings view (either TV or mobile, depending on the feature scope)
+- `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). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
+- 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):`
+- **Translations**: When adding a translation key to a Text component, ensure the key exists in both `translations/en.json` and `translations/sv.json`. Before adding new keys, check if an existing key already covers the use case.
+
+## 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)
+- **TV Design**: Don't use purple accent colors on TV. Use white for focused states and `expo-blur` (`BlurView`) for backgrounds/overlays.
+- **TV Typography**: Use `TVTypography` from `@/components/tv/TVTypography` for all text on TV. It provides consistent font sizes optimized for TV viewing distance.
+- **TV Button Sizing**: Ensure buttons placed next to each other have the same size for visual consistency.
+- **TV Focus Scale Padding**: Add sufficient padding around focusable items in tables/rows/columns/lists. The focus scale animation (typically 1.05x) will clip against parent containers without proper padding. Use `overflow: "visible"` on containers and add padding to prevent clipping.
+- **TV Modals**: Never use React Native's `Modal` component or overlay/absolute-positioned modals for full-screen modals on TV. Use the navigation-based modal pattern instead. **See [docs/tv-modal-guide.md](docs/tv-modal-guide.md) for detailed documentation.**
+
+### TV Component Rendering Pattern
+
+**IMPORTANT**: The `.tv.tsx` file suffix does NOT work in this project - neither for pages nor components. Metro bundler doesn't resolve platform-specific suffixes. Always use `Platform.isTV` conditional rendering instead.
+
+**Pattern for TV-specific pages and components**:
+```typescript
+// In page file (e.g., app/login.tsx)
+import { Platform } from "react-native";
+import { Login } from "@/components/login/Login";
+import { TVLogin } from "@/components/login/TVLogin";
+
+const LoginPage: React.FC = () => {
+ if (Platform.isTV) {
+ return ;
+ }
+ return ;
+};
+
+export default LoginPage;
+```
+
+- Create separate component files for mobile and TV (e.g., `MyComponent.tsx` and `TVMyComponent.tsx`)
+- Use `Platform.isTV` to conditionally render the appropriate component
+- TV components typically use `TVInput`, `TVServerCard`, and other TV-prefixed components with focus handling
+- **Never use `.tv.tsx` file suffix** - it will not be resolved correctly
+
+### TV Option Selectors and Focus Management
+
+For dropdown/select components, bottom sheets, and overlay focus management on TV, see [docs/tv-modal-guide.md](docs/tv-modal-guide.md).
+
+### TV Focus Flickering Between Zones (Lists with Headers)
+
+When you have a page with multiple focusable zones (e.g., a filter bar above a grid), the TV focus engine can rapidly flicker between elements when navigating between zones. This is a known issue with React Native TV.
+
+**Solutions:**
+
+1. **Use FlatList instead of FlashList for TV** - FlashList has known focus issues on TV platforms. Use regular FlatList with `Platform.isTV` check:
+```typescript
+{Platform.isTV ? (
+
+) : (
+
+)}
+```
+
+2. **Add `removeClippedSubviews={false}`** - Prevents the list from unmounting off-screen items, which can cause focus to "fall through" to other elements.
+
+3. **Only ONE element should have `hasTVPreferredFocus`** - Never have multiple elements competing for initial focus. Choose one element (usually the first filter button or first list item) to have preferred focus:
+```typescript
+// ✅ Good - only first filter button has preferred focus
+
+ // No hasTVPreferredFocus
+
+// ❌ Bad - both compete for focus
+
+
+```
+
+4. **Keep headers/filter bars outside the list** - Instead of using `ListHeaderComponent`, render the filter bar as a separate View above the FlatList:
+```typescript
+
+ {/* Filter bar - separate from list */}
+
+
+
+
+
+ {/* Grid */}
+
+
+```
+
+5. **Avoid multiple scrollable containers** - Don't use ScrollView for the filter bar if you have a FlatList below. Use a simple View instead to prevent focus conflicts between scrollable containers.
+
+**Reference implementation**: See `app/(auth)/(tabs)/(libraries)/[libraryId].tsx` for the TV filter bar + grid pattern.
+
+### TV Focus Guide Navigation (Non-Adjacent Sections)
+
+When you need focus to navigate between sections that aren't geometrically aligned (e.g., left-aligned buttons to a horizontal ScrollView), use `TVFocusGuideView` with the `destinations` prop:
+
+```typescript
+// 1. Track destination with useState (NOT useRef - won't trigger re-renders)
+const [firstCardRef, setFirstCardRef] = useState(null);
+
+// 2. Place invisible focus guide between sections
+{firstCardRef && (
+
+)}
+
+// 3. Target component must use forwardRef
+const MyCard = React.forwardRef(({ ... }, ref) => (
+
+ ...
+
+));
+
+// 4. Pass state setter as callback ref to first item
+{items.map((item, index) => (
+
+))}
+```
+
+**For detailed documentation and bidirectional navigation patterns, see [docs/tv-focus-guide.md](docs/tv-focus-guide.md)**
+
+**Reference implementation**: See `components/ItemContent.tv.tsx` for bidirectional focus navigation between playback options and cast list.
diff --git a/GLOBAL_MODAL_GUIDE.md b/GLOBAL_MODAL_GUIDE.md
index 5426ca722..11a05f105 100644
--- a/GLOBAL_MODAL_GUIDE.md
+++ b/GLOBAL_MODAL_GUIDE.md
@@ -143,14 +143,6 @@ interface ModalOptions {
}
```
-## 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):
diff --git a/README.md b/README.md
index 90349db40..3d4221f40 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,12 @@
+
+
+
+
+
+
**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.**
---
@@ -54,6 +60,11 @@ The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that ho
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) works with Streamyfin
@@ -62,7 +73,7 @@ Chromecast support is currently under development. Video casting is already avai
## 🛣️ Roadmap
-Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
+Check out our [Roadmap](https://github.com/orgs/streamyfin/projects/3/views/1) to see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
## 📥 Download Streamyfin
@@ -70,6 +81,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To
+
### 🧪 Beta Testing
@@ -104,6 +116,7 @@ You can contribute translations directly on our [Crowdin project page](https://c
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
@@ -113,6 +126,10 @@ For the TV version suffix the npm commands with `:tv`.
`npm run prebuild:tv`
`npm run ios:tv or npm run android:tv`
+TV platform integration notes:
+
+- [TV Discovery](./docs/tv-discovery.md)
+
## 👋 Get in Touch with Us
Need assistance or have any questions?
@@ -228,6 +245,7 @@ We also thank all other developers who have contributed to Streamyfin, your effo
A special mention to the following people and projects for their contributions:
+- [@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
diff --git a/app.config.js b/app.config.js
deleted file mode 100644
index 2e37927bc..000000000
--- a/app.config.js
+++ /dev/null
@@ -1,21 +0,0 @@
-module.exports = ({ config }) => {
- if (process.env.EXPO_TV !== "1") {
- config.plugins.push("expo-background-task");
-
- config.plugins.push([
- "react-native-google-cast",
- { useDefaultExpandedMediaControls: true },
- ]);
- }
-
- // Only override googleServicesFile if env var is set
- const androidConfig = {};
- if (process.env.GOOGLE_SERVICES_JSON) {
- androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
- }
-
- return {
- ...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
- ...config,
- };
-};
diff --git a/app.config.ts b/app.config.ts
new file mode 100644
index 000000000..ac2b433ab
--- /dev/null
+++ b/app.config.ts
@@ -0,0 +1,79 @@
+// Registers the tsx require hook so the TypeScript config plugins referenced
+// from app.json ("./plugins/*.ts") can be loaded by Node during config evaluation.
+import "tsx/cjs";
+import { execFileSync } from "node:child_process";
+import type { ConfigContext, ExpoConfig } from "expo/config";
+
+// Build metadata, injected into `extra.build` and read at runtime via
+// expo-constants (see utils/version.ts). Sources in priority order:
+// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
+const git = (args: string[]): string | null => {
+ try {
+ return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
+ .toString()
+ .trim();
+ } catch {
+ return null;
+ }
+};
+
+const buildMeta = {
+ commit:
+ (
+ process.env.EAS_BUILD_GIT_COMMIT_HASH ||
+ process.env.GITHUB_SHA ||
+ process.env.EXPO_PUBLIC_GIT_COMMIT ||
+ git(["rev-parse", "HEAD"]) ||
+ ""
+ ).slice(0, 7) || null,
+ branch:
+ process.env.EAS_BUILD_GIT_BRANCH ||
+ process.env.GITHUB_HEAD_REF ||
+ process.env.GITHUB_REF_NAME ||
+ process.env.EXPO_PUBLIC_GIT_BRANCH ||
+ git(["rev-parse", "--abbrev-ref", "HEAD"]) ||
+ null,
+ profile:
+ process.env.EAS_BUILD_PROFILE ||
+ process.env.EXPO_PUBLIC_BUILD_PROFILE ||
+ null,
+ // GitHub Actions run number (#2098) — lets anyone map a sideloaded CI build back
+ // to its Actions run (artifacts + logs) without Expo access. Null outside CI.
+ runNumber:
+ process.env.GITHUB_RUN_NUMBER ||
+ process.env.EXPO_PUBLIC_GIT_RUN_NUMBER ||
+ null,
+ builtAt: new Date().toISOString(),
+};
+
+export default ({ config }: ConfigContext): ExpoConfig => {
+ if (process.env.EXPO_TV !== "1") {
+ config.plugins?.push("expo-background-task");
+
+ config.plugins?.push([
+ "react-native-google-cast",
+ { useDefaultExpandedMediaControls: true },
+ ]);
+
+ config.plugins?.push([
+ "expo-camera",
+ {
+ cameraPermission:
+ "Allow Streamyfin to access the camera to scan QR codes for TV login.",
+ },
+ ]);
+ }
+
+ // Only override googleServicesFile if env var is set
+ const androidConfig: { googleServicesFile?: string } = {};
+ if (process.env.GOOGLE_SERVICES_JSON) {
+ androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
+ }
+
+ config.extra = { ...config.extra, build: buildMeta };
+
+ return {
+ ...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
+ ...config,
+ } as ExpoConfig;
+};
diff --git a/app.json b/app.json
index 5d4e9f4a0..42efc22c9 100644
--- a/app.json
+++ b/app.json
@@ -2,13 +2,11 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
- "version": "0.48.0",
+ "version": "0.54.1",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
"userInterfaceStyle": "dark",
- "jsEngine": "hermes",
- "newArchEnabled": true,
"assetBundlePatterns": ["**/*"],
"ios": {
"requireFullScreen": true,
@@ -17,24 +15,27 @@
"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
},
"UISupportsTrueScreenSizeOnMac": true,
"UIFileSharingEnabled": true,
- "LSSupportsOpeningDocumentsInPlace": true
+ "LSSupportsOpeningDocumentsInPlace": true,
+ "AVInitialRouteSharingPolicy": "LongFormAudio"
},
"config": {
"usesNonExemptEncryption": false
},
"supportsTablet": true,
+ "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": 85,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",
@@ -44,42 +45,49 @@
"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"
},
"plugins": [
- "@react-native-tvos/config-tv",
- "expo-router",
- "expo-font",
[
- "react-native-video",
+ "@react-native-tvos/config-tv",
{
- "enableNotificationControls": true,
- "enableBackgroundAudio": true,
- "androidExtensions": {
- "useExoplayerRtsp": false,
- "useExoplayerSmoothStreaming": false,
- "useExoplayerHls": true,
- "useExoplayerDash": false
+ "appleTVImages": {
+ "icon": "./assets/images/icon-tvos.png",
+ "iconSmall": "./assets/images/icon-tvos-small.png",
+ "iconSmall2x": "./assets/images/icon-tvos-small-2x.png",
+ "topShelf": "./assets/images/icon-tvos-topshelf.png",
+ "topShelf2x": "./assets/images/icon-tvos-topshelf-2x.png",
+ "topShelfWide": "./assets/images/icon-tvos-topshelf-wide.png",
+ "topShelfWide2x": "./assets/images/icon-tvos-topshelf-wide-2x.png"
+ },
+ "infoPlist": {
+ "UIAppSupportsHDR": true
}
}
],
+ "expo-router",
+ "expo-font",
+ "./plugins/withExcludeMedia3Dash.ts",
+ "./plugins/withTVUserManagement.ts",
[
"expo-build-properties",
{
"ios": {
- "deploymentTarget": "15.6",
- "useFrameworks": "static"
+ "deploymentTarget": "16.4",
+ "useFrameworks": "static",
+ "forceStaticLinking": ["ExpoUI", "GlassEffectView", "GlassPoster"]
},
"android": {
- "buildArchs": ["arm64-v8a", "x86_64"],
- "compileSdkVersion": 35,
+ "buildArchs": ["arm64-v8a", "x86_64", "armeabi-v7a"],
+ "compileSdkVersion": 36,
"targetSdkVersion": 35,
"buildToolsVersion": "35.0.0",
- "kotlinVersion": "2.0.21",
- "minSdkVersion": 24,
+ "kotlinVersion": "2.1.20",
+ "minSdkVersion": 26,
"usesCleartextTraffic": true,
"packagingOptions": {
"jniLibs": {
@@ -97,14 +105,11 @@
"initialOrientation": "DEFAULT"
}
],
- [
- "expo-sensors",
- {
- "motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
- }
- ],
"expo-localization",
"expo-asset",
+ "expo-audio",
+ "expo-image",
+ "expo-sharing",
[
"react-native-edge-to-edge",
{
@@ -129,11 +134,22 @@
}
],
"expo-web-browser",
- ["./plugins/with-runtime-framework-headers.js"],
- ["./plugins/withChangeNativeAndroidTextToWhite.js"],
- ["./plugins/withAndroidManifest.js"],
- ["./plugins/withTrustLocalCerts.js"],
- ["./plugins/withGradleProperties.js"]
+ ["./plugins/with-runtime-framework-headers.ts"],
+ ["./plugins/withChangeNativeAndroidTextToWhite.ts"],
+ ["./plugins/withAndroidAlertColors.ts"],
+ ["./plugins/withAndroidManifest.ts"],
+ ["./plugins/withTrustLocalCerts.ts"],
+ ["./plugins/withGradleProperties.ts"],
+ ["./plugins/withTVOSAppIcon.ts"],
+ ["./plugins/withTVOSTopShelf.ts"],
+ ["./plugins/withTVXcodeEnv.ts"],
+ [
+ "./plugins/withGitPod.ts",
+ {
+ "podName": "MPVKit",
+ "podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
+ }
+ ]
],
"experiments": {
"typedRoutes": true
diff --git a/app/(auth)/(tabs)/(custom-links)/_layout.tsx b/app/(auth)/(tabs)/(custom-links)/_layout.tsx
index 3b8a58e20..67648e05a 100644
--- a/app/(auth)/(tabs)/(custom-links)/_layout.tsx
+++ b/app/(auth)/(tabs)/(custom-links)/_layout.tsx
@@ -9,7 +9,7 @@ export default function CustomMenuLayout() {
([]);
@@ -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;
}
diff --git a/app/(auth)/(tabs)/(favorites)/index.tsx b/app/(auth)/(tabs)/(favorites)/index.tsx
index 198695a8c..10fffe9d0 100644
--- a/app/(auth)/(tabs)/(favorites)/index.tsx
+++ b/app/(auth)/(tabs)/(favorites)/index.tsx
@@ -2,9 +2,10 @@ import { useCallback, useState } from "react";
import { Platform, RefreshControl, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Favorites } from "@/components/home/Favorites";
+import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
-export default function favorites() {
+export default function FavoritesPage() {
const invalidateCache = useInvalidatePlaybackProgressCache();
const [loading, setLoading] = useState(false);
@@ -15,6 +16,10 @@ export default function favorites() {
}, []);
const insets = useSafeAreaInsets();
+ if (Platform.isTV) {
+ return ;
+ }
+
return (
();
+ 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 => {
+ 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 }) => (
+
+
+
+
+
+
+ ),
+ [nrOfCols],
+ );
+
+ const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
+
+ const handleEndReached = useCallback(() => {
+ if (hasNextPage) {
+ fetchNextPage();
+ }
+ }, [fetchNextPage, hasNextPage]);
+
+ return (
+ <>
+
+ {!itemType ? (
+
+ {t("favorites.noData")}
+
+ ) : isLoading ? (
+
+
+
+ ) : (
+ (
+
+ )}
+ ListEmptyComponent={
+
+
+ {t("home.no_items")}
+
+
+ }
+ ListFooterComponent={
+ isFetching ? (
+
+
+
+ ) : null
+ }
+ />
+ )}
+ >
+ );
+}
diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx
index bfbebed54..de5545d62 100644
--- a/app/(auth)/(tabs)/(home)/_layout.tsx
+++ b/app/(auth)/(tabs)/(home)/_layout.tsx
@@ -1,12 +1,15 @@
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");
import { useAtom } from "jotai";
+import { HeaderBackButton } from "@/components/common/HeaderBackButton";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { userAtom } from "@/providers/JellyfinProvider";
@@ -41,164 +44,125 @@ export default function IndexLayout() {
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
- }}
- />
- (
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
(
- _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
-
+
),
}}
/>
+ ,
+ }}
+ />
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
+ }}
+ />
+ ,
}}
/>
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
(
- _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
-
+
),
}}
/>
@@ -206,85 +170,77 @@ export default function IndexLayout() {
name='settings/plugins/marlin-search/page'
options={{
title: "Marlin Search",
+ headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
- headerLeft: () => (
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
+ }}
+ />
+ ,
+ }}
+ />
+ ,
}}
/>
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
(
- _router.back()} className='pl-0.5'>
-
-
- ),
- presentation: "modal",
+ title: t("home.settings.network.title"),
+ headerShown: !Platform.isTV,
+ headerBlurEffect: "none",
+ headerTransparent: Platform.OS === "ios",
+ headerShadowVisible: false,
+ headerLeft: () => ,
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
@@ -294,12 +250,8 @@ export default function IndexLayout() {
name='collections/[collectionId]'
options={{
title: "",
- headerLeft: () => (
- _router.back()} className='pl-0.5'>
-
-
- ),
- headerShown: true,
+ headerLeft: () => ,
+ headerShown: !Platform.isTV,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
@@ -313,13 +265,13 @@ const SettingsButton = () => {
const router = useRouter();
return (
- {
router.push("/(auth)/settings");
}}
>
-
+
);
};
@@ -328,7 +280,7 @@ const SessionsButton = () => {
const { sessions = [] } = useSessions({} as useSessionsProps);
return (
- {
router.push("/(auth)/sessions");
}}
@@ -339,6 +291,6 @@ const SessionsButton = () => {
color={sessions.length === 0 ? "white" : "#9333ea"}
size={28}
/>
-
+
);
};
diff --git a/app/(auth)/(tabs)/(home)/companion-login.tsx b/app/(auth)/(tabs)/(home)/companion-login.tsx
new file mode 100644
index 000000000..68dd2254c
--- /dev/null
+++ b/app/(auth)/(tabs)/(home)/companion-login.tsx
@@ -0,0 +1,7 @@
+import { Platform } from "react-native";
+import { CompanionLoginScreen } from "@/components/companion/CompanionLoginScreen";
+
+export default function CompanionLoginPage() {
+ if (Platform.isTV) return null;
+ return ;
+}
diff --git a/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx b/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx
deleted file mode 100644
index e870f5c0c..000000000
--- a/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx
+++ /dev/null
@@ -1,181 +0,0 @@
-import { Ionicons } from "@expo/vector-icons";
-import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { FlashList } from "@shopify/flash-list";
-import { router, useLocalSearchParams, useNavigation } from "expo-router";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { Alert, Platform, TouchableOpacity, View } from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { Text } from "@/components/common/Text";
-import { EpisodeCard } from "@/components/downloads/EpisodeCard";
-import {
- SeasonDropdown,
- type SeasonIndexState,
-} from "@/components/series/SeasonDropdown";
-import { useDownload } from "@/providers/DownloadProvider";
-import { storage } from "@/utils/mmkv";
-
-export default function page() {
- const navigation = useNavigation();
- const local = useLocalSearchParams();
- const { seriesId, episodeSeasonIndex } = local as {
- seriesId: string;
- episodeSeasonIndex: number | string | undefined;
- };
-
- const [seasonIndexState, setSeasonIndexState] = useState(
- {},
- );
- const { downloadedItems, deleteItems } = useDownload();
- const insets = useSafeAreaInsets();
-
- const series = useMemo(() => {
- try {
- return (
- downloadedItems
- ?.filter((f) => f.item.SeriesId === seriesId)
- ?.sort(
- (a, b) =>
- (a.item.ParentIndexNumber ?? 0) - (b.item.ParentIndexNumber ?? 0),
- ) || []
- );
- } catch {
- return [];
- }
- }, [downloadedItems, seriesId]);
-
- // Group episodes by season in a single pass
- const seasonGroups = useMemo(() => {
- const groups: Record = {};
-
- series.forEach((episode) => {
- const seasonNumber = episode.item.ParentIndexNumber;
- if (seasonNumber !== undefined && seasonNumber !== null) {
- if (!groups[seasonNumber]) {
- groups[seasonNumber] = [];
- }
- groups[seasonNumber].push(episode.item);
- }
- });
-
- // Sort episodes within each season
- Object.values(groups).forEach((episodes) => {
- episodes.sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0));
- });
-
- return groups;
- }, [series]);
-
- // Get unique seasons (just the season numbers, sorted)
- const uniqueSeasons = useMemo(() => {
- const seasonNumbers = Object.keys(seasonGroups)
- .map(Number)
- .sort((a, b) => a - b);
- return seasonNumbers.map((seasonNum) => seasonGroups[seasonNum][0]); // First episode of each season
- }, [seasonGroups]);
-
- const seasonIndex =
- seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ??
- episodeSeasonIndex ??
- series?.[0]?.item?.ParentIndexNumber ??
- "";
-
- const groupBySeason = useMemo(() => {
- return seasonGroups[Number(seasonIndex)] ?? [];
- }, [seasonGroups, seasonIndex]);
-
- const initialSeasonIndex = useMemo(
- () =>
- groupBySeason?.[0]?.ParentIndexNumber ??
- series?.[0]?.item?.ParentIndexNumber,
- [groupBySeason, series],
- );
-
- useEffect(() => {
- if (series.length > 0) {
- navigation.setOptions({
- title: series[0].item.SeriesName,
- });
- } else {
- storage.remove(seriesId);
- router.back();
- }
- }, [series]);
-
- const deleteSeries = useCallback(() => {
- Alert.alert(
- "Delete season",
- "Are you sure you want to delete the entire season?",
- [
- {
- text: "Cancel",
- style: "cancel",
- },
- {
- text: "Delete",
- onPress: () =>
- deleteItems(
- groupBySeason
- .map((item) => item.Id)
- .filter((id) => id !== undefined),
- ),
- style: "destructive",
- },
- ],
- );
- }, [groupBySeason, deleteItems]);
-
- const ListHeaderComponent = useCallback(() => {
- if (series.length === 0) return null;
-
- return (
-
- {
- setSeasonIndexState((prev) => ({
- ...prev,
- [series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
- }));
- }}
- />
-
- {groupBySeason.length}
-
-
-
-
-
-
-
- );
- }, [
- series,
- uniqueSeasons,
- seasonIndexState,
- initialSeasonIndex,
- groupBySeason,
- deleteSeries,
- ]);
-
- return (
-
- }
- keyExtractor={(item, index) => item.Id ?? `episode-${index}`}
- ListHeaderComponent={ListHeaderComponent}
- contentInsetAdjustmentBehavior='automatic'
- contentContainerStyle={{
- paddingHorizontal: 16,
- paddingLeft: insets.left + 16,
- paddingRight: insets.right + 16,
- paddingTop: Platform.OS === "android" ? 10 : 8,
- }}
- />
-
- );
-}
diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx
index c71bebd85..68340e6bf 100644
--- a/app/(auth)/(tabs)/(home)/downloads/index.tsx
+++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx
@@ -1,29 +1,32 @@
-import { BottomSheetModal } from "@gorhom/bottom-sheet";
-import { useNavigation, useRouter } from "expo-router";
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} 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,
- Platform,
- 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() {
+export default function DownloadsPage() {
const navigation = useNavigation();
const { t } = useTranslation();
const [_queue, _setQueue] = useAtom(queueAtom);
@@ -103,12 +106,12 @@ export default function page() {
useEffect(() => {
navigation.setOptions({
headerRight: () => (
- bottomSheetModalRef.current?.present()}
className='px-2'
>
f.item) || []} />
-
+
),
});
}, [downloadedFiles]);
@@ -119,7 +122,7 @@ export default function page() {
}
}, [showMigration]);
- const _deleteMovies = () =>
+ const deleteMovies = () =>
deleteFileByType("Movie")
.then(() =>
toast.success(
@@ -130,18 +133,18 @@ 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(
- t("home.downloads.toasts.deleted_all_tvseries_successfully"),
+ t("home.downloads.toasts.deleted_all_series_successfully"),
),
)
.catch((reason) => {
writeToLog("ERROR", reason);
- toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
+ toast.error(t("home.downloads.toasts.failed_to_delete_all_series"));
});
- const _deleteOtherMedia = () =>
+ const deleteOtherMedia = () =>
Promise.all(
otherMedia
.filter((item) => item.item.Type)
@@ -165,146 +168,139 @@ export default function page() {
),
);
+ const deleteAllMedia = async () =>
+ await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
+
return (
-
-
-
- {/* Queue card - hidden */}
- {/*
+
+
+
+
+
+
+
+ {movies.length > 0 && (
+
+
- {t("home.downloads.queue")}
+ {t("home.downloads.movies")}
-
- {t("home.downloads.queue_hint")}
-
-
- {queue.map((q, index) => (
-
- 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}
- >
-
- {q.item.Name}
-
- {q.item.Type}
-
-
- {
- removeProcess(q.id);
- setQueue((prev) => {
- if (!prev) return [];
- return [...prev.filter((i) => i.id !== q.id)];
- });
- }}
- >
-
-
-
+
+ {movies?.length}
+
+
+
+
+ {movies?.map((item) => (
+
+
+
))}
-
- {queue.length === 0 && (
-
- {t("home.downloads.no_items_in_queue")}
-
- )}
- */}
-
-
-
-
- {movies.length > 0 && (
-
-
-
- {t("home.downloads.movies")}
-
-
- {movies?.length}
-
+
-
-
- {movies?.map((item) => (
-
-
-
- ))}
-
-
-
- )}
- {groupedBySeries.length > 0 && (
-
-
-
- {t("home.downloads.tvseries")}
-
-
-
- {groupedBySeries?.length}
+ )}
+ {groupedBySeries.length > 0 && (
+
+
+
+ {t("home.downloads.series")}
+
+
+ {groupedBySeries?.length}
+
+
-
-
-
- {groupedBySeries?.map((items) => (
-
- i.item)}
+
+
+ {groupedBySeries?.map((items) => (
+
-
- ))}
-
-
-
- )}
-
- {otherMedia.length > 0 && (
-
-
-
- {t("home.downloads.other_media")}
-
-
- {otherMedia?.length}
-
+ >
+ i.item)}
+ key={items[0].item.SeriesId}
+ />
+
+ ))}
+
+
-
-
- {otherMedia?.map((item) => (
-
-
-
- ))}
+ )}
+
+ {otherMedia.length > 0 && (
+
+
+
+ {t("home.downloads.other_media")}
+
+
+
+ {otherMedia?.length}
+
+
-
-
+
+
+ {otherMedia?.map((item) => (
+
+
+
+ ))}
+
+
+
+ )}
+ {downloadedFiles?.length === 0 && (
+
+
+ {t("home.downloads.no_downloaded_items")}
+
+
+ )}
+
+
+ (
+
)}
- {downloadedFiles?.length === 0 && (
-
-
- {t("home.downloads.no_downloaded_items")}
-
+ >
+
+
+
+
+ {otherMedia.length > 0 && (
+
+ )}
+
- )}
-
-
+
+
+
);
}
diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx
index ad951c36f..9586d465d 100644
--- a/app/(auth)/(tabs)/(home)/index.tsx
+++ b/app/(auth)/(tabs)/(home)/index.tsx
@@ -1,15 +1,6 @@
-import { useSettings } from "@/utils/atoms/settings";
import { Home } from "../../../../components/home/Home";
-import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
const Index = () => {
- const { settings } = useSettings();
- const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
-
- if (showLargeHomeCarousel) {
- return ;
- }
-
return ;
};
diff --git a/app/(auth)/(tabs)/(home)/intro/page.tsx b/app/(auth)/(tabs)/(home)/intro/page.tsx
deleted file mode 100644
index 572a4980f..000000000
--- a/app/(auth)/(tabs)/(home)/intro/page.tsx
+++ /dev/null
@@ -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 (
-
-
-
- {t("home.intro.welcome_to_streamyfin")}
-
-
- {t("home.intro.a_free_and_open_source_client_for_jellyfin")}
-
-
-
-
-
- {t("home.intro.features_title")}
-
- {t("home.intro.features_description")}
-
-
-
- Jellyseerr
-
- {t("home.intro.jellyseerr_feature_description")}
-
-
-
- {!Platform.isTV && (
- <>
-
-
-
-
-
-
- {t("home.intro.downloads_feature_title")}
-
-
- {t("home.intro.downloads_feature_description")}
-
-
-
-
-
-
-
-
- Chromecast
-
- {t("home.intro.chromecast_feature_description")}
-
-
-
- >
- )}
-
-
-
-
-
-
- {t("home.intro.centralised_settings_plugin_title")}
-
-
-
- {t("home.intro.centralised_settings_plugin_description")}{" "}
-
- {
- Linking.openURL(
- "https://github.com/streamyfin/jellyfin-plugin-streamyfin",
- );
- }}
- >
-
- {t("home.intro.read_more")}
-
-
-
-
-
-
-
-
- {
- router.back();
- router.push("/settings");
- }}
- className='mt-4'
- >
-
- {t("home.intro.go_to_settings_button")}
-
-
-
-
- );
-}
diff --git a/app/(auth)/(tabs)/(home)/sessions/index.tsx b/app/(auth)/(tabs)/(home)/sessions/index.tsx
index 0ed8fc940..d8a3590d5 100644
--- a/app/(auth)/(tabs)/(home)/sessions/index.tsx
+++ b/app/(auth)/(tabs)/(home)/sessions/index.tsx
@@ -23,7 +23,7 @@ import { formatBitrate } from "@/utils/bitrate";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { formatTimeString } from "@/utils/time";
-export default function page() {
+export default function SessionsPage() {
const { sessions, isLoading } = useSessions({} as useSessionsProps);
const { t } = useTranslation();
@@ -72,7 +72,7 @@ const SessionCard = ({ session }: SessionCardProps) => {
};
const getProgressPercentage = () => {
- if (!session.NowPlayingItem || !session.NowPlayingItem.RunTimeTicks) {
+ if (!session.NowPlayingItem?.RunTimeTicks) {
return 0;
}
diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx
index 4bd917d65..69b980d3e 100644
--- a/app/(auth)/(tabs)/(home)/settings.tsx
+++ b/app/(auth)/(tabs)/(home)/settings.tsx
@@ -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";
@@ -11,9 +11,14 @@ import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { QuickConnect } from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings";
import { UserInfo } from "@/components/settings/UserInfo";
+import useRouter from "@/hooks/useAppRouter";
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
-export default function settings() {
+// TV-specific settings component
+const SettingsTV = Platform.isTV ? require("./settings.tv").default : null;
+
+// Mobile settings component
+function SettingsMobile() {
const router = useRouter();
const insets = useSafeAreaInsets();
const [_user] = useAtom(userAtom);
@@ -54,6 +59,20 @@ export default function settings() {
+ {Platform.OS !== "ios" && (
+
+
+
+ router.push("/(auth)/(tabs)/(home)/companion-login")
+ }
+ title={t("pairing.pair_with_phone")}
+ textColor='blue'
+ />
+
+
+ )}
+
@@ -70,6 +89,11 @@ export default function settings() {
showArrow
title={t("home.settings.audio_subtitles.title")}
/>
+ router.push("/settings/music/page")}
+ showArrow
+ title={t("home.settings.music.title")}
+ />
router.push("/settings/appearance/page")}
showArrow
@@ -85,6 +109,11 @@ export default function settings() {
showArrow
title={t("home.settings.intro.title")}
/>
+ router.push("/settings/network/page")}
+ showArrow
+ title={t("home.settings.network.title")}
+ />
router.push("/settings/logs/page")}
showArrow
@@ -93,8 +122,17 @@ export default function settings() {
- {!Platform.isTV && }
+
);
}
+
+export default function settings() {
+ // Use TV settings component on TV platforms
+ if (Platform.isTV && SettingsTV) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx
new file mode 100644
index 000000000..a9a2e2fbb
--- /dev/null
+++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx
@@ -0,0 +1,943 @@
+import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
+import { useQueryClient } from "@tanstack/react-query";
+import { Directory, Paths } from "expo-file-system";
+import { Image } from "expo-image";
+import { useAtom } from "jotai";
+import { useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Alert, ScrollView, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
+import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
+import type { TVOptionItem } from "@/components/tv";
+import {
+ TVLogoutButton,
+ TVSectionHeader,
+ TVSettingsOptionButton,
+ TVSettingsRow,
+ TVSettingsStepper,
+ TVSettingsTextInput,
+ TVSettingsToggle,
+} from "@/components/tv";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { useTVOptionModal } from "@/hooks/useTVOptionModal";
+import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
+import { APP_LANGUAGES } from "@/i18n";
+import { clearCache as clearAudioCache } from "@/providers/AudioStorage";
+import {
+ apiAtom,
+ cacheVersionAtom,
+ useJellyfin,
+ userAtom,
+} from "@/providers/JellyfinProvider";
+import {
+ AudioTranscodeMode,
+ InactivityTimeout,
+ type MpvCacheMode,
+ type MpvVoDriver,
+ TVTypographyScale,
+ useSettings,
+} from "@/utils/atoms/settings";
+import { storage } from "@/utils/mmkv";
+import {
+ getPreviousServers,
+ type SavedServer,
+ type SavedServerAccount,
+} from "@/utils/secureCredentials";
+import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
+
+export default function SettingsTV() {
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+ const { settings, updateSettings } = useSettings();
+ const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
+ const [user] = useAtom(userAtom);
+ const [api] = useAtom(apiAtom);
+ const [, setCacheVersion] = useAtom(cacheVersionAtom);
+ const { showOptions } = useTVOptionModal();
+ const { showUserSwitchModal } = useTVUserSwitchModal();
+ const typography = useScaledTVTypography();
+ const queryClient = useQueryClient();
+
+ // Local state for OpenSubtitles API key (only commit on blur)
+ const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
+ settings.openSubtitlesApiKey || "",
+ );
+
+ // PIN/Password modal state for user switching
+ const [pinModalVisible, setPinModalVisible] = useState(false);
+ const [passwordModalVisible, setPasswordModalVisible] = useState(false);
+ const [selectedServer, setSelectedServer] = useState(
+ null,
+ );
+ const [selectedAccount, setSelectedAccount] =
+ useState(null);
+
+ // Track if any modal is open to disable background focus
+ const isAnyModalOpen = pinModalVisible || passwordModalVisible;
+
+ // Get current server and other accounts
+ const currentServer = useMemo(() => {
+ if (!api?.basePath) return null;
+ const servers = getPreviousServers();
+ return servers.find((s) => s.address === api.basePath) || null;
+ }, [api?.basePath]);
+
+ const otherAccounts = useMemo(() => {
+ if (!currentServer || !user?.Id) return [];
+ return currentServer.accounts.filter(
+ (account) => account.userId !== user.Id,
+ );
+ }, [currentServer, user?.Id]);
+
+ const hasOtherAccounts = otherAccounts.length > 0;
+
+ // Handle account selection from modal
+ const handleAccountSelect = async (account: SavedServerAccount) => {
+ if (!currentServer) return;
+
+ if (account.securityType === "none") {
+ // Direct login with saved credential
+ try {
+ await loginWithSavedCredential(currentServer.address, account.userId);
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : t("server.session_expired");
+ const isSessionExpired = errorMessage.includes(
+ t("server.session_expired"),
+ );
+ Alert.alert(
+ isSessionExpired
+ ? t("server.session_expired")
+ : t("login.connection_failed"),
+ isSessionExpired ? t("server.please_login_again") : errorMessage,
+ );
+ }
+ } else if (account.securityType === "pin") {
+ // Show PIN modal
+ setSelectedServer(currentServer);
+ setSelectedAccount(account);
+ setPinModalVisible(true);
+ } else if (account.securityType === "password") {
+ // Show password modal
+ setSelectedServer(currentServer);
+ setSelectedAccount(account);
+ setPasswordModalVisible(true);
+ }
+ };
+
+ // Handle successful PIN entry
+ const handlePinSuccess = async () => {
+ setPinModalVisible(false);
+ if (selectedServer && selectedAccount) {
+ try {
+ await loginWithSavedCredential(
+ selectedServer.address,
+ selectedAccount.userId,
+ );
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : t("server.session_expired");
+ const isSessionExpired = errorMessage.includes(
+ t("server.session_expired"),
+ );
+ Alert.alert(
+ isSessionExpired
+ ? t("server.session_expired")
+ : t("login.connection_failed"),
+ isSessionExpired ? t("server.please_login_again") : errorMessage,
+ );
+ }
+ }
+ setSelectedServer(null);
+ setSelectedAccount(null);
+ };
+
+ // Handle password submission
+ const handlePasswordSubmit = async (password: string) => {
+ if (selectedServer && selectedAccount) {
+ await loginWithPassword(
+ selectedServer.address,
+ selectedAccount.username,
+ password,
+ );
+ }
+ setPasswordModalVisible(false);
+ setSelectedServer(null);
+ setSelectedAccount(null);
+ };
+
+ // Handle switch user button press
+ const handleSwitchUser = () => {
+ if (!currentServer || !user?.Id) return;
+ showUserSwitchModal(currentServer, user.Id, {
+ onAccountSelect: handleAccountSelect,
+ });
+ };
+
+ // Handle clearing all cache in the entire app
+ const handleClearCache = async () => {
+ Alert.alert(
+ t("home.settings.storage.clear_all_cache_confirm"),
+ t("home.settings.storage.clear_all_cache_confirm_desc"),
+ [
+ {
+ text: t("common.cancel"),
+ style: "cancel",
+ },
+ {
+ text: t("common.ok"),
+ onPress: async () => {
+ try {
+ // 1. Clear React Query Cache (memory & MMKV)
+ storage.remove("REACT_QUERY_OFFLINE_CACHE");
+ await queryClient.resetQueries();
+
+ // 2. Clear expo-image cache (memory & disk)
+ await Image.clearDiskCache();
+ Image.clearMemoryCache();
+
+ // 3. Clear AudioStorage (music) cache
+ await clearAudioCache();
+
+ // 4. Clear TopShelf cache
+ clearTopShelfCacheSafely();
+
+ // 5. Clear Subtitle Cache
+ storage.remove("downloadedSubtitles.json");
+ const subtitlesDir = new Directory(
+ Paths.cache,
+ "streamyfin-subtitles",
+ );
+ if (subtitlesDir.exists) {
+ await subtitlesDir.delete();
+ }
+
+ // 6. Clear MMKV caches like extracted image colors and other non-essential storage keys
+ const keysToKeep = [
+ "settings",
+ "serverUrl",
+ "token",
+ "user",
+ "deviceId",
+ "previousServers",
+ "hasAskedForNotificationPermission",
+ "hasShownIntro",
+ "multiAccountMigrated",
+ "selectedTVServer",
+ "downloads.v2.json",
+ ];
+ const allKeys = storage.getAllKeys();
+ for (const key of allKeys) {
+ if (!keysToKeep.includes(key)) {
+ storage.remove(key);
+ }
+ }
+
+ // 7. Increment cache version to force remount of components
+ setCacheVersion((v) => v + 1);
+ } catch (error) {
+ console.error("Failed to clear cache:", error);
+ Alert.alert(
+ t("home.settings.toasts.error_deleting_files"),
+ t("home.settings.storage.clear_all_cache_error_desc"),
+ );
+ }
+ },
+ },
+ ],
+ );
+ };
+
+ const currentAudioTranscode =
+ settings.audioTranscodeMode || AudioTranscodeMode.Auto;
+ const currentSubtitleMode =
+ settings.subtitleMode || SubtitlePlaybackMode.Default;
+ const currentAlignX = settings.mpvSubtitleAlignX ?? "center";
+ const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
+ const currentTypographyScale =
+ settings.tvTypographyScale || TVTypographyScale.Default;
+ const currentCacheMode = settings.mpvCacheEnabled ?? "auto";
+ const currentVoDriver = settings.mpvVoDriver ?? "gpu-next";
+ const currentLanguage = settings.preferedLanguage;
+
+ // Audio transcoding options
+ const audioTranscodeModeOptions: TVOptionItem[] = useMemo(
+ () => [
+ {
+ label: t("home.settings.audio.transcode_mode.auto"),
+ value: AudioTranscodeMode.Auto,
+ selected: currentAudioTranscode === AudioTranscodeMode.Auto,
+ },
+ {
+ label: t("home.settings.audio.transcode_mode.stereo"),
+ value: AudioTranscodeMode.ForceStereo,
+ selected: currentAudioTranscode === AudioTranscodeMode.ForceStereo,
+ },
+ {
+ label: t("home.settings.audio.transcode_mode.5_1"),
+ value: AudioTranscodeMode.Allow51,
+ selected: currentAudioTranscode === AudioTranscodeMode.Allow51,
+ },
+ {
+ label: t("home.settings.audio.transcode_mode.passthrough"),
+ value: AudioTranscodeMode.AllowAll,
+ selected: currentAudioTranscode === AudioTranscodeMode.AllowAll,
+ },
+ ],
+ [t, currentAudioTranscode],
+ );
+
+ // Subtitle mode options
+ const subtitleModeOptions: TVOptionItem[] = useMemo(
+ () => [
+ {
+ label: t("home.settings.subtitles.modes.Default"),
+ value: SubtitlePlaybackMode.Default,
+ selected: currentSubtitleMode === SubtitlePlaybackMode.Default,
+ },
+ {
+ label: t("home.settings.subtitles.modes.Smart"),
+ value: SubtitlePlaybackMode.Smart,
+ selected: currentSubtitleMode === SubtitlePlaybackMode.Smart,
+ },
+ {
+ label: t("home.settings.subtitles.modes.OnlyForced"),
+ value: SubtitlePlaybackMode.OnlyForced,
+ selected: currentSubtitleMode === SubtitlePlaybackMode.OnlyForced,
+ },
+ {
+ label: t("home.settings.subtitles.modes.Always"),
+ value: SubtitlePlaybackMode.Always,
+ selected: currentSubtitleMode === SubtitlePlaybackMode.Always,
+ },
+ {
+ label: t("home.settings.subtitles.modes.None"),
+ value: SubtitlePlaybackMode.None,
+ selected: currentSubtitleMode === SubtitlePlaybackMode.None,
+ },
+ ],
+ [t, currentSubtitleMode],
+ );
+
+ // MPV alignment options
+ const alignXOptions: TVOptionItem[] = useMemo(
+ () => [
+ { label: "Left", value: "left", selected: currentAlignX === "left" },
+ {
+ label: "Center",
+ value: "center",
+ selected: currentAlignX === "center",
+ },
+ { label: "Right", value: "right", selected: currentAlignX === "right" },
+ ],
+ [currentAlignX],
+ );
+
+ const alignYOptions: TVOptionItem[] = useMemo(
+ () => [
+ { label: "Top", value: "top", selected: currentAlignY === "top" },
+ {
+ label: "Center",
+ value: "center",
+ selected: currentAlignY === "center",
+ },
+ {
+ label: "Bottom",
+ value: "bottom",
+ selected: currentAlignY === "bottom",
+ },
+ ],
+ [currentAlignY],
+ );
+
+ // Cache mode options
+ const cacheModeOptions: TVOptionItem[] = useMemo(
+ () => [
+ {
+ label: t("home.settings.buffer.cache_auto"),
+ value: "auto",
+ selected: currentCacheMode === "auto",
+ },
+ {
+ label: t("home.settings.buffer.cache_yes"),
+ value: "yes",
+ selected: currentCacheMode === "yes",
+ },
+ {
+ label: t("home.settings.buffer.cache_no"),
+ value: "no",
+ selected: currentCacheMode === "no",
+ },
+ ],
+ [t, currentCacheMode],
+ );
+
+ // VO driver options
+ const voDriverOptions: TVOptionItem[] = useMemo(
+ () => [
+ {
+ label: t("home.settings.vo_driver.gpu_next"),
+ value: "gpu-next",
+ selected: currentVoDriver === "gpu-next",
+ },
+ {
+ label: t("home.settings.vo_driver.gpu"),
+ value: "gpu",
+ selected: currentVoDriver === "gpu",
+ },
+ ],
+ [t, currentVoDriver],
+ );
+
+ // Typography scale options
+ const typographyScaleOptions: TVOptionItem[] = useMemo(
+ () => [
+ {
+ label: t("home.settings.appearance.display_size_small"),
+ value: TVTypographyScale.Small,
+ selected: currentTypographyScale === TVTypographyScale.Small,
+ },
+ {
+ label: t("home.settings.appearance.display_size_default"),
+ value: TVTypographyScale.Default,
+ selected: currentTypographyScale === TVTypographyScale.Default,
+ },
+ {
+ label: t("home.settings.appearance.display_size_large"),
+ value: TVTypographyScale.Large,
+ selected: currentTypographyScale === TVTypographyScale.Large,
+ },
+ {
+ label: t("home.settings.appearance.display_size_extra_large"),
+ value: TVTypographyScale.ExtraLarge,
+ selected: currentTypographyScale === TVTypographyScale.ExtraLarge,
+ },
+ ],
+ [t, currentTypographyScale],
+ );
+
+ // Language options
+ const languageOptions: TVOptionItem[] = useMemo(
+ () => [
+ {
+ label: t("home.settings.languages.system"),
+ value: undefined,
+ selected: !currentLanguage,
+ },
+ ...APP_LANGUAGES.map((lang) => ({
+ label: lang.label,
+ value: lang.value,
+ selected: currentLanguage === lang.value,
+ })),
+ ],
+ [t, currentLanguage],
+ );
+
+ // Inactivity timeout options (TV security feature)
+ const currentInactivityTimeout =
+ settings.inactivityTimeout ?? InactivityTimeout.Disabled;
+
+ const inactivityTimeoutOptions: TVOptionItem[] = useMemo(
+ () => [
+ {
+ label: t("home.settings.security.inactivity_timeout.disabled"),
+ value: InactivityTimeout.Disabled,
+ selected: currentInactivityTimeout === InactivityTimeout.Disabled,
+ },
+ {
+ label: t("home.settings.security.inactivity_timeout.1_minute"),
+ value: InactivityTimeout.OneMinute,
+ selected: currentInactivityTimeout === InactivityTimeout.OneMinute,
+ },
+ {
+ label: t("home.settings.security.inactivity_timeout.5_minutes"),
+ value: InactivityTimeout.FiveMinutes,
+ selected: currentInactivityTimeout === InactivityTimeout.FiveMinutes,
+ },
+ {
+ label: t("home.settings.security.inactivity_timeout.15_minutes"),
+ value: InactivityTimeout.FifteenMinutes,
+ selected: currentInactivityTimeout === InactivityTimeout.FifteenMinutes,
+ },
+ {
+ label: t("home.settings.security.inactivity_timeout.30_minutes"),
+ value: InactivityTimeout.ThirtyMinutes,
+ selected: currentInactivityTimeout === InactivityTimeout.ThirtyMinutes,
+ },
+ {
+ label: t("home.settings.security.inactivity_timeout.1_hour"),
+ value: InactivityTimeout.OneHour,
+ selected: currentInactivityTimeout === InactivityTimeout.OneHour,
+ },
+ {
+ label: t("home.settings.security.inactivity_timeout.4_hours"),
+ value: InactivityTimeout.FourHours,
+ selected: currentInactivityTimeout === InactivityTimeout.FourHours,
+ },
+ {
+ label: t("home.settings.security.inactivity_timeout.24_hours"),
+ value: InactivityTimeout.TwentyFourHours,
+ selected:
+ currentInactivityTimeout === InactivityTimeout.TwentyFourHours,
+ },
+ ],
+ [t, currentInactivityTimeout],
+ );
+
+ // Get display labels for option buttons
+ const audioTranscodeLabel = useMemo(() => {
+ const option = audioTranscodeModeOptions.find((o) => o.selected);
+ return option?.label || t("home.settings.audio.transcode_mode.auto");
+ }, [audioTranscodeModeOptions, t]);
+
+ const subtitleModeLabel = useMemo(() => {
+ const option = subtitleModeOptions.find((o) => o.selected);
+ return option?.label || t("home.settings.subtitles.modes.Default");
+ }, [subtitleModeOptions, t]);
+
+ const alignXLabel = useMemo(() => {
+ const option = alignXOptions.find((o) => o.selected);
+ return option?.label || "Center";
+ }, [alignXOptions]);
+
+ const alignYLabel = useMemo(() => {
+ const option = alignYOptions.find((o) => o.selected);
+ return option?.label || "Bottom";
+ }, [alignYOptions]);
+
+ const typographyScaleLabel = useMemo(() => {
+ const option = typographyScaleOptions.find((o) => o.selected);
+ return option?.label || t("home.settings.appearance.display_size_default");
+ }, [typographyScaleOptions, t]);
+
+ const cacheModeLabel = useMemo(() => {
+ const option = cacheModeOptions.find((o) => o.selected);
+ return option?.label || t("home.settings.buffer.cache_auto");
+ }, [cacheModeOptions, t]);
+
+ const voDriverLabel = useMemo(() => {
+ const option = voDriverOptions.find((o) => o.selected);
+ return option?.label || t("home.settings.vo_driver.gpu_next");
+ }, [voDriverOptions, t]);
+
+ const languageLabel = useMemo(() => {
+ if (!currentLanguage) return t("home.settings.languages.system");
+ const option = APP_LANGUAGES.find((l) => l.value === currentLanguage);
+ return option?.label || t("home.settings.languages.system");
+ }, [currentLanguage, t]);
+
+ const inactivityTimeoutLabel = useMemo(() => {
+ const option = inactivityTimeoutOptions.find((o) => o.selected);
+ return (
+ option?.label || t("home.settings.security.inactivity_timeout.disabled")
+ );
+ }, [inactivityTimeoutOptions, t]);
+
+ return (
+
+
+
+ {/* Header */}
+
+ {t("home.settings.settings_title")}
+
+
+ {/* Account Section */}
+
+
+
+ {/* Security Section */}
+
+
+ showOptions({
+ title: t("home.settings.security.inactivity_timeout.title"),
+ options: inactivityTimeoutOptions,
+ onSelect: (value) =>
+ updateSettings({ inactivityTimeout: value }),
+ })
+ }
+ />
+
+ {/* Audio Section */}
+
+
+ showOptions({
+ title: t("home.settings.audio.transcode_mode.title"),
+ options: audioTranscodeModeOptions,
+ onSelect: (value) =>
+ updateSettings({ audioTranscodeMode: value }),
+ })
+ }
+ />
+
+ {/* Subtitles Section */}
+
+
+ showOptions({
+ title: t("home.settings.subtitles.subtitle_mode"),
+ options: subtitleModeOptions,
+ onSelect: (value) => updateSettings({ subtitleMode: value }),
+ })
+ }
+ />
+
+ updateSettings({ rememberSubtitleSelections: value })
+ }
+ />
+ {
+ const newValue = Math.max(
+ 0.1,
+ (settings.mpvSubtitleScale ?? 1.0) - 0.1,
+ );
+ updateSettings({
+ mpvSubtitleScale: Math.round(newValue * 10) / 10,
+ });
+ }}
+ onIncrease={() => {
+ const newValue = Math.min(
+ 3.0,
+ (settings.mpvSubtitleScale ?? 1.0) + 0.1,
+ );
+ updateSettings({
+ mpvSubtitleScale: Math.round(newValue * 10) / 10,
+ });
+ }}
+ formatValue={(v) => `${v.toFixed(1)}x`}
+ />
+ {
+ const newValue = Math.max(
+ 0,
+ (settings.mpvSubtitleMarginY ?? 0) - 5,
+ );
+ updateSettings({ mpvSubtitleMarginY: newValue });
+ }}
+ onIncrease={() => {
+ const newValue = Math.min(
+ 100,
+ (settings.mpvSubtitleMarginY ?? 0) + 5,
+ );
+ updateSettings({ mpvSubtitleMarginY: newValue });
+ }}
+ />
+
+ showOptions({
+ title: "Horizontal Alignment",
+ options: alignXOptions,
+ onSelect: (value) =>
+ updateSettings({
+ mpvSubtitleAlignX: value as "left" | "center" | "right",
+ }),
+ })
+ }
+ />
+
+ showOptions({
+ title: "Vertical Alignment",
+ options: alignYOptions,
+ onSelect: (value) =>
+ updateSettings({
+ mpvSubtitleAlignY: value as "top" | "center" | "bottom",
+ }),
+ })
+ }
+ />
+
+ {/* OpenSubtitles Section */}
+
+
+ {t("home.settings.subtitles.opensubtitles_hint") ||
+ "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured."}
+
+ updateSettings({ openSubtitlesApiKey })}
+ secureTextEntry
+ />
+
+ {t("home.settings.subtitles.opensubtitles_get_key") ||
+ "Get your free API key at opensubtitles.com/en/consumers"}
+
+
+ {/* Buffer Settings Section */}
+
+
+ showOptions({
+ title: t("home.settings.buffer.cache_mode"),
+ options: cacheModeOptions,
+ onSelect: (value) => updateSettings({ mpvCacheEnabled: value }),
+ })
+ }
+ />
+
+ {/* Video Output Section */}
+
+
+ showOptions({
+ title: t("home.settings.vo_driver.vo_mode"),
+ options: voDriverOptions,
+ onSelect: (value) => updateSettings({ mpvVoDriver: value }),
+ })
+ }
+ />
+ {
+ const newValue = Math.max(
+ 5,
+ (settings.mpvCacheSeconds ?? 10) - 5,
+ );
+ updateSettings({ mpvCacheSeconds: newValue });
+ }}
+ onIncrease={() => {
+ const newValue = Math.min(
+ 120,
+ (settings.mpvCacheSeconds ?? 10) + 5,
+ );
+ updateSettings({ mpvCacheSeconds: newValue });
+ }}
+ formatValue={(v) => `${v}s`}
+ />
+ {
+ const newValue = Math.max(
+ 50,
+ (settings.mpvDemuxerMaxBytes ?? 150) - 25,
+ );
+ updateSettings({ mpvDemuxerMaxBytes: newValue });
+ }}
+ onIncrease={() => {
+ const newValue = Math.min(
+ 500,
+ (settings.mpvDemuxerMaxBytes ?? 150) + 25,
+ );
+ updateSettings({ mpvDemuxerMaxBytes: newValue });
+ }}
+ formatValue={(v) => `${v} MB`}
+ />
+ {
+ const newValue = Math.max(
+ 25,
+ (settings.mpvDemuxerMaxBackBytes ?? 50) - 25,
+ );
+ updateSettings({ mpvDemuxerMaxBackBytes: newValue });
+ }}
+ onIncrease={() => {
+ const newValue = Math.min(
+ 200,
+ (settings.mpvDemuxerMaxBackBytes ?? 50) + 25,
+ );
+ updateSettings({ mpvDemuxerMaxBackBytes: newValue });
+ }}
+ formatValue={(v) => `${v} MB`}
+ />
+
+ {/* Appearance Section */}
+
+
+ showOptions({
+ title: t("home.settings.appearance.display_size"),
+ options: typographyScaleOptions,
+ onSelect: (value) =>
+ updateSettings({ tvTypographyScale: value }),
+ })
+ }
+ />
+
+ showOptions({
+ title: t("home.settings.languages.app_language"),
+ options: languageOptions,
+ onSelect: (value) =>
+ updateSettings({ preferedLanguage: value }),
+ })
+ }
+ />
+
+ updateSettings({ mergeNextUpAndContinueWatching: value })
+ }
+ />
+ updateSettings({ showHomeBackdrop: value })}
+ />
+ updateSettings({ showTVHeroCarousel: value })}
+ />
+
+ updateSettings({ showSeriesPosterOnEpisode: value })
+ }
+ />
+ updateSettings({ tvThemeMusicEnabled: value })}
+ />
+
+ {/* Storage Section */}
+
+
+
+ {/* User Section */}
+
+
+
+
+ {/* Logout Button */}
+
+
+
+
+
+
+ {/* PIN Entry Modal */}
+ {
+ setPinModalVisible(false);
+ setSelectedAccount(null);
+ setSelectedServer(null);
+ }}
+ onSuccess={handlePinSuccess}
+ onForgotPIN={() => {
+ setPinModalVisible(false);
+ setSelectedAccount(null);
+ setSelectedServer(null);
+ }}
+ serverUrl={selectedServer?.address || ""}
+ userId={selectedAccount?.userId || ""}
+ username={selectedAccount?.username || ""}
+ />
+
+ {/* Password Entry Modal */}
+ {
+ setPasswordModalVisible(false);
+ setSelectedAccount(null);
+ setSelectedServer(null);
+ }}
+ onSubmit={handlePasswordSubmit}
+ username={selectedAccount?.username || ""}
+ />
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(home)/settings/appearance/hide-libraries/page.tsx b/app/(auth)/(tabs)/(home)/settings/appearance/hide-libraries/page.tsx
index a0b3bab9b..d0109fb7e 100644
--- a/app/(auth)/(tabs)/(home)/settings/appearance/hide-libraries/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/appearance/hide-libraries/page.tsx
@@ -12,7 +12,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
-export default function page() {
+export default function AppearanceHideLibrariesPage() {
const { settings, updateSettings, pluginSettings } = useSettings();
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
@@ -71,7 +71,7 @@ export default function page() {
))}
- {t("home.settings.other.select_liraries_you_want_to_hide")}
+ {t("home.settings.other.select_libraries_you_want_to_hide")}
diff --git a/app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx b/app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx
index 58415127e..7c5f38d85 100644
--- a/app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx
@@ -2,6 +2,7 @@ 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() {
@@ -22,6 +23,7 @@ export default function AudioSubtitlesPage() {
+
diff --git a/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx b/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx
index e1c8b56b6..fe777462f 100644
--- a/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx
@@ -11,7 +11,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
-export default function page() {
+export default function HideLibrariesPage() {
const { settings, updateSettings, pluginSettings } = useSettings();
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
@@ -60,7 +60,7 @@ export default function page() {
))}
- {t("home.settings.other.select_liraries_you_want_to_hide")}
+ {t("home.settings.other.select_libraries_you_want_to_hide")}
);
diff --git a/app/(auth)/(tabs)/(home)/settings/intro/page.tsx b/app/(auth)/(tabs)/(home)/settings/intro/page.tsx
index 4cb43e6d6..6e04777b9 100644
--- a/app/(auth)/(tabs)/(home)/settings/intro/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/intro/page.tsx
@@ -1,13 +1,13 @@
-import { useRouter } from "expo-router";
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 router = useRouter();
+ const { showIntro } = useIntroSheet();
const insets = useSafeAreaInsets();
const { t } = useTranslation();
@@ -26,7 +26,7 @@ export default function IntroPage() {
{
- router.push("/intro/page");
+ showIntro();
}}
title={t("home.settings.intro.show_intro")}
/>
diff --git a/app/(auth)/(tabs)/(home)/settings/logs/page.tsx b/app/(auth)/(tabs)/(home)/settings/logs/page.tsx
index 878319256..4ce5736cd 100644
--- a/app/(auth)/(tabs)/(home)/settings/logs/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/logs/page.tsx
@@ -61,7 +61,10 @@ export default function Page() {
setLoading(true);
try {
logsFile.write(JSON.stringify(filteredLogs));
- await Sharing.shareAsync(logsFile.uri, { mimeType: "txt", UTI: "txt" });
+ await Sharing.shareAsync(logsFile.uri, {
+ mimeType: "text/plain",
+ UTI: "public.plain-text",
+ });
} catch (e: any) {
writeErrorLog("Something went wrong attempting to export", e);
} finally {
@@ -85,12 +88,7 @@ export default function Page() {
}, [share, loading]);
return (
-
+
-
+
{filteredLogs?.map((log, index) => (
diff --git a/app/(auth)/(tabs)/(home)/settings/music/page.tsx b/app/(auth)/(tabs)/(home)/settings/music/page.tsx
new file mode 100644
index 000000000..70467b4dd
--- /dev/null
+++ b/app/(auth)/(tabs)/(home)/settings/music/page.tsx
@@ -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 (
+
+
+
+ {t("home.settings.music.playback_description")}
+
+ }
+ >
+
+
+ updateSettings({ preferLocalAudio: value })
+ }
+ />
+
+
+
+
+
+ {t("home.settings.music.caching_description")}
+
+ }
+ >
+
+
+ updateSettings({ audioLookaheadEnabled: value })
+ }
+ />
+
+
+
+
+ {currentLookaheadLabel}
+
+
+
+ }
+ title={t("home.settings.music.lookahead_count")}
+ />
+
+
+
+
+ {currentCacheSizeLabel}
+
+
+
+ }
+ title={t("home.settings.music.max_cache_size")}
+ />
+
+
+
+
+ {!Platform.isTV && (
+
+
+ {t("home.settings.storage.music_cache_description")}
+
+ }
+ >
+
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(home)/settings/network/page.tsx b/app/(auth)/(tabs)/(home)/settings/network/page.tsx
new file mode 100644
index 000000000..9f3727e42
--- /dev/null
+++ b/app/(auth)/(tabs)/(home)/settings/network/page.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(home)/settings/playback-controls/page.tsx b/app/(auth)/(tabs)/(home)/settings/playback-controls/page.tsx
index 370247c15..2771117f9 100644
--- a/app/(auth)/(tabs)/(home)/settings/playback-controls/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/playback-controls/page.tsx
@@ -3,6 +3,8 @@ 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 { MpvBufferSettings } from "@/components/settings/MpvBufferSettings";
+import { MpvVoSettings } from "@/components/settings/MpvVoSettings";
import { PlaybackControlsSettings } from "@/components/settings/PlaybackControlsSettings";
import { ChromecastSettings } from "../../../../../../components/settings/ChromecastSettings";
@@ -26,6 +28,8 @@ export default function PlaybackControlsPage() {
+
+
{!Platform.isTV && }
diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/jellyseerr/page.tsx
index cd1efca54..84041fd01 100644
--- a/app/(auth)/(tabs)/(home)/settings/plugins/jellyseerr/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/plugins/jellyseerr/page.tsx
@@ -4,7 +4,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings";
-export default function page() {
+export default function JellyseerrPluginPage() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets();
@@ -18,7 +18,7 @@ export default function page() {
>
diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/kefinTweaks/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/kefinTweaks/page.tsx
new file mode 100644
index 000000000..e9af145fd
--- /dev/null
+++ b/app/(auth)/(tabs)/(home)/settings/plugins/kefinTweaks/page.tsx
@@ -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 KefinTweaksPage() {
+ const { pluginSettings } = useSettings();
+ const insets = useSafeAreaInsets();
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx
index 4eb36aefd..3ce2c81c3 100644
--- a/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx
@@ -1,4 +1,3 @@
-import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -16,9 +15,10 @@ 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() {
+export default function MarlinSearchPage() {
const navigation = useNavigation();
const { t } = useTranslation();
@@ -26,7 +26,7 @@ export default function page() {
const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings();
- const queryClient = useQueryClient();
+ const queryClient = useNetworkAwareQueryClient();
const [value, setValue] = useState(settings?.marlinServerUrl || "");
@@ -60,7 +60,7 @@ export default function page() {
),
});
}
- }, [navigation, value]);
+ }, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]);
if (!settings) return null;
@@ -75,7 +75,10 @@ export default function page() {
{
updateSettings({
searchEngine: value ? "Marlin" : "Jellyfin",
diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx
new file mode 100644
index 000000000..8f0a2c931
--- /dev/null
+++ b/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx
@@ -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 StreamystatsPage() {
+ 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(settings?.streamyStatsServerUrl || "");
+ const [useForSearch, setUseForSearch] = useState(
+ settings?.searchEngine === "Streamystats",
+ );
+ const [movieRecs, setMovieRecs] = useState(
+ settings?.streamyStatsMovieRecommendations ?? false,
+ );
+ const [seriesRecs, setSeriesRecs] = useState(
+ settings?.streamyStatsSeriesRecommendations ?? false,
+ );
+ const [promotedWatchlists, setPromotedWatchlists] = useState(
+ settings?.streamyStatsPromotedWatchlists ?? false,
+ );
+ const [hideWatchlistsTab, setHideWatchlistsTab] = useState(
+ 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: () => (
+
+
+ {t("home.settings.plugins.streamystats.save")}
+
+
+ ),
+ });
+ }, [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();
+ // 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 (
+
+
+
+
+
+
+
+
+
+ {t("home.settings.plugins.streamystats.streamystats_search_hint")}{" "}
+
+ {t(
+ "home.settings.plugins.streamystats.read_more_about_streamystats",
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("home.settings.plugins.streamystats.home_sections_hint")}
+
+
+
+
+ {t("home.settings.plugins.streamystats.refresh_from_server")}
+
+
+
+ {/* Disable button - only show if URL is not locked and Streamystats is enabled */}
+ {!isUrlLocked && isStreamystatsEnabled && (
+
+
+ {t("home.settings.plugins.streamystats.disable_streamystats")}
+
+
+ )}
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx
deleted file mode 100644
index 1723fe4b2..000000000
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx
+++ /dev/null
@@ -1,420 +0,0 @@
-import type {
- BaseItemDto,
- BaseItemDtoQueryResult,
- ItemSortBy,
-} from "@jellyfin/sdk/lib/generated-client/models";
-import {
- getFilterApi,
- getItemsApi,
- getUserLibraryApi,
-} from "@jellyfin/sdk/lib/utils/api";
-import { FlashList } from "@shopify/flash-list";
-import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
-import { useLocalSearchParams, useNavigation } from "expo-router";
-import { useAtom } from "jotai";
-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";
-import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
-import { ItemCardText } from "@/components/ItemCardText";
-import { ItemPoster } from "@/components/posters/ItemPoster";
-import * as ScreenOrientation from "@/packages/expo-screen-orientation";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import {
- genreFilterAtom,
- SortByOption,
- SortOrderOption,
- sortByAtom,
- sortOptions,
- sortOrderAtom,
- sortOrderOptions,
- tagsFilterAtom,
- yearFilterAtom,
-} from "@/utils/atoms/filters";
-
-const page: React.FC = () => {
- const searchParams = useLocalSearchParams();
- const { collectionId } = searchParams as { collectionId: string };
-
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
- const navigation = useNavigation();
- const [orientation, _setOrientation] = useState(
- ScreenOrientation.Orientation.PORTRAIT_UP,
- );
-
- const { t } = useTranslation();
-
- const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
- const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
- const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
- const [sortBy, setSortBy] = useAtom(sortByAtom);
- const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
-
- const { data: collection } = useQuery({
- queryKey: ["collection", collectionId],
- queryFn: async () => {
- if (!api) return null;
- const response = await getUserLibraryApi(api).getItem({
- itemId: collectionId,
- userId: user?.Id,
- });
- const data = response.data;
- return data;
- },
- enabled: !!api && !!user?.Id && !!collectionId,
- staleTime: 60 * 1000,
- });
-
- useEffect(() => {
- navigation.setOptions({ title: collection?.Name || "" });
- setSortOrder([SortOrderOption.Ascending]);
-
- if (!collection) return;
-
- // Convert the DisplayOrder to SortByOption
- const displayOrder = collection.DisplayOrder as ItemSortBy;
- const sortByOption = displayOrder
- ? SortByOption[displayOrder as keyof typeof SortByOption] ||
- SortByOption.PremiereDate
- : SortByOption.PremiereDate;
-
- setSortBy([sortByOption]);
- }, [navigation, collection]);
-
- const fetchItems = useCallback(
- async ({
- pageParam,
- }: {
- pageParam: number;
- }): Promise => {
- if (!api || !collection) return null;
-
- const response = await getItemsApi(api).getItems({
- userId: user?.Id,
- parentId: collectionId,
- limit: 18,
- startIndex: pageParam,
- // Set one ordering at a time. As collections do not work with correctly with multiple.
- sortBy: [sortBy[0]],
- sortOrder: [sortOrder[0]],
- fields: [
- "ItemCounts",
- "PrimaryImageAspectRatio",
- "CanDelete",
- "MediaSourceCount",
- ],
- // true is needed for merged versions
- recursive: true,
- genres: selectedGenres,
- tags: selectedTags,
- years: selectedYears.map((year) => Number.parseInt(year, 10)),
- includeItemTypes: ["Movie", "Series"],
- });
-
- return response.data || null;
- },
- [
- api,
- user?.Id,
- collection,
- selectedGenres,
- selectedYears,
- selectedTags,
- sortBy,
- sortOrder,
- ],
- );
-
- const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
- queryKey: [
- "collection-items",
- collection,
- selectedGenres,
- selectedYears,
- selectedTags,
- sortBy,
- sortOrder,
- ],
- queryFn: fetchItems,
- getNextPageParam: (lastPage, pages) => {
- if (
- !lastPage?.Items ||
- !lastPage?.TotalRecordCount ||
- lastPage?.TotalRecordCount === 0
- )
- return undefined;
-
- const totalItems = lastPage.TotalRecordCount;
- const accumulatedItems = pages.reduce(
- (acc, curr) => acc + (curr?.Items?.length || 0),
- 0,
- );
-
- if (accumulatedItems < totalItems) {
- return lastPage?.Items?.length * pages.length;
- }
- return undefined;
- },
- initialPageParam: 0,
- enabled: !!api && !!user?.Id && !!collection,
- });
-
- const flatData = useMemo(() => {
- return (
- (data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
- []
- );
- }, [data]);
-
- const renderItem = useCallback(
- ({ item, index }: { item: BaseItemDto; index: number }) => (
-
-
-
- {/* */}
-
-
-
- ),
- [orientation],
- );
-
- const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
-
- const _insets = useSafeAreaInsets();
-
- const ListHeaderComponent = useCallback(
- () => (
- ,
- },
- {
- key: "genre",
- component: (
- {
- 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: (
- {
- 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: (
- {
- 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: (
- 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: (
- 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,
- api,
- user?.Id,
- selectedGenres,
- setSelectedGenres,
- selectedYears,
- setSelectedYears,
- selectedTags,
- setSelectedTags,
- sortBy,
- setSortBy,
- sortOrder,
- setSortOrder,
- isFetching,
- ],
- );
-
- if (!collection) return null;
-
- return (
-
-
- {t("search.no_results")}
-
-
- }
- extraData={[
- selectedGenres,
- selectedYears,
- selectedTags,
- sortBy,
- sortOrder,
- ]}
- contentInsetAdjustmentBehavior='automatic'
- data={flatData}
- renderItem={renderItem}
- keyExtractor={keyExtractor}
- numColumns={
- orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
- }
- onEndReached={() => {
- if (hasNextPage) {
- fetchNextPage();
- }
- }}
- onEndReachedThreshold={0.5}
- ListHeaderComponent={ListHeaderComponent}
- contentContainerStyle={{ paddingBottom: 24 }}
- ItemSeparatorComponent={() => (
-
- )}
- />
- );
-};
-
-export default page;
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx
deleted file mode 100644
index 259424729..000000000
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-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";
-
-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, false, undefined, [
- ItemFields.MediaSources,
- ItemFields.MediaSourceCount,
- ItemFields.MediaStreams,
- ]);
-
- 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 (
-
- {t("item_card.could_not_load_item")}
-
- );
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {item && }
-
- );
-};
-
-export default Page;
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx
deleted file mode 100644
index 2636d5c01..000000000
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx
+++ /dev/null
@@ -1,163 +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 () => {
- if (!api || !user?.Id || !item?.Id) return [];
-
- 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 && (
-
-
- {!Platform.isTV && (
- (
-
- )}
- DownloadedIconComponent={() => (
-
- )}
- />
- )}
-
- ),
- });
- }, [allEpisodes, isLoading, item]);
-
- if (!item || !backdropUrl) return null;
-
- return (
-
- }
- logo={
- logoUrl ? (
-
- ) : undefined
- }
- >
-
-
-
-
-
-
-
-
- );
-};
-
-export default page;
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx
new file mode 100644
index 000000000..5fd125c96
--- /dev/null
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx
@@ -0,0 +1,785 @@
+import type {
+ BaseItemDto,
+ BaseItemDtoQueryResult,
+ ItemSortBy,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import {
+ getFilterApi,
+ getItemsApi,
+ getUserLibraryApi,
+} from "@jellyfin/sdk/lib/utils/api";
+import { FlashList } from "@shopify/flash-list";
+import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
+import { useLocalSearchParams, useNavigation } from "expo-router";
+import { useAtom } from "jotai";
+import type React from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { FlatList, Platform, useWindowDimensions, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import {
+ getItemNavigation,
+ TouchableItemRouter,
+} from "@/components/common/TouchableItemRouter";
+import { FilterButton } from "@/components/filters/FilterButton";
+import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
+import { ItemCardText } from "@/components/ItemCardText";
+import { Loader } from "@/components/Loader";
+import { ItemPoster } from "@/components/posters/ItemPoster";
+import { TVFilterButton } from "@/components/tv";
+import { TVPosterCard } from "@/components/tv/TVPosterCard";
+import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
+import useRouter from "@/hooks/useAppRouter";
+import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
+import { useTVOptionModal } from "@/hooks/useTVOptionModal";
+import * as ScreenOrientation from "@/packages/expo-screen-orientation";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import {
+ genreFilterAtom,
+ SortByOption,
+ SortOrderOption,
+ sortByAtom,
+ sortOptions,
+ sortOrderAtom,
+ sortOrderOptions,
+ tagsFilterAtom,
+ yearFilterAtom,
+} from "@/utils/atoms/filters";
+import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
+
+const TV_ITEM_GAP = 16;
+const TV_SCALE_PADDING = 20;
+
+const page: React.FC = () => {
+ const searchParams = useLocalSearchParams();
+ const { collectionId } = searchParams as { collectionId: string };
+
+ const posterSizes = useScaledTVPosterSizes();
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const navigation = useNavigation();
+ const router = useRouter();
+ const { showOptions } = useTVOptionModal();
+ const { showItemActions } = useTVItemActionModal();
+ const { width: screenWidth } = useWindowDimensions();
+ const [orientation, _setOrientation] = useState(
+ ScreenOrientation.Orientation.PORTRAIT_UP,
+ );
+
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+
+ const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
+ const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
+ const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
+ const [sortBy, setSortBy] = useAtom(sortByAtom);
+ const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
+
+ const { data: collection, isLoading: isCollectionLoading } = useQuery({
+ queryKey: ["collection", collectionId],
+ queryFn: async () => {
+ if (!api) return null;
+ const response = await getUserLibraryApi(api).getItem({
+ itemId: collectionId,
+ userId: user?.Id,
+ });
+ const data = response.data;
+ return data;
+ },
+ enabled: !!api && !!user?.Id && !!collectionId,
+ staleTime: 60 * 1000,
+ });
+
+ // TV Filter queries
+ const { data: tvGenreOptions } = useQuery({
+ queryKey: ["filters", "Genres", "tvGenreFilter", collectionId],
+ queryFn: async () => {
+ if (!api) return [];
+ const response = await getFilterApi(api).getQueryFiltersLegacy({
+ userId: user?.Id,
+ parentId: collectionId,
+ });
+ return response.data.Genres || [];
+ },
+ enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
+ });
+
+ const { data: tvYearOptions } = useQuery({
+ queryKey: ["filters", "Years", "tvYearFilter", collectionId],
+ queryFn: async () => {
+ if (!api) return [];
+ const response = await getFilterApi(api).getQueryFiltersLegacy({
+ userId: user?.Id,
+ parentId: collectionId,
+ });
+ return response.data.Years || [];
+ },
+ enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
+ });
+
+ const { data: tvTagOptions } = useQuery({
+ queryKey: ["filters", "Tags", "tvTagFilter", collectionId],
+ queryFn: async () => {
+ if (!api) return [];
+ const response = await getFilterApi(api).getQueryFiltersLegacy({
+ userId: user?.Id,
+ parentId: collectionId,
+ });
+ return response.data.Tags || [];
+ },
+ enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
+ });
+
+ useEffect(() => {
+ navigation.setOptions({ title: collection?.Name || "" });
+ setSortOrder([SortOrderOption.Ascending]);
+
+ if (!collection) return;
+
+ // Convert the DisplayOrder to SortByOption
+ const displayOrder = collection.DisplayOrder as ItemSortBy;
+ const sortByOption = displayOrder
+ ? SortByOption[displayOrder as keyof typeof SortByOption] ||
+ SortByOption.PremiereDate
+ : SortByOption.PremiereDate;
+
+ setSortBy([sortByOption]);
+ }, [navigation, collection]);
+
+ // Calculate columns for TV grid
+ const nrOfCols = useMemo(() => {
+ if (Platform.isTV) {
+ const itemWidth = posterSizes.poster + TV_ITEM_GAP;
+ return Math.max(
+ 1,
+ Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
+ );
+ }
+ return orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5;
+ }, [screenWidth, orientation]);
+
+ const fetchItems = useCallback(
+ async ({
+ pageParam,
+ }: {
+ pageParam: number;
+ }): Promise => {
+ if (!api || !collection) return null;
+
+ const response = await getItemsApi(api).getItems({
+ userId: user?.Id,
+ parentId: collectionId,
+ limit: Platform.isTV ? 36 : 18,
+ startIndex: pageParam,
+ // Set one ordering at a time. As collections do not work with correctly with multiple.
+ sortBy: [sortBy[0]],
+ sortOrder: [sortOrder[0]],
+ fields: [
+ "ItemCounts",
+ "PrimaryImageAspectRatio",
+ "CanDelete",
+ "MediaSourceCount",
+ ],
+ // true is needed for merged versions
+ recursive: true,
+ genres: selectedGenres,
+ tags: selectedTags,
+ years: selectedYears.map((year) => Number.parseInt(year, 10)),
+ includeItemTypes: ["Movie", "Series", "Season"],
+ });
+
+ return response.data || null;
+ },
+ [
+ api,
+ user?.Id,
+ collection,
+ collectionId,
+ selectedGenres,
+ selectedYears,
+ selectedTags,
+ sortBy,
+ sortOrder,
+ ],
+ );
+
+ const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
+ useInfiniteQuery({
+ queryKey: [
+ "collection-items",
+ collectionId,
+ selectedGenres,
+ selectedYears,
+ selectedTags,
+ sortBy,
+ sortOrder,
+ ],
+ queryFn: fetchItems,
+ getNextPageParam: (lastPage, pages) => {
+ if (
+ !lastPage?.Items ||
+ !lastPage?.TotalRecordCount ||
+ lastPage?.TotalRecordCount === 0
+ )
+ return undefined;
+
+ const totalItems = lastPage.TotalRecordCount;
+ const accumulatedItems = pages.reduce(
+ (acc, curr) => acc + (curr?.Items?.length || 0),
+ 0,
+ );
+
+ if (accumulatedItems < totalItems) {
+ return lastPage?.Items?.length * pages.length;
+ }
+ return undefined;
+ },
+ initialPageParam: 0,
+ enabled: !!api && !!user?.Id && !!collection,
+ });
+
+ const flatData = useMemo(() => {
+ return (
+ (data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
+ []
+ );
+ }, [data]);
+
+ const renderItem = useCallback(
+ ({ item, index }: { item: BaseItemDto; index: number }) => (
+
+
+
+
+
+
+ ),
+ [orientation],
+ );
+
+ const renderTVItem = useCallback(
+ ({ item }: { item: BaseItemDto }) => {
+ const handlePress = () => {
+ const navTarget = getItemNavigation(item, "(home)");
+ router.push(navTarget as any);
+ };
+
+ return (
+
+ showItemActions(item)}
+ width={posterSizes.poster}
+ />
+
+ );
+ },
+ [router, showItemActions, posterSizes.poster],
+ );
+
+ const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
+
+ const ListHeaderComponent = useCallback(
+ () => (
+ ,
+ },
+ {
+ key: "genre",
+ component: (
+ {
+ 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: (
+ {
+ 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: (
+ {
+ 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: (
+ 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: (
+ 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,
+ api,
+ user?.Id,
+ selectedGenres,
+ setSelectedGenres,
+ selectedYears,
+ setSelectedYears,
+ selectedTags,
+ setSelectedTags,
+ sortBy,
+ setSortBy,
+ sortOrder,
+ setSortOrder,
+ isFetching,
+ ],
+ );
+
+ // TV Filter options - with "All" option for clearable filters
+ const tvGenreFilterOptions = useMemo(
+ (): TVOptionItem[] => [
+ {
+ label: t("library.filters.all"),
+ value: "__all__",
+ selected: selectedGenres.length === 0,
+ },
+ ...(tvGenreOptions || []).map((genre) => ({
+ label: genre,
+ value: genre,
+ selected: selectedGenres.includes(genre),
+ })),
+ ],
+ [tvGenreOptions, selectedGenres, t],
+ );
+
+ const tvYearFilterOptions = useMemo(
+ (): TVOptionItem[] => [
+ {
+ label: t("library.filters.all"),
+ value: "__all__",
+ selected: selectedYears.length === 0,
+ },
+ ...(tvYearOptions || []).map((year) => ({
+ label: String(year),
+ value: String(year),
+ selected: selectedYears.includes(String(year)),
+ })),
+ ],
+ [tvYearOptions, selectedYears, t],
+ );
+
+ const tvTagFilterOptions = useMemo(
+ (): TVOptionItem[] => [
+ {
+ label: t("library.filters.all"),
+ value: "__all__",
+ selected: selectedTags.length === 0,
+ },
+ ...(tvTagOptions || []).map((tag) => ({
+ label: tag,
+ value: tag,
+ selected: selectedTags.includes(tag),
+ })),
+ ],
+ [tvTagOptions, selectedTags, t],
+ );
+
+ const tvSortByOptions = useMemo(
+ (): TVOptionItem[] =>
+ sortOptions.map((option) => ({
+ label: option.value,
+ value: option.key,
+ selected: sortBy[0] === option.key,
+ })),
+ [sortBy],
+ );
+
+ const tvSortOrderOptions = useMemo(
+ (): TVOptionItem[] =>
+ sortOrderOptions.map((option) => ({
+ label: option.value,
+ value: option.key,
+ selected: sortOrder[0] === option.key,
+ })),
+ [sortOrder],
+ );
+
+ // TV Filter handlers using navigation-based modal
+ const handleShowGenreFilter = useCallback(() => {
+ showOptions({
+ title: t("library.filters.genres"),
+ options: tvGenreFilterOptions,
+ onSelect: (value: string) => {
+ if (value === "__all__") {
+ setSelectedGenres([]);
+ } else if (selectedGenres.includes(value)) {
+ setSelectedGenres(selectedGenres.filter((g) => g !== value));
+ } else {
+ setSelectedGenres([...selectedGenres, value]);
+ }
+ },
+ });
+ }, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
+
+ const handleShowYearFilter = useCallback(() => {
+ showOptions({
+ title: t("library.filters.years"),
+ options: tvYearFilterOptions,
+ onSelect: (value: string) => {
+ if (value === "__all__") {
+ setSelectedYears([]);
+ } else if (selectedYears.includes(value)) {
+ setSelectedYears(selectedYears.filter((y) => y !== value));
+ } else {
+ setSelectedYears([...selectedYears, value]);
+ }
+ },
+ });
+ }, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
+
+ const handleShowTagFilter = useCallback(() => {
+ showOptions({
+ title: t("library.filters.tags"),
+ options: tvTagFilterOptions,
+ onSelect: (value: string) => {
+ if (value === "__all__") {
+ setSelectedTags([]);
+ } else if (selectedTags.includes(value)) {
+ setSelectedTags(selectedTags.filter((tag) => tag !== value));
+ } else {
+ setSelectedTags([...selectedTags, value]);
+ }
+ },
+ });
+ }, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
+
+ const handleShowSortByFilter = useCallback(() => {
+ showOptions({
+ title: t("library.filters.sort_by"),
+ options: tvSortByOptions,
+ onSelect: (value: SortByOption) => {
+ setSortBy([value]);
+ },
+ });
+ }, [showOptions, t, tvSortByOptions, setSortBy]);
+
+ const handleShowSortOrderFilter = useCallback(() => {
+ showOptions({
+ title: t("library.filters.sort_order"),
+ options: tvSortOrderOptions,
+ onSelect: (value: SortOrderOption) => {
+ setSortOrder([value]);
+ },
+ });
+ }, [showOptions, t, tvSortOrderOptions, setSortOrder]);
+
+ // TV filter bar state
+ const hasActiveFilters =
+ selectedGenres.length > 0 ||
+ selectedYears.length > 0 ||
+ selectedTags.length > 0;
+
+ const resetAllFilters = useCallback(() => {
+ setSelectedGenres([]);
+ setSelectedYears([]);
+ setSelectedTags([]);
+ }, [setSelectedGenres, setSelectedYears, setSelectedTags]);
+
+ if (isLoading || isCollectionLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!collection) return null;
+
+ // Mobile return
+ if (!Platform.isTV) {
+ return (
+
+
+ {t("search.no_results")}
+
+
+ }
+ extraData={[
+ selectedGenres,
+ selectedYears,
+ selectedTags,
+ sortBy,
+ sortOrder,
+ ]}
+ contentInsetAdjustmentBehavior='automatic'
+ data={flatData}
+ renderItem={renderItem}
+ keyExtractor={keyExtractor}
+ numColumns={nrOfCols}
+ onEndReached={() => {
+ if (hasNextPage) {
+ fetchNextPage();
+ }
+ }}
+ onEndReachedThreshold={0.5}
+ ListHeaderComponent={ListHeaderComponent}
+ contentContainerStyle={{ paddingBottom: 24 }}
+ ItemSeparatorComponent={() => (
+
+ )}
+ />
+ );
+ }
+
+ // TV return with filter bar
+ return (
+
+ {/* Filter bar */}
+
+ {hasActiveFilters && (
+
+ )}
+ 0
+ ? `${selectedGenres.length} selected`
+ : t("library.filters.all")
+ }
+ onPress={handleShowGenreFilter}
+ hasTVPreferredFocus={!hasActiveFilters}
+ hasActiveFilter={selectedGenres.length > 0}
+ />
+ 0
+ ? `${selectedYears.length} selected`
+ : t("library.filters.all")
+ }
+ onPress={handleShowYearFilter}
+ hasActiveFilter={selectedYears.length > 0}
+ />
+ 0
+ ? `${selectedTags.length} selected`
+ : t("library.filters.all")
+ }
+ onPress={handleShowTagFilter}
+ hasActiveFilter={selectedTags.length > 0}
+ />
+ o.key === sortBy[0])?.value || ""}
+ onPress={handleShowSortByFilter}
+ />
+ o.key === sortOrder[0])?.value || ""
+ }
+ onPress={handleShowSortOrderFilter}
+ />
+
+
+ {/* Grid */}
+
+
+ {t("search.no_results")}
+
+
+ }
+ contentInsetAdjustmentBehavior='automatic'
+ data={flatData}
+ renderItem={renderTVItem}
+ extraData={[orientation, nrOfCols]}
+ keyExtractor={keyExtractor}
+ numColumns={nrOfCols}
+ removeClippedSubviews={false}
+ onEndReached={() => {
+ if (hasNextPage) {
+ fetchNextPage();
+ }
+ }}
+ onEndReachedThreshold={1}
+ contentContainerStyle={{
+ paddingBottom: 24,
+ paddingLeft: TV_SCALE_PADDING,
+ paddingRight: TV_SCALE_PADDING,
+ paddingTop: 20,
+ }}
+ ItemSeparatorComponent={() => (
+
+ )}
+ />
+
+ );
+};
+
+export default page;
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx
new file mode 100644
index 000000000..54fe92126
--- /dev/null
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx
@@ -0,0 +1,116 @@
+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 { Platform, View } from "react-native";
+import Animated, {
+ 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 ItemContentSkeletonTV = Platform.isTV
+ ? require("@/components/ItemContentSkeleton.tv").ItemContentSkeletonTV
+ : null;
+
+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,
+ isLoading,
+ } = useItemQuery(id, isOffline, undefined, [
+ ItemFields.MediaSources,
+ ItemFields.MediaSourceCount,
+ ItemFields.MediaStreams,
+ ]);
+
+ // Lazily preload item with full media sources in background — never cache
+ const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, [], {
+ gcTime: 0,
+ });
+
+ const opacity = useSharedValue(1);
+ const animatedStyle = useAnimatedStyle(() => {
+ return {
+ opacity: opacity.value,
+ };
+ });
+
+ // Fast fade out when item loads (no setTimeout delay)
+ useEffect(() => {
+ if (item) {
+ opacity.value = withTiming(0, { duration: 150 });
+ } else {
+ opacity.value = withTiming(1, { duration: 150 });
+ }
+ }, [item, opacity]);
+
+ if (isError)
+ return (
+
+ {t("item_card.could_not_load_item")}
+
+ );
+
+ return (
+
+
+ {/* Always render ItemContent - it handles loading state internally on TV */}
+
+
+ {/* Skeleton overlay - fades out when content loads */}
+ {!item && (
+
+ {Platform.isTV && ItemContentSkeletonTV ? (
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ )}
+
+
+ );
+};
+
+export default Page;
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/company/[companyId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/company/[companyId].tsx
similarity index 90%
rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/company/[companyId].tsx
rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/company/[companyId].tsx
index cd9cc3cc5..34d83446c 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/company/[companyId].tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/company/[companyId].tsx
@@ -13,7 +13,7 @@ import {
} from "@/utils/jellyseerr/server/models/Search";
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
-export default function page() {
+export default function JellyseerrCompanyPage() {
const local = useLocalSearchParams();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
@@ -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={
{
+// Mobile page component
+const MobilePage: React.FC = () => {
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
const { t } = useTranslation();
@@ -58,13 +71,13 @@ const Page: React.FC = () => {
} & Partial;
const navigation = useNavigation();
- const { jellyseerrApi, requestMedia } = useJellyseerr();
+ const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState();
const [issueMessage, setIssueMessage] = useState();
const [requestBody, _setRequestBody] = useState();
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
- const advancedReqModalRef = useRef(null);
+ const advancedReqModalRef = useRef(null);
const bottomSheetModalRef = useRef(null);
const {
@@ -91,6 +104,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) => (
{
)
)}
+ {canManageRequests && pendingRequest && (
+
+
+
+
+ {t("jellyseerr.requested_by", {
+ user:
+ pendingRequest.requestedBy?.displayName ||
+ pendingRequest.requestedBy?.username ||
+ pendingRequest.requestedBy?.jellyfinUsername ||
+ t("jellyseerr.unknown_user"),
+ })}
+
+
+
+
+ }
+ style={{
+ borderWidth: 1,
+ borderStyle: "solid",
+ }}
+ >
+ {t("jellyseerr.approve")}
+
+
+ }
+ style={{
+ borderWidth: 1,
+ borderStyle: "solid",
+ }}
+ >
+ {t("jellyseerr.decline")}
+
+
+
+ )}
@@ -438,4 +545,12 @@ const Page: React.FC = () => {
);
};
+// Platform-conditional page component
+const Page: React.FC = () => {
+ if (Platform.isTV) {
+ return ;
+ }
+ return ;
+};
+
export default Page;
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/person/[personId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/person/[personId].tsx
similarity index 98%
rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/person/[personId].tsx
rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/person/[personId].tsx
index a29e12809..c94a71bd4 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/person/[personId].tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/person/[personId].tsx
@@ -11,7 +11,7 @@ import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
-export default function page() {
+export default function JellyseerrPersonPage() {
const local = useLocalSearchParams();
const { t } = useTranslation();
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/_layout.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx
similarity index 72%
rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/_layout.tsx
rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx
index 072b2f931..f5bdc3e3b 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/_layout.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx
@@ -1,13 +1,14 @@
+import { Slot, Stack, withLayoutContext } from "expo-router";
import {
createMaterialTopTabNavigator,
MaterialTopTabNavigationEventMap,
MaterialTopTabNavigationOptions,
-} from "@react-navigation/material-top-tabs";
+} from "expo-router/js-top-tabs";
import type {
ParamListBase,
TabNavigationState,
-} from "@react-navigation/native";
-import { Stack, withLayoutContext } from "expo-router";
+} from "expo-router/react-navigation";
+import { Platform } from "react-native";
const { Navigator } = createMaterialTopTabNavigator();
@@ -19,6 +20,17 @@ export const Tab = withLayoutContext<
>(Navigator);
const Layout = () => {
+ // On TV, skip the Material Top Tab Navigator and render children directly
+ // The TV version handles its own tab navigation internally
+ if (Platform.isTV) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
return (
<>
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/channels.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/channels.tsx
similarity index 97%
rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/channels.tsx
rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/channels.tsx
index 6c9790b59..98b712acc 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/channels.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/channels.tsx
@@ -8,7 +8,7 @@ import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-export default function page() {
+export default function LiveTvChannelsPage() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const _insets = useSafeAreaInsets();
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/guide.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/guide.tsx
similarity index 99%
rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/guide.tsx
rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/guide.tsx
index 390e8eb60..a69318e68 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/guide.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/guide.tsx
@@ -17,7 +17,7 @@ const ITEMS_PER_PAGE = 20;
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
-export default function page() {
+export default function LiveTvGuidePage() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/programs.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/programs.tsx
similarity index 95%
rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/programs.tsx
rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/programs.tsx
index 812d084d9..f1471e3a0 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/programs.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/programs.tsx
@@ -2,12 +2,21 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
-import { ScrollView, View } from "react-native";
+import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
+import { TVLiveTVPage } from "@/components/livetv/TVLiveTVPage";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
export default function page() {
+ if (Platform.isTV) {
+ return ;
+ }
+
+ return ;
+}
+
+function MobileLiveTVPrograms() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/recordings.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/recordings.tsx
similarity index 86%
rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/recordings.tsx
rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/recordings.tsx
index 9a390162a..cc482c557 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/recordings.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/recordings.tsx
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
-export default function page() {
+export default function LiveTvRecordingsPage() {
const { t } = useTranslation();
return (
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/album/[albumId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/album/[albumId].tsx
new file mode 100644
index 000000000..1d1b7620e
--- /dev/null
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/album/[albumId].tsx
@@ -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(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 (
+
+
+
+ );
+ }
+
+ if (!album) {
+ return (
+
+ {t("music.album_not_found")}
+
+ );
+ }
+
+ return (
+
+ {/* Album artwork */}
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Album info */}
+
+ {album.Name}
+
+
+ {album.AlbumArtist || album.Artists?.join(", ")}
+
+
+ {album.ProductionYear && `${album.ProductionYear} • `}
+ {tracks?.length} tracks • {totalDuration}
+
+
+ {/* Play buttons */}
+
+
+
+
+ {t("music.play")}
+
+
+
+
+
+ {t("music.shuffle")}
+
+
+
+ {isDownloading ? (
+
+ ) : (
+
+ )}
+
+
+
+ }
+ renderItem={({ item, index }) => (
+
+ )}
+ keyExtractor={(item) => item.Id!}
+ ListFooterComponent={
+ <>
+
+
+
+ >
+ }
+ />
+ );
+}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/artist/[artistId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/artist/[artistId].tsx
new file mode 100644
index 000000000..fe2631e9d
--- /dev/null
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/artist/[artistId].tsx
@@ -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(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 (
+
+
+
+ );
+ }
+
+ if (!artist) {
+ return (
+
+ {t("music.artist_not_found")}
+
+ );
+ }
+
+ 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 (
+
+ {/* Artist image */}
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Artist info */}
+
+ {artist.Name}
+
+
+ {albums?.length || 0} {t("music.tabs.albums").toLowerCase()}
+
+
+ {/* Play button */}
+ {topTracks && topTracks.length > 0 && (
+
+
+
+ {t("music.play_top_tracks")}
+
+
+ )}
+
+ }
+ renderItem={({ item: section }) => (
+
+ {section.title}
+ {section.type === "albums" ? (
+ item.Id!}
+ renderItem={(item) => }
+ />
+ ) : (
+ section.data
+ .slice(0, 5)
+ .map((track, index) => (
+
+ ))
+ )}
+
+ )}
+ keyExtractor={(item) => item.id}
+ ListFooterComponent={
+ <>
+
+
+
+ >
+ }
+ />
+ );
+}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/playlist/[playlistId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/playlist/[playlistId].tsx
new file mode 100644
index 000000000..346e40aa0
--- /dev/null
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/playlist/[playlistId].tsx
@@ -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(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: () => (
+ setPlaylistOptionsOpen(true)}
+ className='p-1.5'
+ >
+
+
+ ),
+ });
+ }, [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 (
+
+
+
+ );
+ }
+
+ if (!playlist) {
+ return (
+
+
+ {t("music.playlist_not_found")}
+
+
+ );
+ }
+
+ return (
+
+ {/* Playlist artwork */}
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Playlist info */}
+
+ {playlist.Name}
+
+
+ {tracks?.length} tracks • {totalDuration}
+
+
+ {/* Play buttons */}
+
+
+
+
+ {t("music.play")}
+
+
+
+
+
+ {t("music.shuffle")}
+
+
+
+ {isDownloading ? (
+
+ ) : (
+
+ )}
+
+
+
+ }
+ renderItem={({ item, index }) => (
+
+ )}
+ keyExtractor={(item) => item.Id!}
+ ListFooterComponent={
+ <>
+
+
+
+
+ >
+ }
+ />
+ );
+}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/persons/[personId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/persons/[personId].tsx
similarity index 91%
rename from app/(auth)/(tabs)/(home,libraries,search,favorites)/persons/[personId].tsx
rename to app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/persons/[personId].tsx
index f2f8dcafe..a6dde1e0a 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/persons/[personId].tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/persons/[personId].tsx
@@ -6,7 +6,7 @@ import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
-import { View } from "react-native";
+import { Platform, View } from "react-native";
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorizontalScroll";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
@@ -15,6 +15,7 @@ import { Loader } from "@/components/Loader";
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
+import { TVActorPage } from "@/components/persons/TVActorPage";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
@@ -23,6 +24,16 @@ import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
const page: React.FC = () => {
const local = useLocalSearchParams();
const { personId } = local as { personId: string };
+
+ // Render TV-optimized page on TV platforms
+ if (Platform.isTV) {
+ return ;
+ }
+
+ return ;
+};
+
+const MobileActorPage: React.FC<{ personId: string }> = ({ personId }) => {
const { t } = useTranslation();
const [api] = useAtom(apiAtom);
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx
new file mode 100644
index 000000000..cc4b21b98
--- /dev/null
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx
@@ -0,0 +1,232 @@
+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 { TVSeriesPage } from "@/components/series/TVSeriesPage";
+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,
+ refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
+ 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 * 1000,
+ refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
+ 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 ? (
+
+
+ {!Platform.isTV && (
+ (
+
+ )}
+ DownloadedIconComponent={() => (
+
+ )}
+ />
+ )}
+
+ ) : null,
+ });
+ }, [allEpisodes, isLoading, item, isOffline]);
+
+ // For offline mode, we can show the page even without backdropUrl
+ if (!item || (!isOffline && !backdropUrl)) return null;
+
+ // TV version
+ if (Platform.isTV) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ ) : (
+
+ )
+ }
+ logo={
+ logoUrl ? (
+
+ ) : undefined
+ }
+ >
+
+
+ {!isOffline && (
+
+
+
+ )}
+
+
+
+
+ );
+};
+
+export default page;
diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
index 481881fcd..cdb1a181f 100644
--- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
+++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
@@ -2,6 +2,7 @@ import type {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
+ ItemFilter,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getFilterApi,
@@ -10,24 +11,51 @@ import {
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
-import { useLocalSearchParams, useNavigation } from "expo-router";
+import { Image } from "expo-image";
+import {
+ useFocusEffect,
+ useLocalSearchParams,
+ useNavigation,
+} from "expo-router";
import { useAtom } from "jotai";
-import React, { useCallback, useEffect, useMemo } from "react";
+import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
-import { FlatList, useWindowDimensions, View } from "react-native";
+import {
+ BackHandler,
+ FlatList,
+ Platform,
+ ScrollView,
+ 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 {
+ getItemNavigation,
+ TouchableItemRouter,
+} from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
+import { TVFilterButton, TVFocusablePoster } from "@/components/tv";
+import { TVPosterCard } from "@/components/tv/TVPosterCard";
+import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation";
+import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
+import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
+import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
+ FilterByOption,
+ FilterByPreferenceAtom,
+ filterByAtom,
genreFilterAtom,
+ getFilterByPreference,
getSortByPreference,
getSortOrderPreference,
SortByOption,
@@ -39,13 +67,30 @@ import {
sortOrderOptions,
sortOrderPreferenceAtom,
tagsFilterAtom,
+ useFilterOptions,
yearFilterAtom,
} from "@/utils/atoms/filters";
+import { useSettings } from "@/utils/atoms/settings";
+import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+
+const TV_ITEM_GAP = 20;
+const TV_HORIZONTAL_PADDING = 60;
+const _TV_SCALE_PADDING = 20;
+const TV_PLAYLIST_SQUARE_SIZE = 180;
const Page = () => {
- const searchParams = useLocalSearchParams();
- const { libraryId } = searchParams as { libraryId: string };
+ const searchParams = useLocalSearchParams() as {
+ libraryId: string;
+ sortBy?: string;
+ sortOrder?: string;
+ filterBy?: string;
+ fromSeeAll?: string;
+ };
+ const { libraryId, fromSeeAll } = searchParams;
+ const typography = useScaledTVTypography();
+ const posterSizes = useScaledTVPosterSizes();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { width: screenWidth } = useWindowDimensions();
@@ -54,28 +99,111 @@ 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,
);
const { orientation } = useOrientation();
+ // Fallback refresh for newly added content when returning to the library
+ // (primary path is the LibraryChanged WebSocket event).
+ useRefreshLibraryOnFocus();
+
const { t } = useTranslation();
+ const router = useRouter();
+ const { showOptions } = useTVOptionModal();
+
+ // When this library detail was opened from the home "See All" button, its
+ // libraries stack is just [detail], so the default TV Back would exit to home.
+ // Intercept Back (scoped to while this screen is focused via useFocusEffect) and
+ // route to the library list instead, so the user can switch libraries. Normal
+ // entries from the list keep their native pop-to-list behavior.
+ useFocusEffect(
+ useCallback(() => {
+ if (!Platform.isTV || fromSeeAll !== "true") return;
+ const sub = BackHandler.addEventListener("hardwareBackPress", () => {
+ router.replace("/(auth)/(tabs)/(libraries)");
+ return true;
+ });
+ return () => sub.remove();
+ }, [fromSeeAll, router]),
+ );
+ const { showItemActions } = useTVItemActionModal();
+
+ // TV Filter queries
+ const { data: tvGenreOptions } = useQuery({
+ queryKey: ["filters", "Genres", "tvGenreFilter", libraryId],
+ queryFn: async () => {
+ if (!api) return [];
+ const response = await getFilterApi(api).getQueryFiltersLegacy({
+ userId: user?.Id,
+ parentId: libraryId,
+ });
+ return response.data.Genres || [];
+ },
+ enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
+ });
+
+ const { data: tvYearOptions } = useQuery({
+ queryKey: ["filters", "Years", "tvYearFilter", libraryId],
+ queryFn: async () => {
+ if (!api) return [];
+ const response = await getFilterApi(api).getQueryFiltersLegacy({
+ userId: user?.Id,
+ parentId: libraryId,
+ });
+ return response.data.Years || [];
+ },
+ enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
+ });
+
+ const { data: tvTagOptions } = useQuery({
+ queryKey: ["filters", "Tags", "tvTagFilter", libraryId],
+ queryFn: async () => {
+ if (!api) return [];
+ const response = await getFilterApi(api).getQueryFiltersLegacy({
+ userId: user?.Id,
+ parentId: libraryId,
+ });
+ return response.data.Tags || [];
+ },
+ enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
+ });
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 +211,11 @@ const Page = () => {
sortByPreference,
_setSortOrder,
_setSortBy,
+ filterByPreference,
+ _setFilterBy,
+ searchParams.sortBy,
+ searchParams.sortOrder,
+ searchParams.filterBy,
]);
const setSortBy = useCallback(
@@ -100,17 +233,35 @@ 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(() => {
+ if (Platform.isTV) {
+ // TV uses flexWrap, so nrOfCols is just for mobile
+ return 1;
+ }
if (screenWidth < 300) return 2;
if (screenWidth < 500) return 3;
if (screenWidth < 800) return 5;
@@ -140,6 +291,23 @@ const Page = () => {
});
}, [library]);
+ // If this See-All detail was deep-linked on top of the libraries index, collapse
+ // the libraries stack to just this screen. Otherwise the stack is [index, detail],
+ // which the native bottom tab reliably auto-pops back to the index (the detail
+ // "bounces" to the library list ~0.5s after opening). With [detail] alone it stays
+ // put, and Back is handled explicitly by the fromSeeAll interceptor above.
+ const didCollapseRef = useRef(false);
+ useEffect(() => {
+ if (!Platform.isTV || fromSeeAll !== "true" || didCollapseRef.current)
+ return;
+ const state = navigation.getState();
+ if (state?.routes && state.routes.length > 1) {
+ didCollapseRef.current = true;
+ const top = state.routes[state.routes.length - 1];
+ navigation.reset({ index: 0, routes: [top] } as any);
+ }
+ }, [navigation, fromSeeAll]);
+
const fetchItems = useCallback(
async ({
pageParam,
@@ -158,6 +326,12 @@ 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";
+ } else if (library.CollectionType === "playlists") {
+ itemType = "Playlist";
}
const response = await getItemsApi(api).getItems({
@@ -168,6 +342,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,
@@ -176,6 +351,9 @@ const Page = () => {
tags: selectedTags,
years: selectedYears.map((year) => Number.parseInt(year, 10)),
includeItemTypes: itemType ? [itemType] : undefined,
+ ...(Platform.isTV && library.CollectionType === "playlists"
+ ? { mediaTypes: ["Video"] }
+ : {}),
});
return response.data || null;
@@ -190,6 +368,7 @@ const Page = () => {
selectedTags,
sortBy,
sortOrder,
+ filterBy,
],
);
@@ -203,6 +382,7 @@ const Page = () => {
selectedTags,
sortBy,
sortOrder,
+ filterBy,
],
queryFn: fetchItems,
getNextPageParam: (lastPage, pages) => {
@@ -264,11 +444,93 @@ const Page = () => {
),
- [orientation],
+ [orientation, nrOfCols],
+ );
+
+ const renderTVItem = useCallback(
+ (item: BaseItemDto) => {
+ const handlePress = () => {
+ if (item.Type === "Playlist") {
+ router.push({
+ pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
+ params: { libraryId: item.Id! },
+ });
+ return;
+ }
+ const navTarget = getItemNavigation(item, "(libraries)");
+ router.push(navTarget as any);
+ };
+
+ // Special rendering for Playlist items (square thumbnails)
+ if (item.Type === "Playlist") {
+ const playlistImageUrl = getPrimaryImageUrl({
+ api,
+ item,
+ width: TV_PLAYLIST_SQUARE_SIZE * 2,
+ });
+
+ return (
+
+ showItemActions(item)}
+ >
+
+
+
+
+
+
+ {item.Name}
+
+
+
+ );
+ }
+
+ return (
+ showItemActions(item)}
+ width={posterSizes.poster}
+ />
+ );
+ },
+ [router, showItemActions, api, typography],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
-
+ const generalFilters = useFilterOptions();
+ const settings = useSettings();
const ListHeaderComponent = useCallback(
() => (
{
/>
),
},
+ {
+ key: "filterOptions",
+ component: (
+ 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}
@@ -424,9 +706,194 @@ const Page = () => {
sortOrder,
setSortOrder,
isFetching,
+ filterBy,
+ setFilter,
+ settings,
],
);
+ // TV Filter bar header
+ const hasActiveFilters =
+ selectedGenres.length > 0 ||
+ selectedYears.length > 0 ||
+ selectedTags.length > 0 ||
+ filterBy.length > 0;
+
+ const resetAllFilters = useCallback(() => {
+ setSelectedGenres([]);
+ setSelectedYears([]);
+ setSelectedTags([]);
+ _setFilterBy([]);
+ }, [setSelectedGenres, setSelectedYears, setSelectedTags, _setFilterBy]);
+
+ // TV Filter options - with "All" option for clearable filters
+ const tvGenreFilterOptions = useMemo(
+ (): TVOptionItem[] => [
+ {
+ label: t("library.filters.all"),
+ value: "__all__",
+ selected: selectedGenres.length === 0,
+ },
+ ...(tvGenreOptions || []).map((genre) => ({
+ label: genre,
+ value: genre,
+ selected: selectedGenres.includes(genre),
+ })),
+ ],
+ [tvGenreOptions, selectedGenres, t],
+ );
+
+ const tvYearFilterOptions = useMemo(
+ (): TVOptionItem[] => [
+ {
+ label: t("library.filters.all"),
+ value: "__all__",
+ selected: selectedYears.length === 0,
+ },
+ ...(tvYearOptions || []).map((year) => ({
+ label: String(year),
+ value: String(year),
+ selected: selectedYears.includes(String(year)),
+ })),
+ ],
+ [tvYearOptions, selectedYears, t],
+ );
+
+ const tvTagFilterOptions = useMemo(
+ (): TVOptionItem[] => [
+ {
+ label: t("library.filters.all"),
+ value: "__all__",
+ selected: selectedTags.length === 0,
+ },
+ ...(tvTagOptions || []).map((tag) => ({
+ label: tag,
+ value: tag,
+ selected: selectedTags.includes(tag),
+ })),
+ ],
+ [tvTagOptions, selectedTags, t],
+ );
+
+ const tvSortByOptions = useMemo(
+ (): TVOptionItem[] =>
+ sortOptions.map((option) => ({
+ label: option.value,
+ value: option.key,
+ selected: sortBy[0] === option.key,
+ })),
+ [sortBy],
+ );
+
+ const tvSortOrderOptions = useMemo(
+ (): TVOptionItem[] =>
+ sortOrderOptions.map((option) => ({
+ label: option.value,
+ value: option.key,
+ selected: sortOrder[0] === option.key,
+ })),
+ [sortOrder],
+ );
+
+ const tvFilterByOptions = useMemo(
+ (): TVOptionItem[] => [
+ {
+ label: t("library.filters.all"),
+ value: "__all__",
+ selected: filterBy.length === 0,
+ },
+ ...generalFilters.map((option) => ({
+ label: option.value,
+ value: option.key,
+ selected: filterBy.includes(option.key),
+ })),
+ ],
+ [filterBy, generalFilters, t],
+ );
+
+ // TV Filter handlers using navigation-based modal
+ const handleShowGenreFilter = useCallback(() => {
+ showOptions({
+ title: t("library.filters.genres"),
+ options: tvGenreFilterOptions,
+ onSelect: (value: string) => {
+ if (value === "__all__") {
+ setSelectedGenres([]);
+ } else if (selectedGenres.includes(value)) {
+ setSelectedGenres(selectedGenres.filter((g) => g !== value));
+ } else {
+ setSelectedGenres([...selectedGenres, value]);
+ }
+ },
+ });
+ }, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
+
+ const handleShowYearFilter = useCallback(() => {
+ showOptions({
+ title: t("library.filters.years"),
+ options: tvYearFilterOptions,
+ onSelect: (value: string) => {
+ if (value === "__all__") {
+ setSelectedYears([]);
+ } else if (selectedYears.includes(value)) {
+ setSelectedYears(selectedYears.filter((y) => y !== value));
+ } else {
+ setSelectedYears([...selectedYears, value]);
+ }
+ },
+ });
+ }, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
+
+ const handleShowTagFilter = useCallback(() => {
+ showOptions({
+ title: t("library.filters.tags"),
+ options: tvTagFilterOptions,
+ onSelect: (value: string) => {
+ if (value === "__all__") {
+ setSelectedTags([]);
+ } else if (selectedTags.includes(value)) {
+ setSelectedTags(selectedTags.filter((tag) => tag !== value));
+ } else {
+ setSelectedTags([...selectedTags, value]);
+ }
+ },
+ });
+ }, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
+
+ const handleShowSortByFilter = useCallback(() => {
+ showOptions({
+ title: t("library.filters.sort_by"),
+ options: tvSortByOptions,
+ onSelect: (value: SortByOption) => {
+ setSortBy([value]);
+ },
+ });
+ }, [showOptions, t, tvSortByOptions, setSortBy]);
+
+ const handleShowSortOrderFilter = useCallback(() => {
+ showOptions({
+ title: t("library.filters.sort_order"),
+ options: tvSortOrderOptions,
+ onSelect: (value: SortOrderOption) => {
+ setSortOrder([value]);
+ },
+ });
+ }, [showOptions, t, tvSortOrderOptions, setSortOrder]);
+
+ const handleShowFilterByFilter = useCallback(() => {
+ showOptions({
+ title: t("library.filters.filter_by"),
+ options: tvFilterByOptions,
+ onSelect: (value: string) => {
+ if (value === "__all__") {
+ _setFilterBy([]);
+ } else {
+ setFilter([value as FilterByOption]);
+ }
+ },
+ });
+ }, [showOptions, t, tvFilterByOptions, setFilter, _setFilterBy]);
+
const insets = useSafeAreaInsets();
if (isLoading || isLibraryLoading)
@@ -436,43 +903,176 @@ const Page = () => {
);
+ // Mobile return
+ if (!Platform.isTV) {
+ return (
+
+
+ {t("library.no_results")}
+
+
+ }
+ contentInsetAdjustmentBehavior='automatic'
+ data={flatData}
+ renderItem={renderItem}
+ extraData={[orientation, nrOfCols]}
+ keyExtractor={keyExtractor}
+ numColumns={nrOfCols}
+ onEndReached={() => {
+ if (hasNextPage) {
+ fetchNextPage();
+ }
+ }}
+ onEndReachedThreshold={1}
+ ListHeaderComponent={ListHeaderComponent}
+ contentContainerStyle={{
+ paddingBottom: 24,
+ paddingLeft: insets.left,
+ paddingRight: insets.right,
+ }}
+ ItemSeparatorComponent={() => (
+
+ )}
+ />
+ );
+ }
+
+ // TV return with filter bar
return (
-
-
- {t("library.no_results")}
-
-
- }
- contentInsetAdjustmentBehavior='automatic'
- data={flatData}
- renderItem={renderItem}
- extraData={[orientation, nrOfCols]}
- keyExtractor={keyExtractor}
- numColumns={nrOfCols}
- onEndReached={() => {
- if (hasNextPage) {
+ {
+ // Load more when near bottom
+ const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
+ const isNearBottom =
+ layoutMeasurement.height + contentOffset.y >=
+ contentSize.height - 500;
+ if (isNearBottom && hasNextPage && !isFetching) {
fetchNextPage();
}
}}
- onEndReachedThreshold={1}
- ListHeaderComponent={ListHeaderComponent}
- contentContainerStyle={{
- paddingBottom: 24,
- paddingLeft: insets.left,
- paddingRight: insets.right,
- }}
- ItemSeparatorComponent={() => (
+ scrollEventThrottle={400}
+ >
+ {/* Filter bar */}
+
+ {hasActiveFilters && (
+
+ )}
+ 0
+ ? `${selectedGenres.length} selected`
+ : t("library.filters.all")
+ }
+ onPress={handleShowGenreFilter}
+ hasTVPreferredFocus={!hasActiveFilters}
+ hasActiveFilter={selectedGenres.length > 0}
+ />
+ 0
+ ? `${selectedYears.length} selected`
+ : t("library.filters.all")
+ }
+ onPress={handleShowYearFilter}
+ hasActiveFilter={selectedYears.length > 0}
+ />
+ 0
+ ? `${selectedTags.length} selected`
+ : t("library.filters.all")
+ }
+ onPress={handleShowTagFilter}
+ hasActiveFilter={selectedTags.length > 0}
+ />
+ o.key === sortBy[0])?.value || ""}
+ onPress={handleShowSortByFilter}
+ />
+ o.key === sortOrder[0])?.value || ""
+ }
+ onPress={handleShowSortOrderFilter}
+ />
+ 0
+ ? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
+ : t("library.filters.all")
+ }
+ onPress={handleShowFilterByFilter}
+ hasActiveFilter={filterBy.length > 0}
+ />
+
+
+ {/* Grid with flexWrap */}
+ {flatData.length === 0 ? (
+ >
+
+ {t("library.no_results")}
+
+
+ ) : (
+
+ {flatData.map((item) => renderTVItem(item))}
+
)}
- />
+
+ {/* Loading indicator */}
+ {isFetching && (
+
+
+
+ )}
+
);
};
diff --git a/app/(auth)/(tabs)/(libraries)/_layout.tsx b/app/(auth)/(tabs)/(libraries)/_layout.tsx
index acc7f8173..ed31b438f 100644
--- a/app/(auth)/(tabs)/(libraries)/_layout.tsx
+++ b/app/(auth)/(tabs)/(libraries)/_layout.tsx
@@ -166,7 +166,7 @@ export default function IndexLayout() {
open={dropdownOpen}
onOpenChange={setDropdownOpen}
trigger={
-
+
;
+ }
- 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;
- },
- staleTime: 60,
- });
-
- const libraries = useMemo(
- () =>
- data
- ?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
- .filter((l) => l.CollectionType !== "music")
- .filter((l) => l.CollectionType !== "books") || [],
- [data, settings?.hiddenLibraries],
- );
-
- useEffect(() => {
- for (const item of data || []) {
- queryClient.prefetchQuery({
- queryKey: ["library", item.Id],
- queryFn: async () => {
- if (!item.Id || !user?.Id || !api) return null;
- const response = await getUserLibraryApi(api).getItem({
- itemId: item.Id,
- userId: user?.Id,
- });
- return response.data;
- },
- staleTime: 60 * 1000,
- });
- }
- }, [data]);
-
- const insets = useSafeAreaInsets();
-
- if (isLoading)
- return (
-
-
-
- );
-
- if (!libraries)
- return (
-
-
- {t("library.no_libraries_found")}
-
-
- );
-
- return (
- }
- keyExtractor={(item) => item.Id || ""}
- ItemSeparatorComponent={() =>
- settings?.libraryOptions?.display === "row" ? (
-
- ) : (
-
- )
- }
- />
- );
+ return ;
}
diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx
new file mode 100644
index 000000000..bf21238ad
--- /dev/null
+++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx
@@ -0,0 +1,87 @@
+import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
+import {
+ createMaterialTopTabNavigator,
+ MaterialTopTabNavigationEventMap,
+ MaterialTopTabNavigationOptions,
+} from "expo-router/js-top-tabs";
+import type {
+ ParamListBase,
+ TabNavigationState,
+} from "expo-router/react-navigation";
+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,
+ MaterialTopTabNavigationEventMap
+>(Navigator);
+
+const Layout = () => {
+ const { libraryId } = useLocalSearchParams<{ libraryId: string }>();
+ const { t } = useTranslation();
+
+ return (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default Layout;
diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx
new file mode 100644
index 000000000..10fffbe71
--- /dev/null
+++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx
@@ -0,0 +1,120 @@
+import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
+import { FlashList } from "@shopify/flash-list";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { useLocalSearchParams } from "expo-router";
+import { useRoute } from "expo-router/react-navigation";
+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();
+ 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 (
+
+
+
+ );
+ }
+
+ if (albums.length === 0) {
+ return (
+
+ {t("music.no_albums")}
+
+ );
+ }
+
+ return (
+
+
+ }
+ onEndReached={handleEndReached}
+ onEndReachedThreshold={0.5}
+ renderItem={({ item }) => }
+ keyExtractor={(item) => item.Id!}
+ ListFooterComponent={
+ isFetchingNextPage ? (
+
+
+
+ ) : null
+ }
+ />
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx
new file mode 100644
index 000000000..f268e0b24
--- /dev/null
+++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx
@@ -0,0 +1,157 @@
+import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api";
+import { FlashList } from "@shopify/flash-list";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { useLocalSearchParams } from "expo-router";
+import { useRoute } from "expo-router/react-navigation";
+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();
+ 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 (
+
+
+
+ );
+ }
+
+ if (!libraryId) {
+ return (
+
+
+ Missing music library id.
+
+
+ );
+ }
+
+ // Only show loading if we have no cached data to display
+ if (isLoading && artists.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ // 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 (
+
+
+ Failed to load artists: {(error as Error)?.message || "Unknown error"}
+
+
+ );
+ }
+
+ if (artists.length === 0) {
+ return (
+
+ {t("music.no_artists")}
+
+ );
+ }
+
+ return (
+
+
+ }
+ onEndReached={handleEndReached}
+ onEndReachedThreshold={0.5}
+ renderItem={({ item }) => }
+ keyExtractor={(item) => item.Id!}
+ ListFooterComponent={
+ isFetchingNextPage ? (
+
+
+
+ ) : null
+ }
+ />
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx
new file mode 100644
index 000000000..85b4be5fd
--- /dev/null
+++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx
@@ -0,0 +1,234 @@
+import { Ionicons } from "@expo/vector-icons";
+import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
+import { FlashList } from "@shopify/flash-list";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { useLocalSearchParams } from "expo-router";
+import { useNavigation, useRoute } from "expo-router/react-navigation";
+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();
+ 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("SortName");
+ const [sortOrder, setSortOrder] = useState("Ascending");
+
+ const isReady = Boolean(api && user?.Id && libraryId);
+
+ const handleSortChange = useCallback(
+ (newSortBy: PlaylistSortOption, newSortOrder: PlaylistSortOrder) => {
+ setSortBy(newSortBy);
+ setSortOrder(newSortOrder);
+ },
+ [],
+ );
+
+ useLayoutEffect(() => {
+ navigation.setOptions({
+ headerRight: () => (
+ setCreateModalOpen(true)}
+ className='mr-4'
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
+ >
+
+
+ ),
+ });
+ }, [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 (
+
+
+
+ );
+ }
+
+ if (!libraryId) {
+ return (
+
+
+ Missing music library id.
+
+
+ );
+ }
+
+ // Only show loading if we have no cached data to display
+ if (isLoading && playlists.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ // 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 (
+
+
+ Failed to load playlists:{" "}
+ {(error as Error)?.message || "Unknown error"}
+
+
+ );
+ }
+
+ if (playlists.length === 0) {
+ return (
+
+ {t("music.no_playlists")}
+ setCreateModalOpen(true)}
+ className='flex-row items-center bg-purple-600 px-6 py-3 rounded-full'
+ >
+
+
+ {t("music.playlists.create_playlist")}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ }
+ onEndReached={handleEndReached}
+ onEndReachedThreshold={0.5}
+ ListHeaderComponent={
+ setSortSheetOpen(true)}
+ className='flex-row items-center mb-2 py-1'
+ >
+
+
+ {t(
+ `music.sort.${sortBy === "SortName" ? "alphabetical" : "date_created"}`,
+ )}
+
+
+
+ }
+ renderItem={({ item }) => }
+ keyExtractor={(item) => item.Id!}
+ ListFooterComponent={
+ isFetchingNextPage ? (
+
+
+
+ ) : null
+ }
+ />
+
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx
new file mode 100644
index 000000000..81c2272f8
--- /dev/null
+++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx
@@ -0,0 +1,333 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
+import { FlashList } from "@shopify/flash-list";
+import { useQuery } from "@tanstack/react-query";
+import { useLocalSearchParams } from "expo-router";
+import { useRoute } from "expo-router/react-navigation";
+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();
+ 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(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(
+ `/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 (
+
+
+
+ );
+ }
+
+ if (!libraryId) {
+ return (
+
+
+ Missing music library id.
+
+
+ );
+ }
+
+ // Only show loading if we have no cached data to display
+ if (isLoading && sections.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ // 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 (
+
+
+ Failed to load music: {msg}
+
+
+ );
+ }
+
+ if (sections.length === 0) {
+ return (
+
+ {t("music.no_suggestions")}
+
+ );
+ }
+
+ return (
+
+
+ }
+ renderItem={({ item: section }) => (
+
+ {section.title}
+ {section.type === "albums" ? (
+ item.Id!}
+ renderItem={(item) => }
+ />
+ ) : (
+ section.data
+ .slice(0, 5)
+ .map((track, index, _tracks) => (
+
+ ))
+ )}
+
+ )}
+ keyExtractor={(item) => item.title}
+ />
+
+
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx
index bedf5ffa0..759dae858 100644
--- a/app/(auth)/(tabs)/(search)/index.tsx
+++ b/app/(auth)/(tabs)/(search)/index.tsx
@@ -3,10 +3,13 @@ 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, useSegments } from "expo-router";
import { useAtom } from "jotai";
+import { orderBy, uniqBy } from "lodash";
import {
useCallback,
useEffect,
@@ -19,11 +22,12 @@ 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 {
+ getItemNavigation,
+ TouchableItemRouter,
+} from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
import {
JellyseerrSearchSort,
@@ -35,10 +39,21 @@ 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 { TVSearchPage } from "@/components/search/TVSearchPage";
+import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
+import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
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 { MediaType } from "@/utils/jellyseerr/server/constants/media";
+import type {
+ MovieResult,
+ PersonResult,
+ TvResult,
+} from "@/utils/jellyseerr/server/models/Search";
+import { createStreamystatsApi } from "@/utils/streamystats";
type SearchType = "Library" | "Discover";
@@ -51,9 +66,13 @@ const exampleSearches = [
"The Mandalorian",
];
-export default function search() {
+export default function SearchPage() {
const params = useLocalSearchParams();
const insets = useSafeAreaInsets();
+ const router = useRouter();
+ const { showItemActions } = useTVItemActionModal();
+ const segments = useSegments();
+ const from = (segments as string[])[2] || "(search)";
const [user] = useAtom(userAtom);
@@ -67,7 +86,23 @@ export default function search() {
const [searchType, setSearchType] = useState("Library");
const [search, setSearch] = useState("");
- const [debouncedSearch] = useDebounce(search, 500);
+ const [debouncedSearch, setDebouncedSearch] = useState("");
+ const abortControllerRef = useRef(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 +132,11 @@ export default function search() {
async ({
types,
query,
+ signal,
}: {
types: BaseItemKind[];
query: string;
+ signal?: AbortSignal;
}): Promise => {
if (!api || !query) {
return [];
@@ -107,46 +144,144 @@ export default function search() {
try {
if (searchEngine === "Jellyfin") {
- const searchApi = await getItemsApi(api).getItems({
+ 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 = {
+ Movie: "movies",
+ Series: "series",
+ Episode: "episodes",
+ Person: "actors",
+ BoxSet: "movies",
+ Audio: "audio",
+ } as Record;
+
+ 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 [];
+ }
+
+ const url = `${settings.marlinServerUrl}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
+ .map((type) => encodeURIComponent(type))
+ .join("&includeItemTypes=")}`;
+
+ const response1 = await axios.get(url, { signal });
+
+ const ids = response1.data.ids;
+
+ if (!ids?.length) {
+ return [];
+ }
+
+ const response2 = await getItemsApi(api).getItems(
+ {
+ ids,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ },
+ { signal },
+ );
+
+ return (response2.data.Items as BaseItemDto[]) || [];
+ } catch (error) {
+ // Silently handle aborted requests
+ if (error instanceof Error && error.name === "AbortError") {
+ return [];
+ }
+ return [];
+ }
+ },
+ [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 => {
+ 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[]) || [];
- }
- if (!settings?.marlinServerUrl) {
- return [];
- }
-
- const url = `${
- settings.marlinServerUrl
- }/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
- .map((type) => encodeURIComponent(type))
- .join("&includeItemTypes=")}`;
-
- const response1 = await axios.get(url);
-
- const ids = response1.data.ids;
-
- if (!ids || !ids.length) {
- return [];
- }
-
- const response2 = await getItemsApi(api).getItems({
- ids,
- enableImageTypes: ["Primary", "Backdrop", "Thumb"],
- });
-
- return (response2.data.Items as BaseItemDto[]) || [];
+ return (searchApi.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, user?.Id],
);
type HeaderSearchBarRef = {
@@ -170,6 +305,11 @@ export default function search() {
},
hideWhenScrolling: false,
autoFocus: false,
+ // Android: color of the user-typed text (was dark and unreadable on the dark header)
+ textColor: "#fff",
+ // Android: placeholder and icon color
+ hintTextColor: "#fff",
+ headerIconColor: "#fff",
},
});
}, [navigation]);
@@ -195,6 +335,7 @@ export default function search() {
searchFn({
query: debouncedSearch,
types: ["Movie"],
+ signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
@@ -205,6 +346,7 @@ export default function search() {
searchFn({
query: debouncedSearch,
types: ["Series"],
+ signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
@@ -215,6 +357,7 @@ export default function search() {
searchFn({
query: debouncedSearch,
types: ["Episode"],
+ signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
@@ -225,6 +368,7 @@ export default function search() {
searchFn({
query: debouncedSearch,
types: ["BoxSet"],
+ signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
@@ -235,6 +379,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 +435,201 @@ 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]);
+
+ // TV item press handler
+ const handleItemPress = useCallback(
+ (item: BaseItemDto) => {
+ const navigation = getItemNavigation(item, from);
+ router.push(navigation as any);
+ },
+ [from, router],
+ );
+
+ // Jellyseerr search for TV
+ const { data: jellyseerrTVResults, isFetching: jellyseerrTVLoading } =
+ useQuery({
+ queryKey: ["search", "jellyseerr", "tv", debouncedSearch],
+ queryFn: async () => {
+ const params = {
+ query: new URLSearchParams(debouncedSearch || "").toString(),
+ };
+ return await Promise.all([
+ jellyseerrApi?.search({ ...params, page: 1 }),
+ jellyseerrApi?.search({ ...params, page: 2 }),
+ jellyseerrApi?.search({ ...params, page: 3 }),
+ jellyseerrApi?.search({ ...params, page: 4 }),
+ ]).then((all) =>
+ uniqBy(
+ all.flatMap((v) => v?.results || []),
+ "id",
+ ),
+ );
+ },
+ enabled:
+ Platform.isTV &&
+ !!jellyseerrApi &&
+ searchType === "Discover" &&
+ debouncedSearch.length > 0,
+ });
+
+ // Process Jellyseerr results for TV
+ const jellyseerrMovieResults = useMemo(
+ () =>
+ orderBy(
+ jellyseerrTVResults?.filter(
+ (r) => r.mediaType === MediaType.MOVIE,
+ ) as MovieResult[],
+ [(m) => m?.title?.toLowerCase() === debouncedSearch.toLowerCase()],
+ "desc",
+ ),
+ [jellyseerrTVResults, debouncedSearch],
+ );
+
+ const jellyseerrTvResults = useMemo(
+ () =>
+ orderBy(
+ jellyseerrTVResults?.filter(
+ (r) => r.mediaType === MediaType.TV,
+ ) as TvResult[],
+ [(t) => t?.name?.toLowerCase() === debouncedSearch.toLowerCase()],
+ "desc",
+ ),
+ [jellyseerrTVResults, debouncedSearch],
+ );
+
+ const jellyseerrPersonResults = useMemo(
+ () =>
+ orderBy(
+ jellyseerrTVResults?.filter(
+ (r) => r.mediaType === "person",
+ ) as PersonResult[],
+ [(p) => p?.name?.toLowerCase() === debouncedSearch.toLowerCase()],
+ "desc",
+ ),
+ [jellyseerrTVResults, debouncedSearch],
+ );
+
+ const jellyseerrTVNoResults = useMemo(() => {
+ return (
+ !jellyseerrMovieResults?.length &&
+ !jellyseerrTvResults?.length &&
+ !jellyseerrPersonResults?.length
+ );
+ }, [jellyseerrMovieResults, jellyseerrTvResults, jellyseerrPersonResults]);
+
+ // Fetch discover settings for TV (when no search query in Discover mode)
+ const { data: discoverSliders } = useQuery({
+ queryKey: ["search", "jellyseerr", "discoverSettings", "tv"],
+ queryFn: async () => jellyseerrApi?.discoverSettings(),
+ enabled:
+ Platform.isTV &&
+ !!jellyseerrApi &&
+ searchType === "Discover" &&
+ debouncedSearch.length === 0,
+ });
+
+ // TV Jellyseerr press handlers
+ const handleJellyseerrMoviePress = useCallback(
+ (item: MovieResult) => {
+ router.push({
+ pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
+ params: {
+ mediaTitle: item.title,
+ releaseYear: String(new Date(item.releaseDate || "").getFullYear()),
+ canRequest: "true",
+ posterSrc: jellyseerrApi?.imageProxy(item.posterPath) || "",
+ mediaType: MediaType.MOVIE,
+ id: String(item.id),
+ backdropPath: item.backdropPath || "",
+ overview: item.overview || "",
+ },
+ });
+ },
+ [router, jellyseerrApi],
+ );
+
+ const handleJellyseerrTvPress = useCallback(
+ (item: TvResult) => {
+ router.push({
+ pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
+ params: {
+ mediaTitle: item.name,
+ releaseYear: String(new Date(item.firstAirDate || "").getFullYear()),
+ canRequest: "true",
+ posterSrc: jellyseerrApi?.imageProxy(item.posterPath) || "",
+ mediaType: MediaType.TV,
+ id: String(item.id),
+ backdropPath: item.backdropPath || "",
+ overview: item.overview || "",
+ },
+ });
+ },
+ [router, jellyseerrApi],
+ );
+
+ const handleJellyseerrPersonPress = useCallback(
+ (item: PersonResult) => {
+ router.push(`/(auth)/jellyseerr/person/${item.id}` as any);
+ },
+ [router],
+ );
+
+ // Render TV search page
+ if (Platform.isTV) {
+ return (
+
+ );
+ }
return (
- {/* */}
- {Platform.isTV && (
- {
- router.setParams({ q: "" });
- setSearch(text);
- }}
- keyboardType='default'
- returnKeyType='done'
- autoCapitalize='none'
- clearButtonMode='while-editing'
- maxLength={500}
- />
- )}
)}
/>
+ {/* Music search results */}
+ {
+ const imageUrl = getPrimaryImageUrl({ api, item });
+ return (
+
+
+ {imageUrl ? (
+
+ ) : (
+
+ 👤
+
+ )}
+
+
+ {item.Name}
+
+
+ );
+ }}
+ />
+ {
+ const imageUrl = getPrimaryImageUrl({ api, item });
+ return (
+
+
+ {imageUrl ? (
+
+ ) : (
+
+ 🎵
+
+ )}
+
+
+ {item.Name}
+
+
+ {item.AlbumArtist || item.Artists?.join(", ")}
+
+
+ );
+ }}
+ />
+ {
+ const imageUrl = getPrimaryImageUrl({ api, item });
+ return (
+
+
+ {imageUrl ? (
+
+ ) : (
+
+ 🎵
+
+ )}
+
+
+ {item.Name}
+
+
+ {item.Artists?.join(", ") || item.AlbumArtist}
+
+
+ );
+ }}
+ />
+ {
+ const imageUrl = getPrimaryImageUrl({ api, item });
+ return (
+
+
+ {imageUrl ? (
+
+ ) : (
+
+ 🎶
+
+ )}
+
+
+ {item.Name}
+
+
+ {item.ChildCount} tracks
+
+
+ );
+ }}
+ />
) : (
) : debouncedSearch.length === 0 ? (
-
+
{exampleSearches.map((e) => (
{
diff --git a/app/(auth)/(tabs)/(settings)/_layout.tsx b/app/(auth)/(tabs)/(settings)/_layout.tsx
new file mode 100644
index 000000000..4f1ce0354
--- /dev/null
+++ b/app/(auth)/(tabs)/(settings)/_layout.tsx
@@ -0,0 +1,21 @@
+import { Stack } from "expo-router";
+import { useTranslation } from "react-i18next";
+import { Platform } from "react-native";
+
+export default function SettingsLayout() {
+ const { t } = useTranslation();
+ return (
+
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(settings)/index.tsx b/app/(auth)/(tabs)/(settings)/index.tsx
new file mode 100644
index 000000000..52b86fb47
--- /dev/null
+++ b/app/(auth)/(tabs)/(settings)/index.tsx
@@ -0,0 +1,5 @@
+import SettingsTV from "@/app/(auth)/(tabs)/(home)/settings.tv";
+
+export default function SettingsTabScreen() {
+ return ;
+}
diff --git a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx
new file mode 100644
index 000000000..c649bdf62
--- /dev/null
+++ b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx
@@ -0,0 +1,451 @@
+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,
+ Platform,
+ RefreshControl,
+ ScrollView,
+ 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 {
+ getItemNavigation,
+ TouchableItemRouter,
+} from "@/components/common/TouchableItemRouter";
+import { ItemCardText } from "@/components/ItemCardText";
+import { ItemPoster } from "@/components/posters/ItemPoster";
+import { TVPosterCard } from "@/components/tv/TVPosterCard";
+import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
+import { useOrientation } from "@/hooks/useOrientation";
+import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
+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";
+
+const TV_ITEM_GAP = 20;
+const TV_HORIZONTAL_PADDING = 60;
+
+export default function WatchlistDetailScreen() {
+ const typography = useScaledTVTypography();
+ const posterSizes = useScaledTVPosterSizes();
+ const { t } = useTranslation();
+ const router = useRouter();
+ const { showItemActions } = useTVItemActionModal();
+ 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(() => {
+ // TV uses flexWrap, so nrOfCols is just for mobile
+ if (Platform.isTV) return 1;
+ 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: () => ,
+ headerRight: isOwner
+ ? () => (
+
+
+ router.push(`/(auth)/(tabs)/(watchlists)/edit/${watchlistId}`)
+ }
+ className='p-2'
+ >
+
+
+
+
+
+
+ )
+ : 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 renderTVItem = useCallback(
+ (item: BaseItemDto, index: number) => {
+ const handlePress = () => {
+ const navigation = getItemNavigation(item, "(watchlists)");
+ router.push(navigation as any);
+ };
+
+ return (
+ showItemActions(item)}
+ hasTVPreferredFocus={index === 0}
+ width={posterSizes.poster}
+ />
+ );
+ },
+ [router, showItemActions, posterSizes.poster],
+ );
+
+ const renderItem = useCallback(
+ ({ item, index }: { item: BaseItemDto; index: number }) => (
+ handleRemoveItem(item) : undefined}
+ >
+
+
+
+
+
+ ),
+ [isOwner, handleRemoveItem, orientation, nrOfCols],
+ );
+
+ const ListHeader = useMemo(
+ () =>
+ watchlist ? (
+
+ {watchlist.description && (
+
+ {watchlist.description}
+
+ )}
+
+
+
+
+ {items?.length ?? 0}{" "}
+ {(items?.length ?? 0) === 1
+ ? t("watchlists.item")
+ : t("watchlists.items")}
+
+
+
+
+
+ {watchlist.isPublic
+ ? t("watchlists.public")
+ : t("watchlists.private")}
+
+
+ {!isOwner && (
+
+ {t("watchlists.by_owner")}
+
+ )}
+
+
+ ) : null,
+ [watchlist, items?.length, isOwner, t],
+ );
+
+ const EmptyComponent = useMemo(
+ () => (
+
+
+
+ {t("watchlists.empty_watchlist")}
+
+ {isOwner && (
+
+ {t("watchlists.empty_watchlist_hint")}
+
+ )}
+
+ ),
+ [isOwner, t],
+ );
+
+ const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
+
+ if (watchlistLoading || itemsLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!watchlist) {
+ return (
+
+
+ {t("watchlists.not_found")}
+
+
+ );
+ }
+
+ // TV layout with ScrollView + flexWrap
+ if (Platform.isTV) {
+ return (
+
+ {/* Header */}
+
+ {watchlist.description && (
+
+ {watchlist.description}
+
+ )}
+
+
+
+
+ {items?.length ?? 0}{" "}
+ {(items?.length ?? 0) === 1
+ ? t("watchlists.item")
+ : t("watchlists.items")}
+
+
+
+
+
+ {watchlist.isPublic
+ ? t("watchlists.public")
+ : t("watchlists.private")}
+
+
+ {!isOwner && (
+
+ {t("watchlists.by_owner")}
+
+ )}
+
+
+
+ {/* Grid with flexWrap */}
+ {!items || items.length === 0 ? (
+
+
+
+ {t("watchlists.empty_watchlist")}
+
+
+ ) : (
+
+ {items.map((item, index) => renderTVItem(item, index))}
+
+ )}
+
+ );
+ }
+
+ // Mobile layout with FlashList
+ return (
+
+ }
+ renderItem={renderItem}
+ ItemSeparatorComponent={() => (
+
+ )}
+ />
+ );
+}
diff --git a/app/(auth)/(tabs)/(watchlists)/_layout.tsx b/app/(auth)/(tabs)/(watchlists)/_layout.tsx
new file mode 100644
index 000000000..c1ad57882
--- /dev/null
+++ b/app/(auth)/(tabs)/(watchlists)/_layout.tsx
@@ -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 (
+
+ (
+
+ router.push("/(auth)/(tabs)/(watchlists)/create")
+ }
+ className='p-1.5'
+ >
+
+
+ )
+ : undefined,
+ }}
+ />
+
+
+
+ {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
+
+ ))}
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(watchlists)/create.tsx b/app/(auth)/(tabs)/(watchlists)/create.tsx
new file mode 100644
index 000000000..af77d5dbc
--- /dev/null
+++ b/app/(auth)/(tabs)/(watchlists)/create.tsx
@@ -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(null);
+ const [defaultSortOrder, setDefaultSortOrder] =
+ useState("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 (
+
+
+ {/* Name */}
+
+
+ {t("watchlists.name_label")} *
+
+
+
+
+ {/* Description */}
+
+
+ {t("watchlists.description_label")}
+
+
+
+
+ {/* Public Toggle */}
+
+
+
+ {t("watchlists.is_public_label")}
+
+
+ {t("watchlists.is_public_description")}
+
+
+
+
+
+ {/* Content Type */}
+
+
+ {t("watchlists.allowed_type_label")}
+
+
+ {ITEM_TYPES.map((type) => (
+ setAllowedItemType(type.value)}
+ className={`px-4 py-2 rounded-lg ${allowedItemType === type.value ? "bg-purple-600" : "bg-neutral-800"}`}
+ >
+
+ {type.label}
+
+
+ ))}
+
+
+
+ {/* Sort Order */}
+
+
+ {t("watchlists.sort_order_label")}
+
+
+ {SORT_OPTIONS.map((sort) => (
+ setDefaultSortOrder(sort.value)}
+ className={`px-4 py-2 rounded-lg ${defaultSortOrder === sort.value ? "bg-purple-600" : "bg-neutral-800"}`}
+ >
+
+ {sort.label}
+
+
+ ))}
+
+
+
+ {/* Create Button */}
+
+
+
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(watchlists)/edit/[watchlistId].tsx b/app/(auth)/(tabs)/(watchlists)/edit/[watchlistId].tsx
new file mode 100644
index 000000000..5329d6a69
--- /dev/null
+++ b/app/(auth)/(tabs)/(watchlists)/edit/[watchlistId].tsx
@@ -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(null);
+ const [defaultSortOrder, setDefaultSortOrder] =
+ useState("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 (
+
+
+
+ );
+ }
+
+ if (!watchlist) {
+ return (
+
+
+ {t("watchlists.not_found")}
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Name */}
+
+
+ {t("watchlists.name_label")} *
+
+
+
+
+ {/* Description */}
+
+
+ {t("watchlists.description_label")}
+
+
+
+
+ {/* Public Toggle */}
+
+
+
+ {t("watchlists.is_public_label")}
+
+
+ {t("watchlists.is_public_description")}
+
+
+
+
+
+ {/* Content Type */}
+
+
+ {t("watchlists.allowed_type_label")}
+
+
+ {ITEM_TYPES.map((type) => (
+ setAllowedItemType(type.value)}
+ className={`px-4 py-2 rounded-lg ${allowedItemType === type.value ? "bg-purple-600" : "bg-neutral-800"}`}
+ >
+
+ {type.label}
+
+
+ ))}
+
+
+
+ {/* Sort Order */}
+
+
+ {t("watchlists.sort_order_label")}
+
+
+ {SORT_OPTIONS.map((sort) => (
+ setDefaultSortOrder(sort.value)}
+ className={`px-4 py-2 rounded-lg ${defaultSortOrder === sort.value ? "bg-purple-600" : "bg-neutral-800"}`}
+ >
+
+ {sort.label}
+
+
+ ))}
+
+
+
+ {/* Save Button */}
+
+
+
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(watchlists)/index.tsx b/app/(auth)/(tabs)/(watchlists)/index.tsx
new file mode 100644
index 000000000..76d446d49
--- /dev/null
+++ b/app/(auth)/(tabs)/(watchlists)/index.tsx
@@ -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 = ({
+ watchlist,
+ isOwner,
+ onPress,
+}) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ {watchlist.name}
+
+
+ {isOwner && (
+
+
+ {t("watchlists.you")}
+
+
+ )}
+
+
+
+
+ {watchlist.description && (
+
+ {watchlist.description}
+
+ )}
+
+
+
+
+
+ {watchlist.itemCount ?? 0}{" "}
+ {(watchlist.itemCount ?? 0) === 1
+ ? t("watchlists.item")
+ : t("watchlists.items")}
+
+
+ {watchlist.allowedItemType && (
+
+
+ {watchlist.allowedItemType}
+
+
+ )}
+
+
+ );
+};
+
+const EmptyState: React.FC<{ onCreatePress: () => void }> = ({
+ onCreatePress: _onCreatePress,
+}) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ {t("watchlists.empty_title")}
+
+
+ {t("watchlists.empty_description")}
+
+
+ );
+};
+
+const NotConfiguredState: React.FC = () => {
+ const { t } = useTranslation();
+ const router = useRouter();
+
+ return (
+
+
+
+ {t("watchlists.not_configured_title")}
+
+
+ {t("watchlists.not_configured_description")}
+
+
+
+ );
+};
+
+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 ;
+ }
+
+ if (!isLoading && (!watchlists || watchlists.length === 0)) {
+ return ;
+ }
+
+ return (
+
+ }
+ renderItem={({ item }) => {
+ if (item.type === "header") {
+ return (
+
+ {item.title}
+
+ );
+ }
+
+ return (
+ handleWatchlistPress(item.data.id)}
+ />
+ );
+ }}
+ getItemType={(item) => item.type}
+ />
+ );
+}
diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx
index e8a797281..45f246a5b 100644
--- a/app/(auth)/(tabs)/_layout.tsx
+++ b/app/(auth)/(tabs)/_layout.tsx
@@ -3,19 +3,34 @@ import {
type NativeBottomTabNavigationEventMap,
type NativeBottomTabNavigationOptions,
} from "@bottom-tabs/react-navigation";
+import { Stack, useSegments, withLayoutContext } from "expo-router";
import type {
ParamListBase,
TabNavigationState,
-} from "@react-navigation/native";
-import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
-import { useCallback } from "react";
+} from "expo-router/react-navigation";
+import { useCallback, useEffect, useMemo } from "react";
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 type { TVNavBarTab } from "@/components/tv/TVNavBar";
+import { TVNavBar } from "@/components/tv/TVNavBar";
import { Colors } from "@/constants/Colors";
+import useRouter from "@/hooks/useAppRouter";
+import {
+ isTabRoute,
+ useTVHomeBackHandler,
+ useTVTabRootBackHandler,
+} from "@/hooks/useTVBackHandler";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
-import { storage } from "@/utils/mmkv";
+
+// Music components are not available on tvOS (TrackPlayer not supported)
+const MiniPlayerBar = Platform.isTV
+ ? () => null
+ : require("@/components/music/MiniPlayerBar").MiniPlayerBar;
+const MusicPlaybackEngine = Platform.isTV
+ ? () => null
+ : require("@/components/music/MusicPlaybackEngine").MusicPlaybackEngine;
const { Navigator } = createNativeBottomTabNavigator();
@@ -26,28 +41,110 @@ export const NativeTabs = withLayoutContext<
NativeBottomTabNavigationEventMap
>(Navigator);
+const IS_ANDROID_TV = Platform.isTV && Platform.OS === "android";
+
+function TVTabLayout() {
+ const { settings } = useSettings();
+ const { t } = useTranslation();
+ const segments = useSegments();
+ const router = useRouter();
+
+ const currentTab = segments.find(isTabRoute);
+ const lastSegment = segments[segments.length - 1] ?? "";
+ const atTabRoot = isTabRoute(lastSegment) || lastSegment === "index";
+
+ const tabs: TVNavBarTab[] = useMemo(
+ () =>
+ [
+ { key: "(home)", label: t("tabs.home") },
+ { key: "(search)", label: t("tabs.search") },
+ { key: "(favorites)", label: t("tabs.favorites") },
+ !settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab
+ ? null
+ : { key: "(watchlists)", label: t("watchlists.title") },
+ { key: "(libraries)", label: t("tabs.library") },
+ !settings?.showCustomMenuLinks
+ ? null
+ : { key: "(custom-links)", label: t("tabs.custom_links") },
+ { key: "(settings)", label: t("tabs.settings") },
+ ].filter((tab): tab is TVNavBarTab => tab !== null),
+ [
+ settings?.streamyStatsServerUrl,
+ settings?.hideWatchlistsTab,
+ settings?.showCustomMenuLinks,
+ t,
+ ],
+ );
+
+ const activeTabKey = currentTab ?? "(home)";
+
+ const visibleKeys = useMemo(
+ () => new Set(tabs.map((tab) => tab.key)),
+ [tabs],
+ );
+
+ const handleTabChange = useCallback(
+ (key: string) => {
+ if (key === currentTab) return;
+
+ if (key === "(home)") eventBus.emit("scrollToTop");
+ if (key === "(search)") eventBus.emit("searchTabPressed");
+
+ router.replace(`/(auth)/(tabs)/${key}`);
+ },
+ [currentTab, router],
+ );
+
+ const navigateHome = useCallback(() => {
+ router.replace("/(auth)/(tabs)/(home)");
+ }, [router]);
+ useTVTabRootBackHandler(navigateHome, atTabRoot, currentTab);
+
+ // If current tab is no longer visible (setting changed), navigate to home
+ useEffect(() => {
+ if (!visibleKeys.has(activeTabKey) && activeTabKey !== "(home)") {
+ router.replace("/(auth)/(tabs)/(home)");
+ }
+ }, [visibleKeys, activeTabKey, router]);
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
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);
+ // Must be called before any conditional return (rules of hooks)
+ useTVHomeBackHandler();
- return () => {
- clearTimeout(timer);
- };
- }
- }, []),
- );
+ if (IS_ANDROID_TV) {
+ return ;
+ }
return (
- <>
+
({ sfSymbol: "heart.fill" }),
}}
/>
+ require("@/assets/icons/list.star.png")
+ : (_e) => ({ sfSymbol: "list.star" }),
+ }}
+ />
require("@/assets/icons/server.rack.png")
+ ? (_e) => require("@/assets/icons/rectangle.stack.fill.png")
: (_e) => ({ sfSymbol: "rectangle.stack.fill" }),
}}
/>
@@ -117,11 +226,24 @@ export default function TabLayout() {
tabBarItemHidden: !settings?.showCustomMenuLinks,
tabBarIcon:
Platform.OS === "android"
- ? (_e) => require("@/assets/icons/list.png")
- : (_e) => ({ sfSymbol: "list.dash.fill" }),
+ ? (_e) => require("@/assets/icons/link.png")
+ : (_e) => ({ sfSymbol: "link" }),
+ }}
+ />
+ require("@/assets/icons/gearshape.fill.png")
+ : (_e) => ({ sfSymbol: "gearshape.fill" }),
}}
/>
- >
+
+
+
);
}
diff --git a/app/(auth)/now-playing.tsx b/app/(auth)/now-playing.tsx
new file mode 100644
index 000000000..934175fb8
--- /dev/null
+++ b/app/(auth)/now-playing.tsx
@@ -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("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(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 (
+
+
+ No track playing
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
+ setViewMode("player")}
+ className='px-3 py-1'
+ >
+
+ Now Playing
+
+
+ setViewMode("queue")}
+ className='px-3 py-1'
+ >
+
+ Queue ({queue.length})
+
+
+
+ {/* Empty placeholder to balance header layout */}
+
+
+
+ {viewMode === "player" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+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 = ({
+ 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 (
+
+ {/* Album artwork */}
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Track info with actions */}
+
+
+
+ t}
+ >
+ {currentTrack.Name}
+
+ t}
+ >
+ {currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
+
+
+
+
+
+
+
+
+
+
+ {/* Audio Stats */}
+ {hasAudioStats && (
+
+ {fileSize && }
+ {codec && }
+
+ }
+ />
+ {bitrate && bitrate !== "N/A" && (
+
+ )}
+ {sampleRate && }
+
+ )}
+
+
+ {/* Progress slider */}
+
+ null}
+ sliderHeight={8}
+ containerStyle={{ borderRadius: 100 }}
+ renderBubble={() => null}
+ />
+
+ {progressText}
+ {remainingText}
+
+
+
+ {/* Main Controls with Shuffle & Repeat */}
+
+
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ {repeatMode === "one" && (
+
+ 1
+
+ )}
+
+
+
+ {/* Volume Slider */}
+ {!isTv && VolumeManager && (
+
+
+
+ null}
+ sliderHeight={8}
+ containerStyle={{ borderRadius: 100 }}
+ renderBubble={() => null}
+ />
+
+
+
+ )}
+
+ {/* AirPlay & Chromecast Buttons */}
+ {!isTv && (
+
+ {/* AirPlay (iOS only) */}
+ {Platform.OS === "ios" && (
+
+
+
+ )}
+ {/* Chromecast */}
+
+
+ )}
+
+ );
+};
+
+interface QueueViewProps {
+ api: any;
+ queue: BaseItemDto[];
+ queueIndex: number;
+ onJumpToIndex: (index: number) => void;
+ onRemoveFromQueue: (index: number) => void;
+ onReorderQueue: (newQueue: BaseItemDto[]) => void;
+}
+
+const QueueView: React.FC = ({
+ api,
+ queue,
+ queueIndex,
+ onJumpToIndex,
+ onRemoveFromQueue,
+ onReorderQueue,
+}) => {
+ const renderQueueItem = useCallback(
+ ({ item, drag, isActive, getIndex }: RenderItemParams) => {
+ 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 (
+
+ 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 */}
+
+
+
+
+ {/* Album art */}
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Track info */}
+
+
+ {item.Name}
+
+
+ {item.Artists?.join(", ") || item.AlbumArtist}
+
+
+
+ {/* Now playing indicator */}
+ {isCurrentTrack && (
+
+ )}
+
+ {/* Remove button (not for current track) */}
+ {!isCurrentTrack && (
+ onRemoveFromQueue(index)}
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
+ className='p-2'
+ >
+
+
+ )}
+
+
+ );
+ },
+ [api, queueIndex, onJumpToIndex, onRemoveFromQueue],
+ );
+
+ const handleDragEnd = useCallback(
+ ({ data }: { data: BaseItemDto[] }) => {
+ onReorderQueue(data);
+ },
+ [onReorderQueue],
+ );
+
+ const history = queue.slice(0, queueIndex);
+
+ return (
+ `${item.Id}-${index}`}
+ renderItem={renderQueueItem}
+ onDragEnd={handleDragEnd}
+ showsVerticalScrollIndicator={false}
+ ListHeaderComponent={
+
+
+ {history.length > 0 ? "Playing from queue" : "Up next"}
+
+
+ }
+ ListEmptyComponent={
+
+ Queue is empty
+
+ }
+ />
+ );
+};
diff --git a/app/(auth)/player/_layout.tsx b/app/(auth)/player/_layout.tsx
index 97a3f7eb2..5604160e5 100644
--- a/app/(auth)/player/_layout.tsx
+++ b/app/(auth)/player/_layout.tsx
@@ -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 (
<>
diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx
index 0d93f57f5..1a29ffc02 100644
--- a/app/(auth)/player/direct-player.tsx
+++ b/app/(auth)/player/direct-player.tsx
@@ -1,74 +1,100 @@
import {
type BaseItemDto,
type MediaSourceInfo,
+ type MediaStream,
PlaybackOrder,
PlaybackProgressInfo,
- PlaybackStartInfo,
RepeatMode,
} from "@jellyfin/sdk/lib/generated-client";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
+import { File } from "expo-file-system";
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 { Controls as TVControls } from "@/components/video-player/controls/Controls.tv";
+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 { useInactivity } from "@/providers/InactivityProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
+
+import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { useSettings } from "@/utils/atoms/settings";
+import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
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";
+import { generateDeviceProfile } from "../../../utils/profiles/native";
-export default function page() {
- const videoRef = useRef(null);
+export default function DirectPlayerPage() {
+ const videoRef = useRef(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 [isPipMode, setIsPipMode] = useState(false);
- 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 [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 [showTechnicalInfo, setShowTechnicalInfo] = useState(false);
+
+ // TV audio/subtitle selection state (tracks current selection for dynamic changes)
+ const [currentAudioIndex, setCurrentAudioIndex] = useState<
+ number | undefined
+ >(undefined);
+ const [currentSubtitleIndex, setCurrentSubtitleIndex] = useState(-1);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
@@ -78,10 +104,12 @@ export default function page() {
: require("react-native-volume-manager");
const downloadUtils = useDownload();
- const downloadedFiles = useMemo(
- () => downloadUtils.getDownloadedItems(),
- [downloadUtils.getDownloadedItems],
- );
+ // Call directly instead of useMemo - the function reference doesn't change
+ // when data updates, only when the provider initializes
+ const downloadedFiles = downloadUtils.getDownloadedItems();
+
+ // Inactivity timer controls (TV only)
+ const { pauseInactivityTimer, resumeInactivityTimer } = useInactivity();
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
@@ -100,7 +128,7 @@ export default function page() {
bitrateValue: bitrateValueStr,
offline: offlineStr,
playbackPosition: playbackPositionFromUrl,
- } = useGlobalSearchParams<{
+ } = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
@@ -110,13 +138,13 @@ 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 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
@@ -127,6 +155,13 @@ export default function page() {
: BITRATES[0].value;
const [item, setItem] = useState(null);
+ const initialSeekDoneRef = useRef(false);
+
+ const initialPlaybackTicksRef = useRef(
+ playbackPositionFromUrl
+ ? Number.parseInt(playbackPositionFromUrl, 10)
+ : (item?.UserData?.PlaybackPositionTicks ?? 0),
+ );
const [downloadedItem, setDownloadedItem] = useState(
null,
);
@@ -135,13 +170,77 @@ export default function page() {
isError: false,
});
- /** Gets the initial playback position from the URL. */
- const getInitialPlaybackTicks = useCallback((): number => {
- if (playbackPositionFromUrl) {
- return Number.parseInt(playbackPositionFromUrl, 10);
+ // Playback manager for progress reporting and adjacent items
+ const playbackManager = usePlaybackManager({ item, isOffline: offline });
+ const { nextItem, previousItem } = playbackManager;
+
+ // Resolve audio index: use URL param if provided, otherwise use stored index for offline playback
+ const audioIndex = useMemo(() => {
+ if (audioIndexFromUrl !== undefined) {
+ return audioIndexFromUrl;
}
- return item?.UserData?.PlaybackPositionTicks ?? 0;
- }, [playbackPositionFromUrl]);
+ if (offline && downloadedItem?.userData?.audioStreamIndex !== undefined) {
+ return downloadedItem.userData.audioStreamIndex;
+ }
+ return undefined;
+ }, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
+
+ // Initialize TV audio/subtitle indices from URL params.
+ // No undefined guard: when a new episode's URL omits audioIndex, reset to
+ // undefined (media default) rather than leaking the previous episode's track.
+ useEffect(() => {
+ setCurrentAudioIndex(audioIndex);
+ }, [audioIndex]);
+
+ useEffect(() => {
+ setCurrentSubtitleIndex(subtitleIndex);
+ }, [subtitleIndex]);
+
+ // 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, item?.UserData?.PlaybackPositionTicks]);
+
+ useEffect(() => {
+ if (!tracksReady || !videoRef.current) return;
+ if (initialSeekDoneRef.current) return;
+
+ initialSeekDoneRef.current = true;
+
+ const ticks = initialPlaybackTicksRef.current;
+
+ if (ticks > 0) {
+ videoRef.current.seekTo(ticksToSeconds(ticks));
+ }
+ }, [tracksReady]);
useEffect(() => {
const fetchItemData = async () => {
@@ -155,7 +254,12 @@ export default function page() {
setDownloadedItem(data);
}
} else {
- const res = await getUserLibraryApi(api!).getItem({
+ // Guard against api being null (e.g., during logout)
+ if (!api) {
+ setItemStatus({ isLoading: false, isError: false });
+ return;
+ }
+ const res = await getUserLibraryApi(api).getItem({
itemId,
userId: user?.Id,
});
@@ -170,10 +274,16 @@ export default function page() {
};
if (itemId) {
+ setItem(null);
+ setDownloadedItem(null);
+ // Clear the previous episode's stream so the loader gate stays closed
+ // until the new item's stream resolves (avoids a stale MPV source frame).
+ setStream(null);
fetchItemData();
}
}, [itemId, offline, api, user?.Id]);
+ // Lock orientation based on user settings
useEffect(() => {
if (settings?.defaultVideoOrientation) {
lockOrientation(settings.defaultVideoOrientation);
@@ -182,12 +292,13 @@ export default function page() {
return () => {
unlockOrientation();
};
- }, [settings?.defaultVideoOrientation]);
+ }, [settings?.defaultVideoOrientation, lockOrientation, unlockOrientation]);
interface Stream {
mediaSource: MediaSourceInfo;
sessionId: string;
url: string;
+ requiredHttpHeaders?: Record;
}
const [stream, setStream] = useState(null);
@@ -196,19 +307,28 @@ export default function page() {
isError: false,
});
+ // Ref to store the stream fetch function for refreshing subtitle tracks
+ const refetchStreamRef = useRef<(() => Promise) | null>(null);
+
useEffect(() => {
- const fetchStreamData = async () => {
+ const fetchStreamData = async (): Promise => {
setStreamStatus({ isLoading: true, isError: false });
try {
// Don't attempt to fetch stream data if item is not available
if (!item?.Id) {
console.log("Item not loaded yet, skipping stream data fetch");
setStreamStatus({ isLoading: false, isError: false });
- return;
+ return null;
+ }
+
+ // Ensure item matches the current itemId to avoid race conditions
+ if (item.Id !== itemId) {
+ setStreamStatus({ isLoading: false, isError: false });
+ return null;
}
let result: Stream | null = null;
- if (offline && downloadedItem && downloadedItem.mediaSource) {
+ if (offline && downloadedItem?.mediaSource) {
const url = downloadedItem.videoFilePath;
if (item) {
result = {
@@ -222,45 +342,54 @@ export default function page() {
if (!api) {
console.warn("API not available for streaming");
setStreamStatus({ isLoading: false, isError: true });
- return;
+ return null;
}
if (!user?.Id) {
console.warn("User not authenticated for streaming");
setStreamStatus({ isLoading: false, isError: true });
- return;
+ return null;
}
- 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 (!res) return null;
+ const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
+
if (!sessionId || !mediaSource || !url) {
Alert.alert(
t("player.error"),
t("player.failed_to_get_stream_url"),
);
- return;
+ return null;
}
- result = { mediaSource, sessionId, url };
+ result = { mediaSource, sessionId, url, requiredHttpHeaders };
}
setStream(result);
setStreamStatus({ isLoading: false, isError: false });
+ return result;
} catch (error) {
console.error("Failed to fetch stream:", error);
setStreamStatus({ isLoading: false, isError: true });
+ return null;
}
};
+
+ // Store the fetch function in ref for use by refresh handler
+ refetchStreamRef.current = fetchStreamData;
fetchStreamData();
}, [
itemId,
@@ -270,53 +399,55 @@ export default function page() {
item,
user?.Id,
downloadedItem,
+ offline,
]);
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) return;
+ if (!item?.Id || !stream?.sessionId || offline || !api) return;
const currentTimeInTicks = msToTicks(progress.get());
- await getPlaystateApi(api!).onPlaybackStopped({
- itemId: item.Id,
- mediaSourceId: mediaSourceId,
- positionTicks: currentTimeInTicks,
- playSessionId: stream.sessionId,
+ await getPlaystateApi(api).reportPlaybackStopped({
+ playbackStopInfo: {
+ ItemId: item.Id,
+ MediaSourceId: mediaSourceId,
+ PositionTicks: currentTimeInTicks,
+ PlaySessionId: stream.sessionId,
+ },
});
- }, [
- api,
- item,
- mediaSourceId,
- stream,
- progress,
- offline,
- revalidateProgressCache,
- ]);
+ }, [api, item, mediaSourceId, stream, progress, offline]);
const stop = useCallback(() => {
// Update URL with final playback position before stopping
@@ -325,38 +456,60 @@ export default function page() {
});
reportPlaybackStopped();
setIsPlaybackStopped(true);
- videoRef.current?.stop();
+ // Synchronously destroy the mpv instance + decoder + surface buffers
+ // BEFORE the screen unmounts. Otherwise the next screen (or the next
+ // episode's player) mounts while the old 4K decoder is still alive,
+ // causing OOM on low-RAM devices. Native stop() is idempotent so the
+ // later React unmount cleanup is still safe.
+ videoRef.current?.destroy().catch(() => {});
+ // Pre-libmpv-1.0 used `stop()`:
+ // videoRef.current?.stop();
revalidateProgressCache();
- }, [videoRef, reportPlaybackStopped, progress]);
+ // Resume inactivity timer when leaving player (TV only)
+ resumeInactivityTimer();
+ // Release the keep-awake wakelock acquired during playback so it
+ // doesn't follow us back to the home screen and block the TV
+ // screensaver. activateKeepAwakeAsync() is tag-scoped to this module
+ // and only released on the "paused" event; without this, navigating
+ // away mid-play leaves FLAG_KEEP_SCREEN_ON set on the window.
+ deactivateKeepAwake();
+ }, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => {
+ reportPlaybackStopped();
beforeRemoveListener();
};
- }, [navigation, stop]);
+ }, [navigation, stop, reportPlaybackStopped]);
- const currentPlayStateInfo = useCallback(() => {
+ 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,
+ // Report the live selection so server-side session/resume state reflects
+ // mid-playback track changes. Note: index 0 is valid (don't treat as
+ // falsy); -1 means "off" and is reported as-is.
+ AudioStreamIndex: currentAudioIndex,
+ SubtitleStreamIndex: currentSubtitleIndex,
+ 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,
item?.Id,
- audioIndex,
- subtitleIndex,
+ currentAudioIndex,
+ currentSubtitleIndex,
mediaSourceId,
progress,
isPlaying,
@@ -379,17 +532,27 @@ 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, cacheSeconds } = data.nativeEvent;
+ // MPV reports position in seconds, convert to ms
+ const currentTime = position * 1000;
+
if (isBuffering) {
setIsBuffering(false);
}
progress.set(currentTime);
+ // Update cache progress (current position + buffered seconds ahead)
+ if (cacheSeconds !== undefined && cacheSeconds > 0) {
+ const cacheEnd = currentTime + cacheSeconds * 1000;
+ cacheProgress.set(cacheEnd);
+ }
+
// Update URL immediately after seeking, or every 30 seconds during normal playback
const now = Date.now();
const shouldUpdateUrl = wasJustSeeking.get();
@@ -413,8 +576,8 @@ export default function page() {
},
[
item?.Id,
- audioIndex,
- subtitleIndex,
+ currentAudioIndex,
+ currentSubtitleIndex,
mediaSourceId,
isPlaying,
stream,
@@ -424,10 +587,138 @@ export default function page() {
],
);
- /** Gets the initial playback position in seconds. */
- const startPosition = useMemo(() => {
- return ticksToSeconds(getInitialPlaybackTicks());
- }, [getInitialPlaybackTicks]);
+ /** Prepare metadata for iOS native media controls (Control Center, Lock Screen) */
+ const nowPlayingMetadata = useMemo(() => {
+ if (!item || !api) return undefined;
+
+ const artworkUri = getPrimaryImageUrl({
+ api,
+ item,
+ quality: 90,
+ width: 500,
+ });
+
+ return {
+ title: item.Name || "",
+ artist:
+ item.Type === "Episode"
+ ? item.SeriesName || ""
+ : item.AlbumArtist || "",
+ albumTitle:
+ item.Type === "Episode" && item.SeasonName
+ ? item.SeasonName
+ : undefined,
+ artworkUri: artworkUri || undefined,
+ };
+ }, [item, api]);
+
+ /** Build video source config for MPV */
+ const videoSource = useMemo(() => {
+ 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,
+ isTranscoding,
+ );
+
+ // 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,
+ // Pass cache/buffer settings from user preferences
+ cacheConfig: {
+ enabled: settings.mpvCacheEnabled,
+ cacheSeconds: settings.mpvCacheSeconds,
+ maxBytes: settings.mpvDemuxerMaxBytes,
+ maxBackBytes: settings.mpvDemuxerMaxBackBytes,
+ },
+ // Pass VO driver setting (Android only)
+ voDriver: settings.mpvVoDriver,
+ };
+
+ // Add external subtitles only for online playback
+ if (externalSubs && externalSubs.length > 0) {
+ source.externalSubtitles = externalSubs;
+ }
+
+ // Add headers for online streaming (not for local file:// URLs)
+ if (!offline) {
+ const headers: Record = {};
+ const isRemoteStream =
+ mediaSource?.IsRemote && mediaSource?.Protocol === "Http";
+
+ // Add auth header only for Jellyfin API requests (not for external/remote streams)
+ if (api?.accessToken && !isRemoteStream) {
+ headers.Authorization = `MediaBrowser Token="${api.accessToken}"`;
+ }
+
+ // Add any required headers from the media source (e.g., for external/remote streams)
+ if (stream?.requiredHttpHeaders) {
+ Object.assign(headers, stream.requiredHttpHeaders);
+ }
+
+ if (Object.keys(headers).length > 0) {
+ source.headers = headers;
+ }
+ }
+
+ return source;
+ }, [
+ stream?.url,
+ stream?.mediaSource,
+ stream?.requiredHttpHeaders,
+ item?.UserData?.PlaybackPositionTicks,
+ playbackPositionFromUrl,
+ api?.basePath,
+ api?.accessToken,
+ subtitleIndex,
+ audioIndex,
+ offline,
+ settings.mpvCacheEnabled,
+ settings.mpvCacheSeconds,
+ settings.mpvDemuxerMaxBytes,
+ settings.mpvDemuxerMaxBackBytes,
+ settings.mpvVoDriver,
+ ]);
const volumeUpCb = useCallback(async () => {
if (Platform.isTV) return;
@@ -508,111 +799,62 @@ 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") {
- setIsPlaying(true);
- if (item?.Id) {
- playbackManager.reportPlaybackProgress(
- currentPlayStateInfo() as PlaybackProgressInfo,
- );
- }
- if (!Platform.isTV) await activateKeepAwakeAsync();
- return;
- }
+ async (e: { nativeEvent: MpvOnPlaybackStateChangePayload }) => {
+ const { isPaused, isPlaying: playing, isLoading } = e.nativeEvent;
- if (state === "Paused") {
- setIsPlaying(false);
- if (item?.Id) {
- playbackManager.reportPlaybackProgress(
- currentPlayStateInfo() as PlaybackProgressInfo,
- );
- }
- if (!Platform.isTV) await deactivateKeepAwake();
- return;
- }
-
- if (isPlaying) {
+ if (playing) {
setIsPlaying(true);
setIsBuffering(false);
- } else if (isBuffering) {
- setIsBuffering(true);
+ setHasPlaybackStarted(true);
+ // Pause inactivity timer during playback (TV only)
+ pauseInactivityTimer();
+ if (item?.Id) {
+ playbackManager.reportPlaybackProgress(
+ currentPlayStateInfo() as PlaybackProgressInfo,
+ );
+ }
+ await activateKeepAwakeAsync();
+ return;
+ }
+
+ if (isPaused) {
+ setIsPlaying(false);
+ // Resume inactivity timer when paused (TV only)
+ resumeInactivityTimer();
+ if (item?.Id) {
+ playbackManager.reportPlaybackProgress(
+ currentPlayStateInfo() as PlaybackProgressInfo,
+ );
+ }
+ await deactivateKeepAwake();
+ return;
+ }
+
+ if (isLoading !== undefined) {
+ setIsBuffering(isLoading);
}
},
- [playbackManager, item?.Id, progress],
+ [
+ playbackManager,
+ item?.Id,
+ progress,
+ pauseInactivityTimer,
+ resumeInactivityTimer,
+ ],
);
- 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,
+ const _onPictureInPictureChange = useCallback(
+ (e: { nativeEvent: { isActive: boolean } }) => {
+ const { isActive } = e.nativeEvent;
+ setIsPipMode(isActive);
+ 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);
@@ -624,8 +866,12 @@ export default function page() {
// Memoize video ref functions to prevent unnecessary re-renders
const startPictureInPicture = useCallback(async () => {
+ // Hide controls BEFORE entering PiP so the window captures a clean view
+ _setShowControls(false);
+ setIsPipMode(true);
return videoRef.current?.startPictureInPicture?.();
}, []);
+
const play = useCallback(() => {
videoRef.current?.play?.();
}, []);
@@ -635,69 +881,374 @@ 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;
- }, []);
+ // TV audio track change handler
+ const handleAudioIndexChange = useCallback(
+ async (index: number) => {
+ setCurrentAudioIndex(index);
- const setSubtitleTrack = useCallback((index: number) => {
- videoRef.current?.setSubtitleTrack?.(index);
- }, []);
+ // Check if we're transcoding
+ const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
- const setSubtitleURL = useCallback((url: string, _customName?: string) => {
- // Note: VlcPlayer type only expects url parameter
- videoRef.current?.setSubtitleURL?.(url);
- }, []);
+ // A transcoded stream only carries the audio track the server encoded
+ // into it — switching requires re-negotiating the stream with the new
+ // index (like the mobile menu's replacePlayer), not an mpv aid change.
+ if (isTranscoding) {
+ const queryParams = new URLSearchParams({
+ itemId: item?.Id ?? "",
+ audioIndex: String(index),
+ subtitleIndex: String(currentSubtitleIndex),
+ mediaSourceId: stream?.mediaSource?.Id ?? "",
+ bitrateValue: bitrateValue?.toString() ?? "",
+ playbackPosition: msToTicks(progress.get()).toString(),
+ }).toString();
+ // Destroy the current mpv instance BEFORE navigating, same rationale as
+ // goToNextItem/goToPreviousItem: Expo Router briefly holds two players
+ // during the transition, and two simultaneous decoders OOM-kill low-RAM
+ // devices. Resume is preserved via the playbackPosition param.
+ videoRef.current?.destroy().catch(() => {});
+ router.replace(`player/direct-player?${queryParams}` as any);
+ return;
+ }
- const setAudioTrack = useCallback((index: number) => {
- videoRef.current?.setAudioTrack?.(index);
- }, []);
-
- const setVideoAspectRatio = useCallback(
- async (aspectRatio: string | null) => {
- return (
- videoRef.current?.setVideoAspectRatio?.(aspectRatio) ||
- Promise.resolve()
+ // Convert Jellyfin index to MPV track ID
+ const mpvTrackId = getMpvAudioId(
+ stream?.mediaSource,
+ index,
+ isTranscoding,
);
+
+ if (mpvTrackId !== undefined) {
+ await videoRef.current?.setAudioTrack?.(mpvTrackId);
+ }
},
- [],
+ [
+ stream?.mediaSource,
+ item?.Id,
+ currentSubtitleIndex,
+ bitrateValue,
+ router,
+ progress,
+ ],
);
- const setVideoScaleFactor = useCallback(async (scaleFactor: number) => {
- return (
- videoRef.current?.setVideoScaleFactor?.(scaleFactor) || Promise.resolve()
- );
+ // TV subtitle track change handler
+ const handleSubtitleIndexChange = useCallback(
+ async (index: number) => {
+ setCurrentSubtitleIndex(index);
+
+ // Check if we're transcoding
+ const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
+
+ if (index === -1) {
+ // Disable subtitles
+ await videoRef.current?.disableSubtitles?.();
+ } else {
+ // Convert Jellyfin index to MPV track ID
+ const mpvTrackId = getMpvSubtitleId(
+ stream?.mediaSource,
+ index,
+ isTranscoding,
+ );
+
+ if (mpvTrackId !== undefined && mpvTrackId !== -1) {
+ await videoRef.current?.setSubtitleTrack?.(mpvTrackId);
+ }
+ }
+ },
+ [stream?.mediaSource],
+ );
+
+ // Technical info toggle handler
+ const handleToggleTechnicalInfo = useCallback(() => {
+ setShowTechnicalInfo((prev) => !prev);
}, []);
- // Prepare metadata for iOS native media controls
- const nowPlayingMetadata = useMemo(() => {
- if (!item || !api) return undefined;
+ // Get technical info from the player
+ const getTechnicalInfo = useCallback(async () => {
+ return (await videoRef.current?.getTechnicalInfo?.()) ?? {};
+ }, []);
- const artworkUri = getPrimaryImageUrl({
- api,
- item,
- quality: 90,
- width: 500,
+ // Determine play method based on stream URL and media source
+ const playMethod = useMemo<
+ "DirectPlay" | "DirectStream" | "Transcode" | undefined
+ >(() => {
+ if (!stream?.url) return undefined;
+
+ // Check if transcoding (m3u8 playlist or TranscodingUrl present)
+ if (stream.url.includes("m3u8") || stream.mediaSource?.TranscodingUrl) {
+ return "Transcode";
+ }
+
+ // Check if direct play (no container remuxing needed)
+ // Direct play means the file is being served as-is
+ if (stream.url.includes("/Videos/") && stream.url.includes("/stream")) {
+ return "DirectStream";
+ }
+
+ // Default to direct play if we're not transcoding
+ return "DirectPlay";
+ }, [stream?.url, stream?.mediaSource?.TranscodingUrl]);
+
+ // Extract transcode reasons from the TranscodingUrl
+ const transcodeReasons = useMemo(() => {
+ const transcodingUrl = stream?.mediaSource?.TranscodingUrl;
+ if (!transcodingUrl) return [];
+
+ try {
+ // Parse the TranscodeReasons parameter from the URL
+ const url = new URL(transcodingUrl, "http://localhost");
+ const reasons = url.searchParams.get("TranscodeReasons");
+ if (reasons) {
+ return reasons.split(",").filter(Boolean);
+ }
+ } catch {
+ // If URL parsing fails, try regex fallback
+ const match = transcodingUrl.match(/TranscodeReasons=([^&]+)/);
+ if (match) {
+ return match[1].split(",").filter(Boolean);
+ }
+ }
+ return [];
+ }, [stream?.mediaSource?.TranscodingUrl]);
+
+ const handleZoomToggle = useCallback(async () => {
+ const newZoomState = !isZoomedToFill;
+ await videoRef.current?.setZoomedToFill?.(newZoomState);
+ setIsZoomedToFill(newZoomState);
+
+ // 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 videoAR = videoWidth / videoHeight;
+ const screenAR = screenWidth / screenHeight;
+
+ 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]);
+
+ // TV: Navigate to previous item
+ const goToPreviousItem = useCallback(() => {
+ if (!previousItem || !settings) return;
+
+ const {
+ mediaSource: newMediaSource,
+ audioIndex: defaultAudioIndex,
+ subtitleIndex: defaultSubtitleIndex,
+ } = getDefaultPlaySettings(previousItem, settings, {
+ indexes: {
+ // Use the live selection, not the stale URL params (see goToNextItem).
+ subtitleIndex: currentSubtitleIndex,
+ audioIndex: currentAudioIndex,
+ },
+ source: stream?.mediaSource ?? undefined,
});
- return {
- title: item.Name || "",
- artist:
- item.Type === "Episode"
- ? item.SeriesName || ""
- : item.AlbumArtist || "",
- albumTitle:
- item.Type === "Episode" && item.SeasonName
- ? item.SeasonName
- : undefined,
- artworkUri: artworkUri || undefined,
+ const queryParams = new URLSearchParams({
+ itemId: previousItem.Id ?? "",
+ audioIndex: defaultAudioIndex?.toString() ?? "",
+ subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
+ mediaSourceId: newMediaSource?.Id ?? "",
+ bitrateValue: bitrateValue?.toString() ?? "",
+ playbackPosition:
+ previousItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
+ }).toString();
+
+ router.replace(`player/direct-player?${queryParams}` as any);
+ }, [
+ previousItem,
+ settings,
+ currentSubtitleIndex,
+ currentAudioIndex,
+ stream?.mediaSource,
+ bitrateValue,
+ router,
+ ]);
+
+ // TV: Add subtitle file to player (for client-side downloaded subtitles)
+ const addSubtitleFile = useCallback(async (path: string) => {
+ await videoRef.current?.addSubtitleFile?.(path, true);
+ }, []);
+
+ // TV: Refresh subtitle tracks after server-side subtitle download
+ // Re-fetches the media source to pick up newly downloaded subtitles
+ const handleRefreshSubtitleTracks = useCallback(async (): Promise<
+ MediaStream[]
+ > => {
+ if (!refetchStreamRef.current) return [];
+
+ const newStream = await refetchStreamRef.current();
+
+ // Check if component is still mounted before updating state
+ // This callback may be invoked from a modal after the player unmounts
+ if (!isMounted) return [];
+
+ if (newStream) {
+ setStream(newStream);
+ return (
+ newStream.mediaSource?.MediaStreams?.filter(
+ (s) => s.Type === "Subtitle",
+ ) ?? []
+ );
+ }
+ return [];
+ }, [isMounted]);
+
+ // TV: Navigate to next item
+ const goToNextItem = useCallback(() => {
+ if (!nextItem || !settings || isPlaybackStopped) return;
+
+ const {
+ mediaSource: newMediaSource,
+ audioIndex: defaultAudioIndex,
+ subtitleIndex: defaultSubtitleIndex,
+ } = getDefaultPlaySettings(nextItem, settings, {
+ indexes: {
+ // Use the live selection (updated when the user changes tracks
+ // mid-playback), not the stale URL params the episode started with.
+ subtitleIndex: currentSubtitleIndex,
+ audioIndex: currentAudioIndex,
+ },
+ source: stream?.mediaSource ?? undefined,
+ });
+
+ const queryParams = new URLSearchParams({
+ itemId: nextItem.Id ?? "",
+ audioIndex: defaultAudioIndex?.toString() ?? "",
+ subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
+ mediaSourceId: newMediaSource?.Id ?? "",
+ bitrateValue: bitrateValue?.toString() ?? "",
+ playbackPosition:
+ nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
+ }).toString();
+
+ // Destroy the current mpv instance BEFORE navigating so the old 4K
+ // decoder + surface buffers are freed before the new player screen
+ // mounts. Without this, Expo Router briefly holds two simultaneous
+ // mpv instances during the transition (~768 MB of surface buffers
+ // for two 4K HDR10+ decoders) and OOM-kills the app on low-RAM
+ // devices. Native stop() is idempotent so the subsequent React
+ // unmount cleanup is still safe.
+ videoRef.current?.destroy().catch(() => {});
+
+ router.replace(`player/direct-player?${queryParams}` as any);
+ }, [
+ nextItem,
+ settings,
+ currentSubtitleIndex,
+ currentAudioIndex,
+ stream?.mediaSource,
+ bitrateValue,
+ router,
+ isPlaybackStopped,
+ videoRef,
+ ]);
+
+ // 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);
+ }
+ // Apply subtitle background (iOS only - doesn't work on tvOS due to composite OSD limitation)
+ // mpv uses #RRGGBBAA format (alpha last, same as CSS)
+ if (settings.mpvSubtitleBackgroundEnabled) {
+ const opacity = settings.mpvSubtitleBackgroundOpacity ?? 75;
+ const alphaHex = Math.round((opacity / 100) * 255)
+ .toString(16)
+ .padStart(2, "0")
+ .toUpperCase();
+ // Enable background-box mode (required for sub-back-color to work)
+ await videoRef.current?.setSubtitleBorderStyle?.("background-box");
+ await videoRef.current?.setSubtitleBackgroundColor?.(
+ `#000000${alphaHex}`,
+ );
+ // Force override ASS subtitle styles so background shows on styled subtitles
+ await videoRef.current?.setSubtitleAssOverride?.("force");
+ } else {
+ // Restore default outline-and-shadow style
+ await videoRef.current?.setSubtitleBorderStyle?.("outline-and-shadow");
+ await videoRef.current?.setSubtitleBackgroundColor?.("#00000000");
+ // Restore default ASS behavior (keep original styles)
+ await videoRef.current?.setSubtitleAssOverride?.("no");
+ }
};
- }, [item, api]);
+
+ 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]);
+
+ // TV only: Pre-load locally downloaded subtitles when video loads
+ // This adds them to MPV's track list without auto-selecting them
+ useEffect(() => {
+ if (!Platform.isTV || !isVideoLoaded || !videoRef.current || !itemId)
+ return;
+
+ const preloadLocalSubtitles = async () => {
+ const localSubs = getSubtitlesForItem(itemId);
+ for (const sub of localSubs) {
+ // Verify file still exists (cache may have been cleared)
+ const subtitleFile = new File(sub.filePath);
+ if (!subtitleFile.exists) {
+ continue;
+ }
+ // Add subtitle file to MPV without selecting it (select: false)
+ await videoRef.current?.addSubtitleFile?.(sub.filePath, false);
+ }
+ };
+
+ preloadLocalSubtitles();
+ }, [isVideoLoaded, itemId]);
// Show error UI first, before checking loading/missing‐data
if (itemStatus.isError || streamStatus.isError) {
@@ -708,7 +1259,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 (
@@ -726,91 +1277,141 @@ export default function page() {
);
return (
-
-
+
- {
- 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);
- }}
- onPipStarted={(e) => {
- setIsPipMode(e.nativeEvent.pipStarted);
- }}
- />
-
- {isMounted === true && item && !isPipMode && (
-
- )}
-
+
+
+
+ setIsVideoLoaded(true)}
+ onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
+ console.error("Video Error:", e.nativeEvent);
+ Alert.alert(
+ t("player.error"),
+ t("player.an_error_occurred_while_playing_the_video"),
+ );
+ writeToLog("ERROR", "Video Error", e.nativeEvent);
+ }}
+ onTracksReady={() => {
+ setTracksReady(true);
+ }}
+ />
+ {!hasPlaybackStarted && (
+
+
+
+ )}
+
+ {isMounted === true &&
+ item &&
+ !isPipMode &&
+ (Platform.isTV ? (
+
+ ) : (
+
+ ))}
+
+
+
+
);
}
diff --git a/app/(auth)/tv-option-modal.tsx b/app/(auth)/tv-option-modal.tsx
new file mode 100644
index 000000000..442a29ec5
--- /dev/null
+++ b/app/(auth)/tv-option-modal.tsx
@@ -0,0 +1,204 @@
+import { BlurView } from "expo-blur";
+import { useAtomValue } from "jotai";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ Animated,
+ Easing,
+ InteractionManager,
+ ScrollView,
+ StyleSheet,
+ TVFocusGuideView,
+ View,
+} from "react-native";
+import { Text } from "@/components/common/Text";
+import { TVOptionCard } from "@/components/tv";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
+import { useTVBackPress } from "@/hooks/useTVBackPress";
+import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal";
+import { scaleSize } from "@/utils/scaleSize";
+import { store } from "@/utils/store";
+
+export default function TVOptionModal() {
+ const router = useRouter();
+ const modalState = useAtomValue(tvOptionModalAtom);
+ const typography = useScaledTVTypography();
+
+ const [isReady, setIsReady] = useState(false);
+ const firstCardRef = useRef(null);
+
+ const overlayOpacity = useRef(new Animated.Value(0)).current;
+ const sheetTranslateY = useRef(new Animated.Value(200)).current;
+
+ const initialSelectedIndex = useMemo(() => {
+ if (!modalState?.options) return 0;
+ const idx = modalState.options.findIndex((o) => o.selected);
+ return idx >= 0 ? idx : 0;
+ }, [modalState?.options]);
+
+ // Animate in on mount and cleanup atom on unmount
+ useEffect(() => {
+ overlayOpacity.setValue(0);
+ sheetTranslateY.setValue(200);
+
+ Animated.parallel([
+ Animated.timing(overlayOpacity, {
+ toValue: 1,
+ duration: 250,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(sheetTranslateY, {
+ toValue: 0,
+ duration: 300,
+ easing: Easing.out(Easing.cubic),
+ useNativeDriver: true,
+ }),
+ ]).start();
+
+ // Delay focus setup to allow layout
+ const timer = setTimeout(() => setIsReady(true), 100);
+ return () => {
+ clearTimeout(timer);
+ // Clear the atom on unmount to prevent stale callbacks from being retained
+ store.set(tvOptionModalAtom, null);
+ };
+ }, [overlayOpacity, sheetTranslateY]);
+
+ // Request focus on the first card when ready
+ useEffect(() => {
+ if (isReady && firstCardRef.current) {
+ const timer = setTimeout(() => {
+ (firstCardRef.current as any)?.requestTVFocus?.();
+ }, 50);
+ return () => clearTimeout(timer);
+ }
+ }, [isReady]);
+
+ const handleSelect = (value: any) => {
+ if (modalState?.deferApplyUntilDismissed) {
+ // onSelect navigates (the transcode audio switch replacing the player);
+ // a router.replace fired while this modal is the active route would be
+ // swallowed. Close FIRST, apply after dismissal.
+ const onSelect = modalState.onSelect;
+ store.set(tvOptionModalAtom, null);
+ router.back();
+ InteractionManager.runAfterInteractions(() => onSelect?.(value));
+ return;
+ }
+ // State-only callers (detail page, library filters, settings): run before
+ // closing so the re-render happens while the modal is up. Deferring it until
+ // after dismissal re-renders the page after focus returns and yanks TV
+ // focus, leaving navigation stuck.
+ modalState?.onSelect(value);
+ store.set(tvOptionModalAtom, null);
+ router.back();
+ };
+
+ const handleClose = useCallback(() => {
+ store.set(tvOptionModalAtom, null);
+ router.back();
+ }, [router]);
+
+ // Intercept back/menu press to close the modal instead of the player
+ useTVBackPress(() => {
+ handleClose();
+ return true;
+ }, [handleClose]);
+
+ // If no modal state, just go back (shouldn't happen in normal usage)
+ if (!modalState) {
+ return null;
+ }
+
+ const { title, options } = modalState;
+ const scaledCardWidth = scaleSize(160);
+ const scaledCardHeight = scaleSize(75);
+
+ return (
+
+
+
+
+
+ {title}
+
+ {isReady && (
+
+ {options.map((option, index) => (
+ handleSelect(option.value)}
+ width={scaledCardWidth}
+ height={scaledCardHeight}
+ />
+ ))}
+
+ )}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ justifyContent: "flex-end",
+ },
+ sheetContainer: {
+ width: "100%",
+ },
+ blurContainer: {
+ borderTopLeftRadius: scaleSize(24),
+ borderTopRightRadius: scaleSize(24),
+ overflow: "hidden",
+ },
+ content: {
+ paddingTop: scaleSize(24),
+ paddingBottom: scaleSize(50),
+ overflow: "visible",
+ },
+ title: {
+ fontWeight: "500",
+ color: "rgba(255,255,255,0.6)",
+ marginBottom: scaleSize(16),
+ paddingHorizontal: scaleSize(48),
+ textTransform: "uppercase",
+ letterSpacing: 1,
+ },
+ scrollView: {
+ overflow: "visible",
+ },
+ scrollContent: {
+ paddingHorizontal: scaleSize(48),
+ paddingVertical: scaleSize(20),
+ gap: scaleSize(12),
+ },
+});
diff --git a/app/(auth)/tv-request-modal.tsx b/app/(auth)/tv-request-modal.tsx
new file mode 100644
index 000000000..372bd542f
--- /dev/null
+++ b/app/(auth)/tv-request-modal.tsx
@@ -0,0 +1,496 @@
+import { Ionicons } from "@expo/vector-icons";
+import { useQuery } from "@tanstack/react-query";
+import { BlurView } from "expo-blur";
+import { useAtomValue } from "jotai";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Animated,
+ Easing,
+ ScrollView,
+ StyleSheet,
+ TVFocusGuideView,
+ View,
+} from "react-native";
+import { Text } from "@/components/common/Text";
+import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRow";
+import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
+import { TVButton, TVOptionSelector } from "@/components/tv";
+import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
+import { useJellyseerr } from "@/hooks/useJellyseerr";
+import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
+import type {
+ QualityProfile,
+ RootFolder,
+ Tag,
+} from "@/utils/jellyseerr/server/api/servarr/base";
+import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
+import { store } from "@/utils/store";
+
+export default function TVRequestModalPage() {
+ const typography = useScaledTVTypography();
+ const router = useRouter();
+ const modalState = useAtomValue(tvRequestModalAtom);
+ const { t } = useTranslation();
+ const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
+
+ const [isReady, setIsReady] = useState(false);
+ const [requestOverrides, setRequestOverrides] = useState({
+ mediaId: modalState?.id ? Number(modalState.id) : 0,
+ mediaType: modalState?.mediaType,
+ userId: jellyseerrUser?.id,
+ });
+
+ const [activeSelector, setActiveSelector] = useState<
+ "profile" | "folder" | "user" | null
+ >(null);
+
+ const overlayOpacity = useRef(new Animated.Value(0)).current;
+ const sheetTranslateY = useRef(new Animated.Value(200)).current;
+
+ // Animate in on mount
+ useEffect(() => {
+ overlayOpacity.setValue(0);
+ sheetTranslateY.setValue(200);
+
+ Animated.parallel([
+ Animated.timing(overlayOpacity, {
+ toValue: 1,
+ duration: 250,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(sheetTranslateY, {
+ toValue: 0,
+ duration: 300,
+ easing: Easing.out(Easing.cubic),
+ useNativeDriver: true,
+ }),
+ ]).start();
+
+ const timer = setTimeout(() => setIsReady(true), 100);
+ return () => {
+ clearTimeout(timer);
+ store.set(tvRequestModalAtom, null);
+ };
+ }, [overlayOpacity, sheetTranslateY]);
+
+ const { data: serviceSettings } = useQuery({
+ queryKey: ["jellyseerr", "request", modalState?.mediaType, "service"],
+ queryFn: async () =>
+ jellyseerrApi?.service(
+ modalState?.mediaType === "movie" ? "radarr" : "sonarr",
+ ),
+ enabled: !!jellyseerrApi && !!jellyseerrUser && !!modalState,
+ });
+
+ const { data: users } = useQuery({
+ queryKey: ["jellyseerr", "users"],
+ queryFn: async () =>
+ jellyseerrApi?.user({ take: 1000, sort: "displayname" }),
+ enabled: !!jellyseerrApi && !!jellyseerrUser && !!modalState,
+ });
+
+ const defaultService = useMemo(
+ () => serviceSettings?.find?.((v) => v.isDefault),
+ [serviceSettings],
+ );
+
+ const { data: defaultServiceDetails } = useQuery({
+ queryKey: [
+ "jellyseerr",
+ "request",
+ modalState?.mediaType,
+ "service",
+ "details",
+ defaultService?.id,
+ ],
+ queryFn: async () => {
+ setRequestOverrides((prev) => ({
+ ...prev,
+ serverId: defaultService?.id,
+ }));
+ return jellyseerrApi?.serviceDetails(
+ modalState?.mediaType === "movie" ? "radarr" : "sonarr",
+ defaultService!.id,
+ );
+ },
+ enabled:
+ !!jellyseerrApi && !!jellyseerrUser && !!defaultService && !!modalState,
+ });
+
+ const defaultProfile: QualityProfile | undefined = useMemo(
+ () =>
+ defaultServiceDetails?.profiles.find(
+ (p) => p.id === defaultServiceDetails.server?.activeProfileId,
+ ),
+ [defaultServiceDetails],
+ );
+
+ const defaultFolder: RootFolder | undefined = useMemo(
+ () =>
+ defaultServiceDetails?.rootFolders.find(
+ (f) => f.path === defaultServiceDetails.server?.activeDirectory,
+ ),
+ [defaultServiceDetails],
+ );
+
+ const defaultTags: Tag[] = useMemo(() => {
+ return (
+ defaultServiceDetails?.tags.filter((t) =>
+ defaultServiceDetails?.server.activeTags?.includes(t.id),
+ ) ?? []
+ );
+ }, [defaultServiceDetails]);
+
+ const pathTitleExtractor = (item: RootFolder) =>
+ `${item.path} (${item.freeSpace.bytesToReadable()})`;
+
+ // Option builders
+ const qualityProfileOptions: TVOptionItem[] = useMemo(
+ () =>
+ defaultServiceDetails?.profiles.map((profile) => ({
+ label: profile.name,
+ value: profile.id,
+ selected:
+ (requestOverrides.profileId || defaultProfile?.id) === profile.id,
+ })) || [],
+ [
+ defaultServiceDetails?.profiles,
+ defaultProfile,
+ requestOverrides.profileId,
+ ],
+ );
+
+ const rootFolderOptions: TVOptionItem[] = useMemo(
+ () =>
+ defaultServiceDetails?.rootFolders.map((folder) => ({
+ label: pathTitleExtractor(folder),
+ value: folder.path,
+ selected:
+ (requestOverrides.rootFolder || defaultFolder?.path) === folder.path,
+ })) || [],
+ [
+ defaultServiceDetails?.rootFolders,
+ defaultFolder,
+ requestOverrides.rootFolder,
+ ],
+ );
+
+ const userOptions: TVOptionItem[] = useMemo(
+ () =>
+ users?.map((user) => ({
+ label: user.displayName,
+ value: user.id,
+ selected: (requestOverrides.userId || jellyseerrUser?.id) === user.id,
+ })) || [],
+ [users, jellyseerrUser, requestOverrides.userId],
+ );
+
+ const tagItems = useMemo(() => {
+ return (
+ defaultServiceDetails?.tags.map((tag) => ({
+ id: tag.id,
+ label: tag.label,
+ selected:
+ requestOverrides.tags?.includes(tag.id) ||
+ defaultTags.some((dt) => dt.id === tag.id),
+ })) ?? []
+ );
+ }, [defaultServiceDetails?.tags, defaultTags, requestOverrides.tags]);
+
+ // Selected display values
+ const selectedProfileName = useMemo(() => {
+ const profile = defaultServiceDetails?.profiles.find(
+ (p) => p.id === (requestOverrides.profileId || defaultProfile?.id),
+ );
+ return profile?.name || defaultProfile?.name || t("jellyseerr.select");
+ }, [
+ defaultServiceDetails?.profiles,
+ requestOverrides.profileId,
+ defaultProfile,
+ t,
+ ]);
+
+ const selectedFolderName = useMemo(() => {
+ const folder = defaultServiceDetails?.rootFolders.find(
+ (f) => f.path === (requestOverrides.rootFolder || defaultFolder?.path),
+ );
+ return folder
+ ? pathTitleExtractor(folder)
+ : defaultFolder
+ ? pathTitleExtractor(defaultFolder)
+ : t("jellyseerr.select");
+ }, [
+ defaultServiceDetails?.rootFolders,
+ requestOverrides.rootFolder,
+ defaultFolder,
+ t,
+ ]);
+
+ const selectedUserName = useMemo(() => {
+ const user = users?.find(
+ (u) => u.id === (requestOverrides.userId || jellyseerrUser?.id),
+ );
+ return (
+ user?.displayName || jellyseerrUser?.displayName || t("jellyseerr.select")
+ );
+ }, [users, requestOverrides.userId, jellyseerrUser, t]);
+
+ // Handlers
+ const handleProfileChange = useCallback((profileId: number) => {
+ setRequestOverrides((prev) => ({ ...prev, profileId }));
+ setActiveSelector(null);
+ }, []);
+
+ const handleFolderChange = useCallback((rootFolder: string) => {
+ setRequestOverrides((prev) => ({ ...prev, rootFolder }));
+ setActiveSelector(null);
+ }, []);
+
+ const handleUserChange = useCallback((userId: number) => {
+ setRequestOverrides((prev) => ({ ...prev, userId }));
+ setActiveSelector(null);
+ }, []);
+
+ const handleTagToggle = useCallback(
+ (tagId: number) => {
+ setRequestOverrides((prev) => {
+ const currentTags = prev.tags || defaultTags.map((t) => t.id);
+ const hasTag = currentTags.includes(tagId);
+ return {
+ ...prev,
+ tags: hasTag
+ ? currentTags.filter((id) => id !== tagId)
+ : [...currentTags, tagId],
+ };
+ });
+ },
+ [defaultTags],
+ );
+
+ const handleRequest = useCallback(() => {
+ if (!modalState) return;
+
+ const body = {
+ is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
+ profileId: defaultProfile?.id,
+ rootFolder: defaultFolder?.path,
+ tags: defaultTags.map((t) => t.id),
+ ...modalState.requestBody,
+ ...requestOverrides,
+ };
+
+ const seasonTitle =
+ modalState.requestBody?.seasons?.length === 1
+ ? t("jellyseerr.season_number", {
+ season_number: modalState.requestBody.seasons[0],
+ })
+ : modalState.requestBody?.seasons &&
+ modalState.requestBody.seasons.length > 1
+ ? t("jellyseerr.season_all")
+ : undefined;
+
+ requestMedia(
+ seasonTitle ? `${modalState.title}, ${seasonTitle}` : modalState.title,
+ body,
+ () => {
+ modalState.onRequested();
+ router.back();
+ },
+ );
+ }, [
+ modalState,
+ requestOverrides,
+ defaultProfile,
+ defaultFolder,
+ defaultTags,
+ defaultService,
+ defaultServiceDetails,
+ requestMedia,
+ router,
+ t,
+ ]);
+
+ if (!modalState) {
+ return null;
+ }
+
+ const isDataLoaded = defaultService && defaultServiceDetails && users;
+
+ return (
+
+
+
+
+
+ {t("jellyseerr.advanced")}
+
+
+ {modalState.title}
+
+
+ {isDataLoaded && isReady ? (
+
+
+ setActiveSelector("profile")}
+ hasTVPreferredFocus
+ />
+ setActiveSelector("folder")}
+ />
+ setActiveSelector("user")}
+ />
+
+ {tagItems.length > 0 && (
+
+ )}
+
+
+ ) : (
+
+ {t("common.loading")}
+
+ )}
+
+ {isReady && (
+
+
+
+
+ {t("jellyseerr.request_button")}
+
+
+
+ )}
+
+
+
+
+ {/* Sub-selectors */}
+ setActiveSelector(null)}
+ cancelLabel={t("jellyseerr.cancel")}
+ />
+ setActiveSelector(null)}
+ cancelLabel={t("jellyseerr.cancel")}
+ cardWidth={280}
+ />
+ setActiveSelector(null)}
+ cancelLabel={t("jellyseerr.cancel")}
+ />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ justifyContent: "flex-end",
+ },
+ sheetContainer: {
+ width: "100%",
+ },
+ blurContainer: {
+ borderTopLeftRadius: 24,
+ borderTopRightRadius: 24,
+ overflow: "hidden",
+ },
+ content: {
+ paddingTop: 24,
+ paddingBottom: 50,
+ paddingHorizontal: 44,
+ overflow: "visible",
+ },
+ heading: {
+ fontWeight: "bold",
+ color: "#FFFFFF",
+ marginBottom: 8,
+ },
+ subtitle: {
+ color: "rgba(255,255,255,0.6)",
+ marginBottom: 24,
+ },
+ scrollView: {
+ maxHeight: 320,
+ overflow: "visible",
+ },
+ optionsContainer: {
+ gap: 12,
+ paddingVertical: 8,
+ paddingHorizontal: 4,
+ },
+ loadingContainer: {
+ height: 200,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ loadingText: {
+ color: "rgba(255,255,255,0.5)",
+ },
+ buttonContainer: {
+ marginTop: 24,
+ },
+ buttonText: {
+ fontWeight: "bold",
+ color: "#FFFFFF",
+ },
+});
diff --git a/app/(auth)/tv-season-select-modal.tsx b/app/(auth)/tv-season-select-modal.tsx
new file mode 100644
index 000000000..b9285e65f
--- /dev/null
+++ b/app/(auth)/tv-season-select-modal.tsx
@@ -0,0 +1,446 @@
+import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
+import { BlurView } from "expo-blur";
+import { useAtomValue } from "jotai";
+import { orderBy } from "lodash";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Animated,
+ Easing,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ TVFocusGuideView,
+ View,
+} from "react-native";
+import { Text } from "@/components/common/Text";
+import { TVButton } from "@/components/tv";
+import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
+import { useJellyseerr } from "@/hooks/useJellyseerr";
+import { useTVRequestModal } from "@/hooks/useTVRequestModal";
+import { tvSeasonSelectModalAtom } from "@/utils/atoms/tvSeasonSelectModal";
+import {
+ MediaStatus,
+ MediaType,
+} from "@/utils/jellyseerr/server/constants/media";
+import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
+import { store } from "@/utils/store";
+
+interface TVSeasonToggleCardProps {
+ season: {
+ id: number;
+ seasonNumber: number;
+ episodeCount: number;
+ status: MediaStatus;
+ };
+ selected: boolean;
+ onToggle: () => void;
+ canRequest: boolean;
+ hasTVPreferredFocus?: boolean;
+}
+
+const TVSeasonToggleCard: React.FC = ({
+ season,
+ selected,
+ onToggle,
+ canRequest,
+ hasTVPreferredFocus,
+}) => {
+ const { t } = useTranslation();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.08 });
+
+ // Get status icon and color based on MediaStatus
+ const getStatusIcon = (): {
+ icon: keyof typeof MaterialCommunityIcons.glyphMap;
+ color: string;
+ } | null => {
+ switch (season.status) {
+ case MediaStatus.PROCESSING:
+ return { icon: "clock", color: "#6366f1" };
+ case MediaStatus.AVAILABLE:
+ return { icon: "check", color: "#22c55e" };
+ case MediaStatus.PENDING:
+ return { icon: "bell", color: "#eab308" };
+ case MediaStatus.PARTIALLY_AVAILABLE:
+ return { icon: "minus", color: "#22c55e" };
+ case MediaStatus.BLACKLISTED:
+ return { icon: "eye-off", color: "#ef4444" };
+ default:
+ return canRequest ? { icon: "plus", color: "#22c55e" } : null;
+ }
+ };
+
+ const statusInfo = getStatusIcon();
+ const isDisabled = !canRequest;
+
+ return (
+
+
+ {/* Checkmark for selected */}
+
+ {selected && (
+
+ )}
+
+
+ {/* Season info */}
+
+
+ {t("jellyseerr.season_number", {
+ season_number: season.seasonNumber,
+ })}
+
+
+
+ {t("jellyseerr.number_episodes", {
+ episode_number: season.episodeCount,
+ })}
+
+ {statusInfo && (
+
+
+
+ )}
+
+
+
+
+ );
+};
+
+export default function TVSeasonSelectModalPage() {
+ const typography = useScaledTVTypography();
+ const router = useRouter();
+ const modalState = useAtomValue(tvSeasonSelectModalAtom);
+ const { t } = useTranslation();
+ const { requestMedia } = useJellyseerr();
+ const { showRequestModal } = useTVRequestModal();
+
+ // Selected seasons - initially select all requestable (UNKNOWN status) seasons
+ const [selectedSeasons, setSelectedSeasons] = useState>(
+ new Set(),
+ );
+
+ const overlayOpacity = useRef(new Animated.Value(0)).current;
+ const sheetTranslateY = useRef(new Animated.Value(200)).current;
+
+ // Initialize selected seasons when modal state changes
+ useEffect(() => {
+ if (modalState?.seasons) {
+ const requestableSeasons = modalState.seasons
+ .filter((s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0)
+ .map((s) => s.seasonNumber);
+ setSelectedSeasons(new Set(requestableSeasons));
+ }
+ }, [modalState?.seasons]);
+
+ // Animate in on mount
+ useEffect(() => {
+ overlayOpacity.setValue(0);
+ sheetTranslateY.setValue(200);
+
+ Animated.parallel([
+ Animated.timing(overlayOpacity, {
+ toValue: 1,
+ duration: 250,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(sheetTranslateY, {
+ toValue: 0,
+ duration: 300,
+ easing: Easing.out(Easing.cubic),
+ useNativeDriver: true,
+ }),
+ ]).start();
+
+ return () => {
+ store.set(tvSeasonSelectModalAtom, null);
+ };
+ }, [overlayOpacity, sheetTranslateY]);
+
+ // Sort seasons by season number (ascending)
+ const sortedSeasons = useMemo(() => {
+ if (!modalState?.seasons) return [];
+ return orderBy(
+ modalState.seasons.filter((s) => s.seasonNumber !== 0),
+ "seasonNumber",
+ "asc",
+ );
+ }, [modalState?.seasons]);
+
+ // Find the index of the first requestable season for initial focus
+ const firstRequestableIndex = useMemo(() => {
+ return sortedSeasons.findIndex((s) => s.status === MediaStatus.UNKNOWN);
+ }, [sortedSeasons]);
+
+ const handleToggleSeason = useCallback((seasonNumber: number) => {
+ setSelectedSeasons((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(seasonNumber)) {
+ newSet.delete(seasonNumber);
+ } else {
+ newSet.add(seasonNumber);
+ }
+ return newSet;
+ });
+ }, []);
+
+ const handleRequestSelected = useCallback(() => {
+ if (!modalState || selectedSeasons.size === 0) return;
+
+ const seasonsArray = Array.from(selectedSeasons);
+ const body: MediaRequestBody = {
+ mediaId: modalState.mediaId,
+ mediaType: MediaType.TV,
+ tvdbId: modalState.tvdbId,
+ seasons: seasonsArray,
+ };
+
+ if (modalState.hasAdvancedRequestPermission) {
+ // Close this modal and open the advanced request modal
+ router.back();
+ showRequestModal({
+ requestBody: body,
+ title: modalState.title,
+ id: modalState.mediaId,
+ mediaType: MediaType.TV,
+ onRequested: modalState.onRequested,
+ });
+ return;
+ }
+
+ // Build the title based on selected seasons
+ const seasonTitle =
+ seasonsArray.length === 1
+ ? t("jellyseerr.season_number", { season_number: seasonsArray[0] })
+ : seasonsArray.length === sortedSeasons.length
+ ? t("jellyseerr.season_all")
+ : t("jellyseerr.n_selected", { count: seasonsArray.length });
+
+ requestMedia(`${modalState.title}, ${seasonTitle}`, body, () => {
+ modalState.onRequested();
+ router.back();
+ });
+ }, [
+ modalState,
+ selectedSeasons,
+ sortedSeasons.length,
+ requestMedia,
+ router,
+ t,
+ showRequestModal,
+ ]);
+
+ if (!modalState) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ {t("jellyseerr.select_seasons")}
+
+
+ {modalState.title}
+
+
+ {/* Season cards horizontal scroll */}
+
+ {sortedSeasons.map((season, index) => {
+ const canRequestSeason = season.status === MediaStatus.UNKNOWN;
+ return (
+ handleToggleSeason(season.seasonNumber)}
+ canRequest={canRequestSeason}
+ hasTVPreferredFocus={index === firstRequestableIndex}
+ />
+ );
+ })}
+
+
+ {/* Request button */}
+
+
+
+
+ {t("jellyseerr.request_selected")}
+ {selectedSeasons.size > 0 && ` (${selectedSeasons.size})`}
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ justifyContent: "flex-end",
+ },
+ sheetContainer: {
+ width: "100%",
+ },
+ blurContainer: {
+ borderTopLeftRadius: 24,
+ borderTopRightRadius: 24,
+ overflow: "hidden",
+ },
+ content: {
+ paddingTop: 24,
+ paddingBottom: 50,
+ paddingHorizontal: 44,
+ overflow: "visible",
+ },
+ heading: {
+ fontWeight: "bold",
+ color: "#FFFFFF",
+ marginBottom: 8,
+ },
+ subtitle: {
+ color: "rgba(255,255,255,0.6)",
+ marginBottom: 24,
+ },
+ scrollView: {
+ overflow: "visible",
+ },
+ scrollContent: {
+ paddingVertical: 12,
+ paddingHorizontal: 4,
+ gap: 16,
+ },
+ seasonCard: {
+ width: 160,
+ paddingVertical: 16,
+ paddingHorizontal: 16,
+ borderRadius: 12,
+ shadowColor: "#fff",
+ shadowOffset: { width: 0, height: 0 },
+ shadowOpacity: 0.2,
+ shadowRadius: 8,
+ },
+ checkmarkContainer: {
+ height: 24,
+ marginBottom: 8,
+ },
+ seasonInfo: {
+ flex: 1,
+ },
+ seasonTitle: {
+ fontWeight: "600",
+ marginBottom: 4,
+ },
+ episodeRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ },
+ episodeCount: {
+ fontSize: 14,
+ },
+ statusBadge: {
+ width: 22,
+ height: 22,
+ borderRadius: 11,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ buttonContainer: {
+ marginTop: 24,
+ },
+ buttonText: {
+ fontWeight: "bold",
+ color: "#FFFFFF",
+ },
+});
diff --git a/app/(auth)/tv-series-season-modal.tsx b/app/(auth)/tv-series-season-modal.tsx
new file mode 100644
index 000000000..b1117e6f4
--- /dev/null
+++ b/app/(auth)/tv-series-season-modal.tsx
@@ -0,0 +1,190 @@
+import { BlurView } from "expo-blur";
+import { useAtomValue } from "jotai";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Animated,
+ Easing,
+ ScrollView,
+ StyleSheet,
+ TVFocusGuideView,
+ View,
+} from "react-native";
+import { Text } from "@/components/common/Text";
+import { TVCancelButton, TVOptionCard } from "@/components/tv";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
+import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal";
+import { store } from "@/utils/store";
+
+export default function TVSeriesSeasonModalPage() {
+ const typography = useScaledTVTypography();
+ const router = useRouter();
+ const modalState = useAtomValue(tvSeriesSeasonModalAtom);
+ const { t } = useTranslation();
+
+ const [isReady, setIsReady] = useState(false);
+ const firstCardRef = useRef(null);
+
+ const overlayOpacity = useRef(new Animated.Value(0)).current;
+ const sheetTranslateY = useRef(new Animated.Value(200)).current;
+
+ const initialSelectedIndex = useMemo(() => {
+ if (!modalState?.seasons) return 0;
+ const idx = modalState.seasons.findIndex((o) => o.selected);
+ return idx >= 0 ? idx : 0;
+ }, [modalState?.seasons]);
+
+ // Animate in on mount
+ useEffect(() => {
+ overlayOpacity.setValue(0);
+ sheetTranslateY.setValue(200);
+
+ Animated.parallel([
+ Animated.timing(overlayOpacity, {
+ toValue: 1,
+ duration: 250,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(sheetTranslateY, {
+ toValue: 0,
+ duration: 300,
+ easing: Easing.out(Easing.cubic),
+ useNativeDriver: true,
+ }),
+ ]).start();
+
+ const timer = setTimeout(() => setIsReady(true), 100);
+ return () => {
+ clearTimeout(timer);
+ store.set(tvSeriesSeasonModalAtom, null);
+ };
+ }, [overlayOpacity, sheetTranslateY]);
+
+ // Focus on the selected card when ready
+ useEffect(() => {
+ if (isReady && firstCardRef.current) {
+ const timer = setTimeout(() => {
+ (firstCardRef.current as any)?.requestTVFocus?.();
+ }, 50);
+ return () => clearTimeout(timer);
+ }
+ }, [isReady]);
+
+ const handleSelect = (seasonIndex: number) => {
+ if (modalState?.onSeasonSelect) {
+ modalState.onSeasonSelect(seasonIndex);
+ }
+ router.back();
+ };
+
+ const handleCancel = () => {
+ router.back();
+ };
+
+ if (!modalState) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ {t("item_card.select_season")}
+
+
+ {isReady && (
+
+ {modalState.seasons.map((season, index) => (
+ handleSelect(season.value)}
+ width={180}
+ height={85}
+ />
+ ))}
+
+ )}
+
+ {isReady && (
+
+
+
+ )}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ justifyContent: "flex-end",
+ },
+ sheetContainer: {
+ width: "100%",
+ },
+ blurContainer: {
+ borderTopLeftRadius: 24,
+ borderTopRightRadius: 24,
+ overflow: "hidden",
+ },
+ content: {
+ paddingTop: 24,
+ paddingBottom: 50,
+ overflow: "visible",
+ },
+ title: {
+ fontWeight: "500",
+ color: "rgba(255,255,255,0.6)",
+ marginBottom: 16,
+ paddingHorizontal: 48,
+ textTransform: "uppercase",
+ letterSpacing: 1,
+ },
+ scrollView: {
+ overflow: "visible",
+ },
+ scrollContent: {
+ paddingHorizontal: 48,
+ paddingVertical: 20,
+ gap: 12,
+ },
+ cancelButtonContainer: {
+ marginTop: 16,
+ paddingHorizontal: 48,
+ alignItems: "flex-start",
+ },
+});
diff --git a/app/(auth)/tv-subtitle-modal.tsx b/app/(auth)/tv-subtitle-modal.tsx
new file mode 100644
index 000000000..ed5d24d9c
--- /dev/null
+++ b/app/(auth)/tv-subtitle-modal.tsx
@@ -0,0 +1,1350 @@
+import { Ionicons } from "@expo/vector-icons";
+import { BlurView } from "expo-blur";
+import { useAtomValue } from "jotai";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { useTranslation } from "react-i18next";
+import {
+ ActivityIndicator,
+ Animated,
+ Easing,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ TVFocusGuideView,
+ View,
+} from "react-native";
+import { Text } from "@/components/common/Text";
+import { TVTabButton, useTVFocusAnimation } from "@/components/tv";
+import type { Track } from "@/components/video-player/controls/types";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
+import {
+ type SubtitleSearchResult,
+ useRemoteSubtitles,
+} from "@/hooks/useRemoteSubtitles";
+import { useTVBackPress } from "@/hooks/useTVBackPress";
+import { useSettings } from "@/utils/atoms/settings";
+import { tvSubtitleModalAtom } from "@/utils/atoms/tvSubtitleModal";
+import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
+import { scaleSize } from "@/utils/scaleSize";
+import { store } from "@/utils/store";
+
+type TabType = "tracks" | "download" | "settings";
+
+// Track card for subtitle track selection
+const TVTrackCard = React.forwardRef<
+ View,
+ {
+ label: string;
+ sublabel?: string;
+ selected: boolean;
+ hasTVPreferredFocus?: boolean;
+ onPress: () => void;
+ }
+>(({ label, sublabel, selected, hasTVPreferredFocus, onPress }, ref) => {
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.05 });
+
+ return (
+
+
+
+ {label}
+
+ {sublabel && (
+
+ {sublabel}
+
+ )}
+ {selected && !focused && (
+
+
+
+ )}
+
+
+ );
+});
+
+// Language selector card
+const LanguageCard = React.forwardRef<
+ View,
+ {
+ code: string;
+ name: string;
+ selected: boolean;
+ hasTVPreferredFocus?: boolean;
+ onPress: () => void;
+ }
+>(({ code, name, selected, hasTVPreferredFocus, onPress }, ref) => {
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.05 });
+
+ return (
+
+
+
+ {name}
+
+
+ {code.toUpperCase()}
+
+ {selected && !focused && (
+
+
+
+ )}
+
+
+ );
+});
+
+// Subtitle result card
+const SubtitleResultCard = React.forwardRef<
+ View,
+ {
+ result: SubtitleSearchResult;
+ hasTVPreferredFocus?: boolean;
+ isDownloading?: boolean;
+ onPress: () => void;
+ }
+>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.03 });
+
+ return (
+
+
+ {/* Provider/Source badge */}
+
+
+ {result.providerName}
+
+
+
+ {/* Name */}
+
+ {result.name}
+
+
+ {/* Meta info row */}
+
+ {/* Format */}
+
+ {result.format?.toUpperCase()}
+
+
+ {/* Rating if available */}
+ {result.communityRating !== undefined &&
+ result.communityRating > 0 && (
+
+
+
+ {result.communityRating.toFixed(1)}
+
+
+ )}
+
+ {/* Download count if available */}
+ {result.downloadCount !== undefined && result.downloadCount > 0 && (
+
+
+
+ {result.downloadCount.toLocaleString()}
+
+
+ )}
+
+
+ {/* Flags */}
+
+ {result.isHashMatch && (
+
+
+ Hash Match
+
+
+ )}
+ {result.hearingImpaired && (
+
+
+
+ )}
+ {result.aiTranslated && (
+
+
+ AI
+
+
+ )}
+
+
+ {/* Loading indicator when downloading */}
+ {isDownloading && (
+
+
+
+ )}
+
+
+ );
+});
+
+// Stepper button for subtitle size control
+const TVStepperButton: React.FC<{
+ icon: "remove" | "add";
+ onPress: () => void;
+ disabled?: boolean;
+ hasTVPreferredFocus?: boolean;
+}> = ({ icon, onPress, disabled, hasTVPreferredFocus }) => {
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.1 });
+
+ return (
+
+
+
+
+
+ );
+};
+
+// Generic stepper control component
+const TVStepperControl: React.FC<{
+ value: number;
+ min: number;
+ max: number;
+ step: number;
+ formatValue: (value: number) => string;
+ onChange: (newValue: number) => void;
+ hasTVPreferredFocus?: boolean;
+}> = ({
+ value,
+ min,
+ max,
+ step,
+ formatValue,
+ onChange,
+ hasTVPreferredFocus,
+}) => {
+ const canDecrease = value > min;
+ const canIncrease = value < max;
+
+ const handleDecrease = () => {
+ if (canDecrease) {
+ const newValue = Math.max(min, Math.round((value - step) * 10) / 10);
+ onChange(newValue);
+ }
+ };
+
+ const handleIncrease = () => {
+ if (canIncrease) {
+ const newValue = Math.min(max, Math.round((value + step) * 10) / 10);
+ onChange(newValue);
+ }
+ };
+
+ return (
+
+
+
+ {formatValue(value)}
+
+
+
+ );
+};
+
+// Alignment option card
+const TVAlignmentCard: React.FC<{
+ label: string;
+ selected: boolean;
+ onPress: () => void;
+ hasTVPreferredFocus?: boolean;
+}> = ({ label, selected, onPress, hasTVPreferredFocus }) => {
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.05 });
+
+ return (
+
+
+
+ {label}
+
+ {selected && !focused && (
+
+
+
+ )}
+
+
+ );
+};
+
+export default function TVSubtitleModal() {
+ const router = useRouter();
+ const { t } = useTranslation();
+ const modalState = useAtomValue(tvSubtitleModalAtom);
+ const { settings, updateSettings } = useSettings();
+ const typography = useScaledTVTypography();
+
+ const [activeTab, setActiveTab] = useState("tracks");
+ const [selectedLanguage, setSelectedLanguage] = useState("eng");
+ const [downloadingId, setDownloadingId] = useState(null);
+ const [hasSearchedThisSession, setHasSearchedThisSession] = useState(false);
+ const [isReady, setIsReady] = useState(false);
+ const [isTabContentReady, setIsTabContentReady] = useState(false);
+ const firstTrackRef = useRef(null);
+
+ const overlayOpacity = useRef(new Animated.Value(0)).current;
+ const sheetTranslateY = useRef(new Animated.Value(300)).current;
+
+ const {
+ hasOpenSubtitlesApiKey,
+ isSearching,
+ searchError,
+ searchResults,
+ search,
+ downloadAsync,
+ reset,
+ } = useRemoteSubtitles({
+ itemId: modalState?.item?.Id ?? "",
+ item: modalState?.item ?? ({} as any),
+ mediaSourceId: modalState?.mediaSourceId,
+ });
+
+ const resetRef = useRef(reset);
+ resetRef.current = reset;
+
+ const subtitleTracks = modalState?.subtitleTracks ?? [];
+ const currentSubtitleIndex = modalState?.currentSubtitleIndex ?? -1;
+
+ const initialSelectedTrackIndex = useMemo(() => {
+ if (currentSubtitleIndex === -1) return 0;
+ const trackIdx = subtitleTracks.findIndex(
+ (t) => t.index === currentSubtitleIndex,
+ );
+ return trackIdx >= 0 ? trackIdx + 1 : 0;
+ }, [subtitleTracks, currentSubtitleIndex]);
+
+ // Track if component is mounted for async operations
+ const isMountedRef = useRef(true);
+
+ // Animate in on mount and cleanup atom on unmount
+ useEffect(() => {
+ isMountedRef.current = true;
+ overlayOpacity.setValue(0);
+ sheetTranslateY.setValue(300);
+
+ Animated.parallel([
+ Animated.timing(overlayOpacity, {
+ toValue: 1,
+ duration: 250,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(sheetTranslateY, {
+ toValue: 0,
+ duration: 300,
+ easing: Easing.out(Easing.cubic),
+ useNativeDriver: true,
+ }),
+ ]).start();
+
+ const timer = setTimeout(() => setIsReady(true), 100);
+ return () => {
+ clearTimeout(timer);
+ isMountedRef.current = false;
+ // Clear the atom on unmount to prevent stale callbacks from being retained
+ store.set(tvSubtitleModalAtom, null);
+ };
+ }, [overlayOpacity, sheetTranslateY]);
+
+ useEffect(() => {
+ if (activeTab === "download" && !hasSearchedThisSession && modalState) {
+ search({ language: selectedLanguage });
+ setHasSearchedThisSession(true);
+ }
+ }, [activeTab, hasSearchedThisSession, search, selectedLanguage, modalState]);
+
+ useEffect(() => {
+ if (isReady) {
+ setIsTabContentReady(false);
+ const timer = setTimeout(() => setIsTabContentReady(true), 50);
+ return () => clearTimeout(timer);
+ }
+ setIsTabContentReady(false);
+ }, [activeTab, isReady]);
+
+ const handleClose = useCallback(() => {
+ store.set(tvSubtitleModalAtom, null);
+ router.back();
+ }, [router]);
+
+ // Intercept back/menu press to close the modal instead of the player
+ useTVBackPress(() => {
+ handleClose();
+ return true;
+ }, [handleClose]);
+
+ const handleLanguageSelect = useCallback(
+ (code: string) => {
+ setSelectedLanguage(code);
+ search({ language: code });
+ },
+ [search],
+ );
+
+ const handleTrackSelect = useCallback(
+ (option: { setTrack?: () => void }) => {
+ option.setTrack?.();
+ handleClose();
+ },
+ [handleClose],
+ );
+
+ const handleDownload = useCallback(
+ async (result: SubtitleSearchResult) => {
+ setDownloadingId(result.id);
+
+ try {
+ const downloadResult = await downloadAsync(result);
+
+ // Check if component is still mounted after async operation
+ if (!isMountedRef.current) return;
+
+ if (downloadResult.type === "server") {
+ // Give Jellyfin time to process the downloaded subtitle
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+
+ // Check if component is still mounted after the wait
+ if (!isMountedRef.current) return;
+
+ // Refresh tracks and stay open for server-side downloads
+ if (modalState?.refreshSubtitleTracks) {
+ const newTracks = await modalState.refreshSubtitleTracks();
+
+ // Check if component is still mounted after fetching tracks
+ if (!isMountedRef.current) return;
+
+ // Update atom with new tracks
+ store.set(tvSubtitleModalAtom, {
+ ...modalState,
+ subtitleTracks: newTracks,
+ });
+ // Switch to tracks tab to show the new subtitle
+ setActiveTab("tracks");
+ }
+
+ // Also call onServerSubtitleDownloaded to invalidate React Query cache
+ // (used when opening modal from item detail page)
+ modalState?.onServerSubtitleDownloaded?.();
+
+ // Do NOT close modal - user can see and select the new track
+ } else if (downloadResult.type === "local" && downloadResult.path) {
+ // Notify parent that a local subtitle was downloaded
+ modalState?.onLocalSubtitleDownloaded?.(downloadResult.path);
+
+ // Check if component is still mounted after callback
+ if (!isMountedRef.current) return;
+
+ // Refresh tracks to include the newly downloaded subtitle
+ if (modalState?.refreshSubtitleTracks) {
+ const newTracks = await modalState.refreshSubtitleTracks();
+
+ // Check if component is still mounted after fetching tracks
+ if (!isMountedRef.current) return;
+
+ // Update atom with new tracks
+ store.set(tvSubtitleModalAtom, {
+ ...modalState,
+ subtitleTracks: newTracks,
+ });
+ // Switch to tracks tab to show the new subtitle
+ setActiveTab("tracks");
+ } else {
+ // No refreshSubtitleTracks available (e.g., from player), just close
+ handleClose();
+ }
+ }
+ } catch (error) {
+ console.error("Failed to download subtitle:", error);
+ } finally {
+ if (isMountedRef.current) {
+ setDownloadingId(null);
+ }
+ }
+ },
+ [downloadAsync, modalState, handleClose],
+ );
+
+ const displayLanguages = useMemo(
+ () => COMMON_SUBTITLE_LANGUAGES.slice(0, 16),
+ [],
+ );
+
+ const trackOptions = useMemo(() => {
+ const noneOption = {
+ label: t("item_card.subtitles.none"),
+ sublabel: undefined as string | undefined,
+ value: -1,
+ selected: currentSubtitleIndex === -1,
+ setTrack: () => modalState?.onDisableSubtitles?.(),
+ isLocal: false,
+ };
+ const options = subtitleTracks.map((track: Track) => ({
+ label: track.name,
+ sublabel: track.isLocal
+ ? t("player.downloaded") || "Downloaded"
+ : (undefined as string | undefined),
+ value: track.index,
+ selected: track.index === currentSubtitleIndex,
+ setTrack: track.setTrack,
+ isLocal: track.isLocal ?? false,
+ }));
+ return [noneOption, ...options];
+ }, [subtitleTracks, currentSubtitleIndex, t, modalState]);
+
+ if (!modalState) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ {/* Header with tabs */}
+
+
+ {t("item_card.subtitles.label") || "Subtitles"}
+
+
+ {/* Tab bar */}
+
+ setActiveTab("tracks")}
+ />
+ setActiveTab("download")}
+ />
+ setActiveTab("settings")}
+ />
+
+
+
+ {/* Tracks Tab Content */}
+ {activeTab === "tracks" && isTabContentReady && (
+
+
+ {trackOptions.map((option, index) => (
+ handleTrackSelect(option)}
+ />
+ ))}
+
+
+ )}
+
+ {/* Download Tab Content */}
+ {activeTab === "download" && isTabContentReady && (
+ <>
+ {/* Language Selector */}
+
+
+ {t("player.language") || "Language"}
+
+
+ {displayLanguages.map((lang, index) => (
+ handleLanguageSelect(lang.code)}
+ />
+ ))}
+
+
+
+ {/* Results Section */}
+
+
+ {t("player.results") || "Results"}
+ {searchResults && ` (${searchResults.length})`}
+
+
+ {/* Loading state */}
+ {isSearching && (
+
+
+
+ )}
+
+ {/* Error state */}
+ {searchError && !isSearching && (
+
+
+
+ {t("player.search_failed") || "Search failed"}
+
+
+ {!hasOpenSubtitlesApiKey
+ ? t("player.no_subtitle_provider") ||
+ "No subtitle provider configured on server"
+ : String(searchError)}
+
+
+ )}
+
+ {/* No results */}
+ {searchResults &&
+ searchResults.length === 0 &&
+ !isSearching &&
+ !searchError && (
+
+
+
+ {t("player.no_subtitles_found") ||
+ "No subtitles found"}
+
+
+ )}
+
+ {/* Results list */}
+ {searchResults &&
+ searchResults.length > 0 &&
+ !isSearching && (
+
+ {searchResults.map((result, index) => (
+ handleDownload(result)}
+ />
+ ))}
+
+ )}
+
+
+ {/* API Key hint if no fallback available */}
+ {!hasOpenSubtitlesApiKey && (
+
+
+
+ {t("player.add_opensubtitles_key_hint") ||
+ "Add OpenSubtitles API key in settings for client-side fallback"}
+
+
+ )}
+ >
+ )}
+
+ {/* Settings Tab Content */}
+ {activeTab === "settings" && isTabContentReady && (
+
+
+ {/* Subtitle Scale */}
+
+ `${v.toFixed(1)}x`}
+ onChange={(newValue) => {
+ updateSettings({
+ mpvSubtitleScale: Math.round(newValue * 10) / 10,
+ });
+ }}
+ hasTVPreferredFocus={true}
+ />
+
+ {t("home.settings.subtitles.mpv_subtitle_scale") ||
+ "Subtitle Scale"}
+
+
+
+ {/* Vertical Margin */}
+
+ `${v}`}
+ onChange={(newValue) => {
+ updateSettings({ mpvSubtitleMarginY: newValue });
+ }}
+ />
+
+ {t("home.settings.subtitles.mpv_subtitle_margin_y") ||
+ "Vertical Margin"}
+
+
+
+ {/* Horizontal Alignment */}
+
+
+ {(["left", "center", "right"] as const).map((align) => (
+
+ updateSettings({ mpvSubtitleAlignX: align })
+ }
+ />
+ ))}
+
+
+ {t("home.settings.subtitles.mpv_subtitle_align_x") ||
+ "Horizontal Align"}
+
+
+
+ {/* Vertical Alignment */}
+
+
+ {(["top", "center", "bottom"] as const).map((align) => (
+
+ updateSettings({ mpvSubtitleAlignY: align })
+ }
+ />
+ ))}
+
+
+ {t("home.settings.subtitles.mpv_subtitle_align_y") ||
+ "Vertical Align"}
+
+
+
+
+ )}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ backgroundColor: "rgba(0, 0, 0, 0.6)",
+ justifyContent: "flex-end",
+ },
+ sheetContainer: {
+ maxHeight: "70%",
+ },
+ blurContainer: {
+ borderTopLeftRadius: scaleSize(24),
+ borderTopRightRadius: scaleSize(24),
+ overflow: "hidden",
+ },
+ content: {
+ paddingTop: scaleSize(24),
+ paddingBottom: scaleSize(48),
+ },
+ header: {
+ paddingHorizontal: scaleSize(48),
+ marginBottom: scaleSize(20),
+ },
+ title: {
+ fontWeight: "600",
+ color: "#fff",
+ marginBottom: scaleSize(16),
+ },
+ tabRow: {
+ flexDirection: "row",
+ gap: scaleSize(24),
+ },
+ section: {
+ marginBottom: scaleSize(20),
+ },
+ sectionTitle: {
+ fontWeight: "500",
+ color: "rgba(255,255,255,0.5)",
+ textTransform: "uppercase",
+ letterSpacing: 1,
+ marginBottom: scaleSize(12),
+ paddingHorizontal: scaleSize(48),
+ },
+ tracksScroll: {
+ overflow: "visible",
+ },
+ tracksScrollContent: {
+ paddingHorizontal: scaleSize(48),
+ paddingVertical: scaleSize(8),
+ gap: scaleSize(12),
+ },
+ trackCard: {
+ width: scaleSize(180),
+ height: scaleSize(80),
+ borderRadius: scaleSize(14),
+ justifyContent: "center",
+ alignItems: "center",
+ paddingHorizontal: scaleSize(12),
+ },
+ trackCardText: {
+ textAlign: "center",
+ },
+ trackCardSublabel: {
+ marginTop: scaleSize(2),
+ },
+ checkmark: {
+ position: "absolute",
+ top: scaleSize(8),
+ right: scaleSize(8),
+ },
+ languageScroll: {
+ overflow: "visible",
+ },
+ languageScrollContent: {
+ paddingHorizontal: scaleSize(48),
+ paddingVertical: scaleSize(8),
+ gap: scaleSize(10),
+ },
+ languageCard: {
+ width: scaleSize(120),
+ height: scaleSize(60),
+ borderRadius: scaleSize(12),
+ justifyContent: "center",
+ alignItems: "center",
+ paddingHorizontal: scaleSize(12),
+ },
+ languageCardText: {
+ fontWeight: "500",
+ },
+ languageCardCode: {
+ marginTop: scaleSize(2),
+ },
+ resultsScroll: {
+ overflow: "visible",
+ },
+ resultsScrollContent: {
+ paddingHorizontal: scaleSize(48),
+ paddingVertical: scaleSize(8),
+ gap: scaleSize(12),
+ },
+ resultCard: {
+ width: scaleSize(220),
+ height: scaleSize(130),
+ borderRadius: scaleSize(14),
+ padding: scaleSize(14),
+ borderWidth: 1,
+ overflow: "hidden",
+ },
+ providerBadge: {
+ alignSelf: "flex-start",
+ paddingHorizontal: scaleSize(8),
+ paddingVertical: scaleSize(3),
+ borderRadius: scaleSize(6),
+ marginBottom: scaleSize(8),
+ },
+ providerText: {
+ fontWeight: "600",
+ textTransform: "uppercase",
+ letterSpacing: 0.5,
+ },
+ resultName: {
+ fontWeight: "500",
+ marginBottom: scaleSize(8),
+ lineHeight: scaleSize(18),
+ },
+ resultMeta: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: scaleSize(12),
+ marginBottom: scaleSize(8),
+ },
+ resultMetaText: {},
+ ratingContainer: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: scaleSize(3),
+ },
+ downloadCountContainer: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: scaleSize(3),
+ },
+ flagsContainer: {
+ flexDirection: "row",
+ gap: scaleSize(6),
+ flexWrap: "wrap",
+ },
+ flag: {
+ paddingHorizontal: scaleSize(6),
+ paddingVertical: scaleSize(2),
+ borderRadius: scaleSize(4),
+ },
+ flagText: {
+ fontWeight: "600",
+ color: "#fff",
+ },
+ downloadingOverlay: {
+ ...StyleSheet.absoluteFill,
+ backgroundColor: "rgba(0,0,0,0.5)",
+ borderRadius: scaleSize(14),
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ loadingContainer: {
+ paddingVertical: scaleSize(20),
+ alignItems: "center",
+ },
+ errorContainer: {
+ paddingVertical: scaleSize(40),
+ paddingHorizontal: scaleSize(48),
+ alignItems: "center",
+ },
+ errorText: {
+ color: "rgba(255,100,100,0.9)",
+ marginTop: scaleSize(8),
+ fontWeight: "500",
+ },
+ errorHint: {
+ color: "rgba(255,255,255,0.5)",
+ marginTop: scaleSize(4),
+ textAlign: "center",
+ },
+ emptyContainer: {
+ paddingVertical: scaleSize(40),
+ alignItems: "center",
+ },
+ emptyText: {
+ color: "rgba(255,255,255,0.5)",
+ marginTop: scaleSize(8),
+ },
+ apiKeyHint: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: scaleSize(8),
+ paddingHorizontal: scaleSize(48),
+ paddingTop: scaleSize(8),
+ },
+ apiKeyHintText: {},
+ // Settings tab styles
+ settingsScroll: {
+ maxHeight: scaleSize(300),
+ },
+ settingsScrollContent: {
+ paddingHorizontal: scaleSize(48),
+ paddingVertical: scaleSize(8),
+ gap: scaleSize(24),
+ },
+ settingRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ },
+ settingLabel: {
+ fontWeight: "500",
+ color: "#fff",
+ },
+ sizeControlContainer: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: scaleSize(16),
+ },
+ stepperButton: {
+ width: scaleSize(56),
+ height: scaleSize(56),
+ borderRadius: scaleSize(14),
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ sizeValueContainer: {
+ width: scaleSize(80),
+ alignItems: "center",
+ },
+ sizeValueText: {
+ fontWeight: "600",
+ color: "#fff",
+ fontSize: scaleSize(24),
+ },
+ alignmentRow: {
+ flexDirection: "row",
+ gap: scaleSize(10),
+ },
+ alignmentCard: {
+ paddingHorizontal: scaleSize(20),
+ paddingVertical: scaleSize(14),
+ borderRadius: scaleSize(12),
+ minWidth: scaleSize(90),
+ alignItems: "center",
+ },
+ alignmentCardText: {
+ textTransform: "capitalize",
+ },
+ alignmentCheckmark: {
+ position: "absolute",
+ top: scaleSize(6),
+ right: scaleSize(6),
+ },
+});
diff --git a/app/(auth)/tv-user-switch-modal.tsx b/app/(auth)/tv-user-switch-modal.tsx
new file mode 100644
index 000000000..1478b0f7b
--- /dev/null
+++ b/app/(auth)/tv-user-switch-modal.tsx
@@ -0,0 +1,174 @@
+import { BlurView } from "expo-blur";
+import { useAtomValue } from "jotai";
+import { useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Animated,
+ Easing,
+ ScrollView,
+ StyleSheet,
+ TVFocusGuideView,
+ View,
+} from "react-native";
+import { Text } from "@/components/common/Text";
+import { TVUserCard } from "@/components/tv/TVUserCard";
+import useRouter from "@/hooks/useAppRouter";
+import { tvUserSwitchModalAtom } from "@/utils/atoms/tvUserSwitchModal";
+import type { SavedServerAccount } from "@/utils/secureCredentials";
+import { store } from "@/utils/store";
+
+export default function TVUserSwitchModalPage() {
+ const { t } = useTranslation();
+ const router = useRouter();
+ const modalState = useAtomValue(tvUserSwitchModalAtom);
+
+ const [isReady, setIsReady] = useState(false);
+ const firstCardRef = useRef(null);
+
+ const overlayOpacity = useRef(new Animated.Value(0)).current;
+ const sheetTranslateY = useRef(new Animated.Value(200)).current;
+
+ // Animate in on mount and cleanup atom on unmount
+ useEffect(() => {
+ overlayOpacity.setValue(0);
+ sheetTranslateY.setValue(200);
+
+ Animated.parallel([
+ Animated.timing(overlayOpacity, {
+ toValue: 1,
+ duration: 250,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(sheetTranslateY, {
+ toValue: 0,
+ duration: 300,
+ easing: Easing.out(Easing.cubic),
+ useNativeDriver: true,
+ }),
+ ]).start();
+
+ // Delay focus setup to allow layout
+ const timer = setTimeout(() => setIsReady(true), 100);
+ return () => {
+ clearTimeout(timer);
+ // Clear the atom on unmount to prevent stale callbacks from being retained
+ store.set(tvUserSwitchModalAtom, null);
+ };
+ }, [overlayOpacity, sheetTranslateY]);
+
+ // Request focus on the first card when ready
+ useEffect(() => {
+ if (isReady && firstCardRef.current) {
+ const timer = setTimeout(() => {
+ (firstCardRef.current as any)?.requestTVFocus?.();
+ }, 50);
+ return () => clearTimeout(timer);
+ }
+ }, [isReady]);
+
+ const handleSelect = (account: SavedServerAccount) => {
+ modalState?.onAccountSelect(account);
+ store.set(tvUserSwitchModalAtom, null);
+ router.back();
+ };
+
+ // If no modal state, just return null
+ if (!modalState) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ {t("home.settings.switch_user.title")}
+
+ {modalState.serverName}
+ {isReady && (
+
+ {modalState.accounts.map((account, index) => {
+ const isCurrent = account.userId === modalState.currentUserId;
+ return (
+ handleSelect(account)}
+ />
+ );
+ })}
+
+ )}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ justifyContent: "flex-end",
+ },
+ sheetContainer: {
+ width: "100%",
+ },
+ blurContainer: {
+ borderTopLeftRadius: 24,
+ borderTopRightRadius: 24,
+ overflow: "hidden",
+ },
+ content: {
+ paddingTop: 24,
+ paddingBottom: 50,
+ overflow: "visible",
+ },
+ title: {
+ fontSize: 18,
+ fontWeight: "500",
+ color: "rgba(255,255,255,0.6)",
+ marginBottom: 4,
+ paddingHorizontal: 48,
+ textTransform: "uppercase",
+ letterSpacing: 1,
+ },
+ subtitle: {
+ fontSize: 14,
+ color: "rgba(255,255,255,0.4)",
+ marginBottom: 16,
+ paddingHorizontal: 48,
+ },
+ scrollView: {
+ overflow: "visible",
+ },
+ scrollContent: {
+ paddingHorizontal: 48,
+ paddingVertical: 20,
+ gap: 16,
+ },
+});
diff --git a/app/_layout.tsx b/app/_layout.tsx
index b373739c7..d3e6fd5d9 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,22 +1,31 @@
import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
-import { DarkTheme, ThemeProvider } from "@react-navigation/native";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import NetInfo from "@react-native-community/netinfo";
+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 { Image } from "expo-image";
+import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal";
+import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
+import { InactivityProvider } from "@/providers/InactivityProvider";
+import { IntroSheetProvider } from "@/providers/IntroSheetProvider";
import {
apiAtom,
getOrSetDeviceId,
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 { useSettings } from "@/utils/atoms/settings";
import {
@@ -42,24 +51,42 @@ import type {
NotificationResponse,
} from "expo-notifications/build/Notifications.types";
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
-import { router, Stack, useSegments } from "expo-router";
+import { Stack, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import * as 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 { Appearance, LogBox } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
+
+// Suppress harmless tvOS warning from react-native-gesture-handler
+if (Platform.isTV) {
+ LogBox.ignoreLogs(["HoverGestureHandler is not supported on tvOS"]);
+}
+
+import useRouter from "@/hooks/useAppRouter";
import { userAtom } from "@/providers/JellyfinProvider";
-import { store } from "@/utils/store";
+import { store as jotaiStore, store } from "@/utils/store";
import "react-native-reanimated";
+import {
+ configureReanimatedLogger,
+ ReanimatedLogLevel,
+} from "react-native-reanimated";
import { Toaster } from "sonner-native";
+// Disable strict mode warnings for reading shared values during render
+configureReanimatedLogger({
+ level: ReanimatedLogLevel.warn,
+ strict: false,
+});
+
if (!Platform.isTV) {
Notifications.setNotificationHandler({
handleNotification: async () => ({
- shouldShowAlert: true,
+ shouldShowBanner: true,
+ shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
@@ -75,14 +102,25 @@ SplashScreen.setOptions({
fade: true,
});
-function redirect(notification: typeof Notifications.Notification) {
- const url = notification.request.content.data?.url;
- if (url) {
- router.push(url);
- }
+// Cap expo-image's in-memory cache. Default is unbounded (maxMemoryCost=0),
+// which on a 2GB Android TV box leads to ~200MB of decoded backdrops/posters
+// pinned in RAM after browsing. Caps are intentionally tighter on TV (which
+// has less RAM and runs alongside libmpv/MediaCodec) than on mobile.
+// Cost is measured in bytes of decoded bitmap (ARGB8888 = 4 bytes/pixel).
+try {
+ Image.configureCache({
+ maxMemoryCost: Platform.isTV
+ ? 8 * 1024 * 1024 // ~8 MB on TV
+ : 128 * 1024 * 1024, // ~128 MB on mobile
+ maxDiskSize: 200 * 1024 * 1024, // 200 MB disk cache on all platforms
+ });
+} catch {
+ // configureCache is a no-op on some platforms/versions; safe to ignore.
}
function useNotificationObserver() {
+ const router = useRouter();
+
useEffect(() => {
if (Platform.isTV) return;
@@ -93,14 +131,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) {
@@ -173,7 +214,7 @@ export default function RootLayout() {
return (
-
+
@@ -184,11 +225,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: 30000,
+ 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),
},
});
@@ -197,6 +266,12 @@ function Layout() {
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const _segments = useSegments();
+ const router = useRouter();
+
+ // Enable TV menu key interception so React Native handles it instead of tvOS
+ useEffect(() => {
+ enableTVMenuKeyInterception();
+ }, []);
useEffect(() => {
i18n.changeLanguage(
@@ -218,22 +293,19 @@ function Layout() {
deviceId: getOrSetDeviceId(),
userId: user.Id,
})
- .then((_) => console.log("Posted expo push token"))
.catch((_) =>
writeErrorLog("Failed to push expo push token to plugin"),
);
- } else console.log("No token available");
+ }
}, [api, expoPushToken, user]);
const registerNotifications = useCallback(async () => {
if (Platform.OS === "android") {
- console.log("Setting android notification channel 'default'");
await Notifications?.setNotificationChannelAsync("default", {
name: "default",
});
// Create dedicated channel for download notifications
- console.log("Setting android notification channel 'downloads'");
await Notifications?.setNotificationChannelAsync("downloads", {
name: "Downloads",
importance: Notifications.AndroidImportance.DEFAULT,
@@ -279,9 +351,12 @@ function Layout() {
notificationListener.current =
Notifications?.addNotificationReceivedListener(
(notification: Notification) => {
+ // Log only the title — serializing the whole notification touches
+ // the deprecated dataString getter (deprecation warning) and dumps
+ // noisy payloads into the console.
console.log(
- "Notification received while app running",
- notification,
+ "Notification received while app running:",
+ notification.request.content.title,
);
},
);
@@ -289,9 +364,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);
@@ -311,8 +383,8 @@ function Layout() {
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
// summarized season notification for multiple episodes. Bring them to series season
} else {
- const seriesId = data.seriesId;
- const seasonIndex = data.seasonIndex;
+ const seriesId = data?.seriesId;
+ const seasonIndex = data?.seasonIndex;
if (seasonIndex) {
url = `/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`;
} else {
@@ -337,68 +409,161 @@ function Layout() {
}, [user]);
return (
-
+ {
+ return (
+ query.state.status === "success" && query.options.gcTime !== 0
+ );
+ },
+ },
+ }}
+ >
-
-
-
-
-
-
-
-
-
-
- null,
- }}
- />
- null,
- }}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ null,
+ }}
+ />
+ null,
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!Platform.isTV && }
+
+
+
+
+
+
+
+
+
+
+
+
-
+
);
}
diff --git a/app/login.tsx b/app/login.tsx
index 46e6ab140..d028ae4f2 100644
--- a/app/login.tsx
+++ b/app/login.tsx
@@ -1,541 +1,13 @@
-import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
-import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
-import { Image } from "expo-image";
-import { useLocalSearchParams, useNavigation } from "expo-router";
-import { t } from "i18next";
-import { useAtomValue } from "jotai";
-import { useCallback, useEffect, useState } from "react";
-import {
- Alert,
- Keyboard,
- KeyboardAvoidingView,
- Platform,
- 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 { Colors } from "@/constants/Colors";
-import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
+import { Platform } from "react-native";
+import { Login } from "@/components/login/Login";
+import { TVLogin } from "@/components/login/TVLogin";
-const CredentialsSchema = z.object({
- username: z.string().min(1, t("login.username_required")),
-});
-
-const Login: React.FC = () => {
- const api = useAtomValue(apiAtom);
- const navigation = useNavigation();
- const params = useLocalSearchParams();
- const { setServer, login, removeServer, initiateQuickConnect } =
- useJellyfin();
-
- const {
- apiUrl: _apiUrl,
- username: _username,
- password: _password,
- } = params as { apiUrl: string; username: string; password: string };
-
- const [loadingServerCheck, setLoadingServerCheck] = useState(false);
- const [loading, setLoading] = useState(false);
- const [serverURL, setServerURL] = useState(_apiUrl || "");
- const [serverName, setServerName] = useState("");
- const [credentials, setCredentials] = useState<{
- username: string;
- password: string;
- }>({
- username: _username || "",
- password: _password || "",
- });
-
- /**
- * A way to auto login based on a link
- */
- useEffect(() => {
- (async () => {
- if (_apiUrl) {
- await setServer({
- address: _apiUrl,
- });
-
- // Wait for server setup and state updates to complete
- setTimeout(() => {
- if (_username && _password) {
- setCredentials({ username: _username, password: _password });
- login(_username, _password);
- }
- }, 0);
- }
- })();
- }, [_apiUrl, _username, _password]);
-
- useEffect(() => {
- navigation.setOptions({
- headerTitle: serverName,
- headerLeft: () =>
- api?.basePath ? (
- {
- removeServer();
- }}
- className='flex flex-row items-center pr-2 pl-1'
- >
-
-
- {t("login.change_server")}
-
-
- ) : null,
- });
- }, [serverName, navigation, api?.basePath]);
-
- const handleLogin = async () => {
- Keyboard.dismiss();
-
- setLoading(true);
- try {
- const result = CredentialsSchema.safeParse(credentials);
- if (result.success) {
- await login(credentials.username, credentials.password);
- }
- } catch (error) {
- if (error instanceof Error) {
- Alert.alert(t("login.connection_failed"), error.message);
- } else {
- Alert.alert(
- t("login.connection_failed"),
- t("login.an_unexpected_error_occured"),
- );
- }
- } finally {
- setLoading(false);
- }
- };
-
- /**
- * Checks the availability and validity of a Jellyfin server URL.
- *
- * This function attempts to connect to a Jellyfin server using the provided URL.
- * It tries both HTTPS and HTTP protocols, with a timeout to handle long 404 responses.
- *
- * @param {string} url - The base URL of the Jellyfin server to check.
- * @returns {Promise} A Promise that resolves to:
- * - The full URL (including protocol) if a valid Jellyfin server is found.
- * - undefined if no valid server is found at the given URL.
- *
- * Side effects:
- * - Sets loadingServerCheck state to true at the beginning and false at the end.
- * - Logs errors and timeout information to the console.
- */
- const checkUrl = useCallback(async (url: string) => {
- setLoadingServerCheck(true);
- const baseUrl = url.replace(/^https?:\/\//i, "");
- const protocols = ["https", "http"];
- try {
- return checkHttp(baseUrl, protocols);
- } catch (e) {
- if (e instanceof Error && e.message === "Server too old") {
- throw e;
- }
- return undefined;
- } finally {
- setLoadingServerCheck(false);
- }
- }, []);
-
- async function checkHttp(baseUrl: string, protocols: string[]) {
- for (const protocol of protocols) {
- try {
- const response = await fetch(
- `${protocol}://${baseUrl}/System/Info/Public`,
- {
- mode: "cors",
- },
- );
- if (response.ok) {
- const data = (await response.json()) as PublicSystemInfo;
- const serverVersion = data.Version?.split(".");
- if (serverVersion && +serverVersion[0] <= 10) {
- if (+serverVersion[1] < 10) {
- Alert.alert(
- t("login.too_old_server_text"),
- t("login.too_old_server_description"),
- );
- throw new Error("Server too old");
- }
- }
- setServerName(data.ServerName || "");
- return `${protocol}://${baseUrl}`;
- }
- } catch (e) {
- if (e instanceof Error && e.message === "Server too old") {
- throw e;
- }
- }
- }
- return undefined;
+const LoginPage: React.FC = () => {
+ if (Platform.isTV) {
+ return ;
}
- /**
- * Handles the connection attempt to a Jellyfin server.
- *
- * This function trims the input URL, checks its validity using the `checkUrl` function,
- * and sets the server address if a valid connection is established.
- *
- * @param {string} url - The URL of the Jellyfin server to connect to.
- *
- * @returns {Promise}
- *
- * Side effects:
- * - Calls `checkUrl` to validate the server URL.
- * - Shows an alert if the connection fails.
- * - Sets the server address using `setServer` if the connection is successful.
- *
- */
- const handleConnect = useCallback(async (url: string) => {
- url = url.trim().replace(/\/$/, "");
- try {
- const result = await checkUrl(url);
- if (result === undefined) {
- Alert.alert(
- t("login.connection_failed"),
- t("login.could_not_connect_to_server"),
- );
- return;
- }
- await setServer({ address: result });
- } catch {}
- }, []);
- const handleQuickConnect = async () => {
- try {
- const code = await initiateQuickConnect();
- if (code) {
- Alert.alert(
- t("login.quick_connect"),
- t("login.enter_code_to_login", { code: code }),
- [
- {
- text: t("login.got_it"),
- },
- ],
- );
- }
- } catch (_error) {
- Alert.alert(
- t("login.error_title"),
- t("login.failed_to_initiate_quick_connect"),
- );
- }
- };
-
- return Platform.isTV ? (
- // TV layout
-
-
- {api?.basePath ? (
- // ------------ Username/Password view ------------
-
- {/* Safe centered column with max width so TV doesn’t stretch too far */}
-
-
- {serverName ? (
- <>
- {`${t("login.login_to_title")} `}
- {serverName}
- >
- ) : (
- t("login.login_title")
- )}
-
-
- {api.basePath}
-
-
- {/* Username */}
-
- setCredentials({ ...credentials, username: text })
- }
- onEndEditing={(e) => {
- const newValue = e.nativeEvent.text;
- if (newValue && newValue !== credentials.username) {
- setCredentials({ ...credentials, username: newValue });
- }
- }}
- value={credentials.username}
- keyboardType='default'
- returnKeyType='done'
- autoCapitalize='none'
- textContentType='oneTimeCode'
- clearButtonMode='while-editing'
- maxLength={500}
- extraClassName='mb-4'
- autoFocus={false}
- blurOnSubmit={true}
- />
-
- {/* Password */}
-
- setCredentials({ ...credentials, password: text })
- }
- onEndEditing={(e) => {
- const newValue = e.nativeEvent.text;
- if (newValue && newValue !== credentials.password) {
- setCredentials({ ...credentials, password: newValue });
- }
- }}
- value={credentials.password}
- secureTextEntry
- keyboardType='default'
- returnKeyType='done'
- autoCapitalize='none'
- textContentType='password'
- clearButtonMode='while-editing'
- maxLength={500}
- extraClassName='mb-4'
- autoFocus={false}
- blurOnSubmit={true}
- />
-
-
-
-
-
-
-
-
-
- ) : (
- // ------------ Server connect view ------------
-
-
-
-
-
-
-
- Streamyfin
-
-
- {t("server.enter_url_to_jellyfin_server")}
-
-
- {/* Full-width Input with clear focus ring */}
-
-
- {/* Full-width primary button */}
-
-
-
-
- {/* Lists stay full width but inside max width container */}
-
- {
- setServerURL(server.address);
- if (server.serverName) setServerName(server.serverName);
- await handleConnect(server.address);
- }}
- />
- {
- await handleConnect(s.address);
- }}
- />
-
-
-
- )}
-
-
- ) : (
- // Mobile layout
-
-
- {api?.basePath ? (
-
-
-
-
- {serverName ? (
- <>
- {`${t("login.login_to_title")} `}
- {serverName}
- >
- ) : (
- t("login.login_title")
- )}
-
- {api.basePath}
-
- setCredentials({ ...credentials, username: text })
- }
- onEndEditing={(e) => {
- const newValue = e.nativeEvent.text;
- if (newValue && newValue !== credentials.username) {
- setCredentials({ ...credentials, 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'
- clearButtonMode='while-editing'
- maxLength={500}
- />
-
-
- setCredentials({ ...credentials, password: text })
- }
- onEndEditing={(e) => {
- const newValue = e.nativeEvent.text;
- if (newValue && newValue !== credentials.password) {
- setCredentials({ ...credentials, password: newValue });
- }
- }}
- value={credentials.password}
- secureTextEntry
- keyboardType='default'
- returnKeyType='done'
- autoCapitalize='none'
- textContentType='password'
- clearButtonMode='while-editing'
- maxLength={500}
- />
-
-
-
-
-
-
-
-
-
-
-
- ) : (
-
-
-
- Streamyfin
-
- {t("server.enter_url_to_jellyfin_server")}
-
-
-
- {
- setServerURL(server.address);
- if (server.serverName) {
- setServerName(server.serverName);
- }
- await handleConnect(server.address);
- }}
- />
- {
- await handleConnect(s.address);
- }}
- />
-
-
- )}
-
-
- );
+ return ;
};
-export default Login;
+export default LoginPage;
diff --git a/app/topshelf/item.tsx b/app/topshelf/item.tsx
new file mode 100644
index 000000000..6f93cf5b7
--- /dev/null
+++ b/app/topshelf/item.tsx
@@ -0,0 +1,33 @@
+import { useLocalSearchParams, useRootNavigationState } from "expo-router";
+import { useEffect } from "react";
+import { View } from "react-native";
+import useRouter from "@/hooks/useAppRouter";
+
+export default function TopShelfItemRedirect() {
+ const router = useRouter();
+ const rootNavigationState = useRootNavigationState();
+ const { id, type } = useLocalSearchParams<{
+ id?: string;
+ type?: string;
+ }>();
+
+ useEffect(() => {
+ if (!rootNavigationState?.key) {
+ return;
+ }
+
+ if (!id) {
+ router.replace("/(auth)/(tabs)/(home)");
+ return;
+ }
+
+ if (type === "Series") {
+ router.replace(`/(auth)/(tabs)/(home)/series/${id}`);
+ return;
+ }
+
+ router.replace(`/(auth)/(tabs)/(home)/items/page?id=${id}`);
+ }, [id, rootNavigationState?.key, router, type]);
+
+ return ;
+}
diff --git a/app/topshelf/play.tsx b/app/topshelf/play.tsx
new file mode 100644
index 000000000..5b848d27a
--- /dev/null
+++ b/app/topshelf/play.tsx
@@ -0,0 +1,32 @@
+import { useLocalSearchParams, useRootNavigationState } from "expo-router";
+import { useEffect } from "react";
+import { View } from "react-native";
+import useRouter from "@/hooks/useAppRouter";
+
+export default function TopShelfPlayRedirect() {
+ const router = useRouter();
+ const rootNavigationState = useRootNavigationState();
+ const { id } = useLocalSearchParams<{
+ id?: string;
+ }>();
+
+ useEffect(() => {
+ if (!rootNavigationState?.key) {
+ return;
+ }
+
+ if (!id) {
+ router.replace("/(auth)/(tabs)/(home)");
+ return;
+ }
+
+ const queryParams = new URLSearchParams({
+ itemId: id,
+ offline: "false",
+ });
+
+ router.replace(`/player/direct-player?${queryParams.toString()}`);
+ }, [id, rootNavigationState?.key, router]);
+
+ return ;
+}
diff --git a/app/tv-account-action-modal.tsx b/app/tv-account-action-modal.tsx
new file mode 100644
index 000000000..87f42dfe7
--- /dev/null
+++ b/app/tv-account-action-modal.tsx
@@ -0,0 +1,251 @@
+import { Ionicons } from "@expo/vector-icons";
+import { BlurView } from "expo-blur";
+import { useAtomValue } from "jotai";
+import { useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Animated,
+ Easing,
+ Pressable,
+ ScrollView,
+ TVFocusGuideView,
+} from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
+import { tvAccountActionModalAtom } from "@/utils/atoms/tvAccountActionModal";
+import { store } from "@/utils/store";
+
+// Action card component
+const TVAccountActionCard: React.FC<{
+ label: string;
+ icon: keyof typeof Ionicons.glyphMap;
+ variant?: "default" | "destructive";
+ hasTVPreferredFocus?: boolean;
+ onPress: () => void;
+}> = ({ label, icon, variant = "default", hasTVPreferredFocus, onPress }) => {
+ const [focused, setFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+ const typography = useScaledTVTypography();
+
+ const animateTo = (v: number) =>
+ Animated.timing(scale, {
+ toValue: v,
+ duration: 150,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start();
+
+ const isDestructive = variant === "destructive";
+
+ return (
+ {
+ setFocused(true);
+ animateTo(1.05);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
+ hasTVPreferredFocus={hasTVPreferredFocus}
+ >
+
+
+
+ {label}
+
+
+
+ );
+};
+
+export default function TVAccountActionModalPage() {
+ const typography = useScaledTVTypography();
+ const router = useRouter();
+ const modalState = useAtomValue(tvAccountActionModalAtom);
+ const { t } = useTranslation();
+
+ const [isReady, setIsReady] = useState(false);
+ const overlayOpacity = useRef(new Animated.Value(0)).current;
+ const sheetTranslateY = useRef(new Animated.Value(200)).current;
+
+ // Animate in on mount
+ useEffect(() => {
+ overlayOpacity.setValue(0);
+ sheetTranslateY.setValue(200);
+
+ Animated.parallel([
+ Animated.timing(overlayOpacity, {
+ toValue: 1,
+ duration: 250,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(sheetTranslateY, {
+ toValue: 0,
+ duration: 300,
+ easing: Easing.out(Easing.cubic),
+ useNativeDriver: true,
+ }),
+ ]).start();
+
+ const timer = setTimeout(() => setIsReady(true), 100);
+ return () => {
+ clearTimeout(timer);
+ store.set(tvAccountActionModalAtom, null);
+ };
+ }, [overlayOpacity, sheetTranslateY]);
+
+ const handleLogin = () => {
+ modalState?.onLogin();
+ router.back();
+ };
+
+ const handleDelete = () => {
+ modalState?.onDelete();
+ router.back();
+ };
+
+ if (!modalState) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ {/* Account username as title */}
+
+ {modalState.account.username}
+
+
+ {/* Server name as subtitle */}
+
+ {modalState.server.name || modalState.server.address}
+
+
+ {/* Horizontal options */}
+ {isReady && (
+
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/app/tv-account-select-modal.tsx b/app/tv-account-select-modal.tsx
new file mode 100644
index 000000000..a8a8e6bd8
--- /dev/null
+++ b/app/tv-account-select-modal.tsx
@@ -0,0 +1,256 @@
+import { Ionicons } from "@expo/vector-icons";
+import { BlurView } from "expo-blur";
+import { useAtomValue } from "jotai";
+import { useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Animated,
+ Easing,
+ Pressable,
+ ScrollView,
+ TVFocusGuideView,
+} from "react-native";
+import { Text } from "@/components/common/Text";
+import { TVUserCard } from "@/components/tv/TVUserCard";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
+import { tvAccountSelectModalAtom } from "@/utils/atoms/tvAccountSelectModal";
+import { store } from "@/utils/store";
+
+// Action button for bottom sheet
+const TVAccountSelectAction: React.FC<{
+ label: string;
+ icon: keyof typeof Ionicons.glyphMap;
+ variant?: "default" | "destructive";
+ onPress: () => void;
+}> = ({ label, icon, variant = "default", onPress }) => {
+ const [focused, setFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+ const typography = useScaledTVTypography();
+
+ const animateTo = (v: number) =>
+ Animated.timing(scale, {
+ toValue: v,
+ duration: 150,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start();
+
+ const isDestructive = variant === "destructive";
+
+ return (
+ {
+ setFocused(true);
+ animateTo(1.05);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
+ >
+
+
+
+ {label}
+
+
+
+ );
+};
+
+export default function TVAccountSelectModalPage() {
+ const typography = useScaledTVTypography();
+ const router = useRouter();
+ const modalState = useAtomValue(tvAccountSelectModalAtom);
+ const { t } = useTranslation();
+
+ const [isReady, setIsReady] = useState(false);
+ const overlayOpacity = useRef(new Animated.Value(0)).current;
+ const sheetTranslateY = useRef(new Animated.Value(300)).current;
+
+ // Animate in on mount
+ useEffect(() => {
+ overlayOpacity.setValue(0);
+ sheetTranslateY.setValue(300);
+
+ Animated.parallel([
+ Animated.timing(overlayOpacity, {
+ toValue: 1,
+ duration: 250,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(sheetTranslateY, {
+ toValue: 0,
+ duration: 300,
+ easing: Easing.out(Easing.cubic),
+ useNativeDriver: true,
+ }),
+ ]).start();
+
+ const timer = setTimeout(() => setIsReady(true), 100);
+ return () => {
+ clearTimeout(timer);
+ store.set(tvAccountSelectModalAtom, null);
+ };
+ }, [overlayOpacity, sheetTranslateY]);
+
+ if (!modalState) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ {/* Title */}
+
+ {t("server.select_account")}
+
+
+ {/* Server name as subtitle */}
+
+ {modalState.server.name || modalState.server.address}
+
+
+ {/* All options in single horizontal row */}
+ {isReady && (
+
+ {modalState.server.accounts?.map((account, index) => (
+ {
+ modalState.onAccountAction(account);
+ }}
+ hasTVPreferredFocus={index === 0}
+ />
+ ))}
+ {
+ modalState.onAddAccount();
+ router.back();
+ }}
+ />
+ {
+ modalState.onDeleteServer();
+ router.back();
+ }}
+ />
+
+ )}
+
+
+
+
+ );
+}
diff --git a/assets/Download_with_Obtainium.png b/assets/Download_with_Obtainium.png
new file mode 100644
index 000000000..a4cf4f9ca
Binary files /dev/null and b/assets/Download_with_Obtainium.png differ
diff --git a/assets/icons/gearshape.fill.png b/assets/icons/gearshape.fill.png
new file mode 100644
index 000000000..a3ee5bfe1
Binary files /dev/null and b/assets/icons/gearshape.fill.png differ
diff --git a/assets/icons/heart.fill.png b/assets/icons/heart.fill.png
index 25bb2527a..fd868d990 100644
Binary files a/assets/icons/heart.fill.png and b/assets/icons/heart.fill.png differ
diff --git a/assets/icons/heart.png b/assets/icons/heart.png
deleted file mode 100644
index 96a448a79..000000000
Binary files a/assets/icons/heart.png and /dev/null differ
diff --git a/assets/icons/house.fill.png b/assets/icons/house.fill.png
index 9e32f71e7..aa6f116c9 100644
Binary files a/assets/icons/house.fill.png and b/assets/icons/house.fill.png differ
diff --git a/assets/icons/jellyseerr-logo.svg b/assets/icons/jellyseerr-logo.svg
deleted file mode 100644
index 1f8b997df..000000000
--- a/assets/icons/jellyseerr-logo.svg
+++ /dev/null
@@ -1,118 +0,0 @@
-
-
diff --git a/assets/icons/link.png b/assets/icons/link.png
new file mode 100644
index 000000000..95d08787f
Binary files /dev/null and b/assets/icons/link.png differ
diff --git a/assets/icons/list.png b/assets/icons/list.png
deleted file mode 100644
index 3c548bb4c..000000000
Binary files a/assets/icons/list.png and /dev/null differ
diff --git a/assets/icons/list.star.png b/assets/icons/list.star.png
new file mode 100644
index 000000000..cfa85c3ac
Binary files /dev/null and b/assets/icons/list.star.png differ
diff --git a/assets/icons/magnifyingglass.png b/assets/icons/magnifyingglass.png
index 5fc44c41b..d62b64825 100644
Binary files a/assets/icons/magnifyingglass.png and b/assets/icons/magnifyingglass.png differ
diff --git a/assets/icons/rectangle.stack.fill.png b/assets/icons/rectangle.stack.fill.png
new file mode 100644
index 000000000..86460d708
Binary files /dev/null and b/assets/icons/rectangle.stack.fill.png differ
diff --git a/assets/icons/seerr-logo.svg b/assets/icons/seerr-logo.svg
new file mode 100644
index 000000000..a0e32e793
--- /dev/null
+++ b/assets/icons/seerr-logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/server.rack.png b/assets/icons/server.rack.png
deleted file mode 100644
index 245e5ad2d..000000000
Binary files a/assets/icons/server.rack.png and /dev/null differ
diff --git a/assets/images/icon-tvos-small-2x.png b/assets/images/icon-tvos-small-2x.png
new file mode 100644
index 000000000..ba42f64e7
Binary files /dev/null and b/assets/images/icon-tvos-small-2x.png differ
diff --git a/assets/images/icon-tvos-small.png b/assets/images/icon-tvos-small.png
new file mode 100644
index 000000000..8f2300d62
Binary files /dev/null and b/assets/images/icon-tvos-small.png differ
diff --git a/assets/images/icon-tvos-topshelf-2x.png b/assets/images/icon-tvos-topshelf-2x.png
new file mode 100644
index 000000000..611869f9d
Binary files /dev/null and b/assets/images/icon-tvos-topshelf-2x.png differ
diff --git a/assets/images/icon-tvos-topshelf-wide-2x.png b/assets/images/icon-tvos-topshelf-wide-2x.png
new file mode 100644
index 000000000..94c4378e0
Binary files /dev/null and b/assets/images/icon-tvos-topshelf-wide-2x.png differ
diff --git a/assets/images/icon-tvos-topshelf-wide.png b/assets/images/icon-tvos-topshelf-wide.png
new file mode 100644
index 000000000..45bca6fea
Binary files /dev/null and b/assets/images/icon-tvos-topshelf-wide.png differ
diff --git a/assets/images/icon-tvos-topshelf.png b/assets/images/icon-tvos-topshelf.png
new file mode 100644
index 000000000..119fabac4
Binary files /dev/null and b/assets/images/icon-tvos-topshelf.png differ
diff --git a/assets/images/icon-tvos.png b/assets/images/icon-tvos.png
new file mode 100644
index 000000000..df59c8173
Binary files /dev/null and b/assets/images/icon-tvos.png differ
diff --git a/assets/images/not-rotten-tomatoes.svg b/assets/images/not-rotten-tomatoes.svg
deleted file mode 100644
index 18fa58b89..000000000
--- a/assets/images/not-rotten-tomatoes.svg
+++ /dev/null
@@ -1,65 +0,0 @@
-
diff --git a/assets/images/rotten-tomatoes.png b/assets/images/rotten-tomatoes.png
deleted file mode 100644
index 341b62b00..000000000
Binary files a/assets/images/rotten-tomatoes.png and /dev/null differ
diff --git a/assets/images/rt_aud_fresh.svg b/assets/images/rt_aud_fresh.svg
new file mode 100644
index 000000000..f9fa29044
--- /dev/null
+++ b/assets/images/rt_aud_fresh.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/rt_aud_rotten.svg b/assets/images/rt_aud_rotten.svg
new file mode 100644
index 000000000..cd84ac5b0
--- /dev/null
+++ b/assets/images/rt_aud_rotten.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/rt_fresh.svg b/assets/images/rt_fresh.svg
new file mode 100644
index 000000000..ed6f44d73
--- /dev/null
+++ b/assets/images/rt_fresh.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/rt_rotten.svg b/assets/images/rt_rotten.svg
new file mode 100644
index 000000000..60ba169e0
--- /dev/null
+++ b/assets/images/rt_rotten.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/tmdb_logo.svg b/assets/images/tmdb_logo.svg
new file mode 100644
index 000000000..bdf988ba7
--- /dev/null
+++ b/assets/images/tmdb_logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/augmentations/index.ts b/augmentations/index.ts
index abec02c9d..0c193e831 100644
--- a/augmentations/index.ts
+++ b/augmentations/index.ts
@@ -1,4 +1,3 @@
export * from "./api";
export * from "./mmkv";
export * from "./number";
-export * from "./string";
diff --git a/augmentations/number.ts b/augmentations/number.ts
index 11c0837d1..c8146d659 100644
--- a/augmentations/number.ts
+++ b/augmentations/number.ts
@@ -3,7 +3,6 @@ declare global {
bytesToReadable(decimals?: number): string;
secondsToMilliseconds(): number;
minutesToMilliseconds(): number;
- hoursToMilliseconds(): number;
}
}
@@ -11,7 +10,7 @@ Number.prototype.bytesToReadable = function (decimals = 2) {
const bytes = this.valueOf();
if (bytes === 0) return "0 Bytes";
- const k = 1024;
+ const k = 1000;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
@@ -28,8 +27,4 @@ Number.prototype.minutesToMilliseconds = function () {
return this.valueOf() * (60).secondsToMilliseconds();
};
-Number.prototype.hoursToMilliseconds = function () {
- return this.valueOf() * (60).minutesToMilliseconds();
-};
-
export {};
diff --git a/augmentations/string.ts b/augmentations/string.ts
deleted file mode 100644
index f4a50b55c..000000000
--- a/augmentations/string.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-declare global {
- interface String {
- toTitle(): string;
- }
-}
-
-String.prototype.toTitle = function () {
- return this.replaceAll("_", " ").replace(
- /\w\S*/g,
- (text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
- );
-};
-
-export {};
diff --git a/biome.json b/biome.json
index b94bda949..6e51af7d5 100644
--- a/biome.json
+++ b/biome.json
@@ -1,5 +1,5 @@
{
- "$schema": "https://biomejs.dev/schemas/2.3.5/schema.json",
+ "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
"files": {
"includes": [
"**/*",
@@ -8,7 +8,10 @@
"!android",
"!Streamyfin.app",
"!utils/jellyseerr",
- "!.expo"
+ "!expo-env.d.ts",
+ "!modules/**/android/build",
+ "!.expo",
+ "!docs/jellyfin-openapi-stable.json"
]
},
"linter": {
diff --git a/bun.lock b/bun.lock
index 42e697a97..03789f894 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,387 +1,425 @@
{
"lockfileVersion": 1,
+ "configVersion": 1,
"workspaces": {
"": {
"name": "streamyfin",
"dependencies": {
- "@bottom-tabs/react-navigation": "^1.0.2",
- "@expo/metro-runtime": "~6.1.1",
+ "@bottom-tabs/react-navigation": "1.2.0",
+ "@douglowder/expo-av-route-picker-view": "^0.0.5",
+ "@expo/metro-runtime": "~56.0.15",
"@expo/react-native-action-sheet": "^4.1.1",
- "@expo/ui": "^0.2.0-beta.4",
+ "@expo/ui": "~56.0.17",
"@expo/vector-icons": "^15.0.3",
- "@gorhom/bottom-sheet": "^5.1.0",
+ "@gorhom/bottom-sheet": "5.2.14",
"@jellyfin/sdk": "^0.13.0",
- "@react-native-community/netinfo": "^11.4.1",
- "@react-navigation/material-top-tabs": "^7.2.14",
- "@react-navigation/native": "^7.0.14",
- "@shopify/flash-list": "2.0.2",
- "@tanstack/react-query": "^5.90.7",
+ "@react-native-community/netinfo": "^12.0.0",
+ "@react-navigation/material-top-tabs": "7.4.28",
+ "@react-navigation/native": "^7.2.5",
+ "@shopify/flash-list": "2.0.3",
+ "@tanstack/query-sync-storage-persister": "^5.100.14",
+ "@tanstack/react-pacer": "^0.19.1",
+ "@tanstack/react-query": "5.100.14",
+ "@tanstack/react-query-persist-client": "^5.100.14",
"axios": "^1.7.9",
- "expo": "^54.0.23",
- "expo-application": "~7.0.5",
- "expo-asset": "~12.0.6",
- "expo-background-task": "~1.0.5",
- "expo-blur": "~15.0.5",
- "expo-brightness": "~14.0.5",
- "expo-build-properties": "~1.0.6",
- "expo-constants": "~18.0.10",
- "expo-dev-client": "~6.0.17",
- "expo-device": "~8.0.5",
- "expo-font": "~14.0.9",
- "expo-haptics": "~15.0.5",
- "expo-image": "~3.0.10",
- "expo-linear-gradient": "~15.0.5",
- "expo-linking": "~8.0.6",
- "expo-localization": "~17.0.5",
- "expo-notifications": "~0.32.7",
- "expo-router": "~6.0.14",
- "expo-screen-orientation": "~9.0.5",
- "expo-sensors": "~15.0.5",
- "expo-sharing": "~14.0.5",
- "expo-splash-screen": "~31.0.7",
- "expo-status-bar": "~3.0.6",
- "expo-system-ui": "~6.0.8",
- "expo-task-manager": "~14.0.8",
- "expo-web-browser": "~15.0.9",
- "i18next": "^25.0.0",
- "jotai": "^2.12.5",
- "lodash": "^4.17.21",
+ "expo": "~56.0.11",
+ "expo-application": "~56.0.3",
+ "expo-asset": "~56.0.17",
+ "expo-audio": "~56.0.12",
+ "expo-background-task": "~56.0.18",
+ "expo-blur": "~56.0.3",
+ "expo-brightness": "~56.0.5",
+ "expo-build-properties": "~56.0.18",
+ "expo-camera": "~56.0.8",
+ "expo-constants": "~56.0.18",
+ "expo-crypto": "~56.0.4",
+ "expo-dev-client": "~56.0.20",
+ "expo-device": "~56.0.4",
+ "expo-font": "~56.0.6",
+ "expo-haptics": "~56.0.3",
+ "expo-image": "~56.0.11",
+ "expo-linear-gradient": "~56.0.4",
+ "expo-linking": "~56.0.14",
+ "expo-localization": "~56.0.6",
+ "expo-location": "~56.0.17",
+ "expo-notifications": "~56.0.17",
+ "expo-router": "~56.2.10",
+ "expo-screen-orientation": "~56.0.5",
+ "expo-secure-store": "~56.0.4",
+ "expo-sharing": "~56.0.17",
+ "expo-splash-screen": "~56.0.10",
+ "expo-status-bar": "~56.0.4",
+ "expo-system-ui": "~56.0.5",
+ "expo-task-manager": "~56.0.18",
+ "expo-web-browser": "~56.0.5",
+ "i18next": "^26.3.0",
+ "jotai": "2.20.0",
+ "lodash": "4.18.1",
"nativewind": "^2.0.11",
"patch-package": "^8.0.0",
- "react": "19.1.0",
- "react-dom": "19.1.0",
- "react-i18next": "16.0.0",
- "react-native": "npm:react-native-tvos@0.81.5-1",
+ "react": "19.2.3",
+ "react-dom": "19.2.3",
+ "react-i18next": "17.0.8",
+ "react-native": "npm:react-native-tvos@0.85.3-0",
"react-native-awesome-slider": "^2.9.0",
- "react-native-bottom-tabs": "^1.0.2",
+ "react-native-bottom-tabs": "1.2.0",
"react-native-circular-progress": "^1.4.1",
"react-native-collapsible": "^1.6.2",
"react-native-country-flag": "^2.0.2",
"react-native-device-info": "^15.0.0",
+ "react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
- "react-native-gesture-handler": "~2.28.0",
+ "react-native-gesture-handler": "~2.31.1",
+ "react-native-glass-effect-view": "^1.0.0",
"react-native-google-cast": "^4.9.1",
"react-native-image-colors": "^2.4.0",
"react-native-ios-context-menu": "^3.2.1",
"react-native-ios-utilities": "5.2.0",
- "react-native-mmkv": "4.0.0",
- "react-native-nitro-modules": "^0.31.5",
- "react-native-pager-view": "^6.9.1",
- "react-native-reanimated": "~4.1.1",
- "react-native-reanimated-carousel": "4.0.2",
- "react-native-safe-area-context": "~5.6.0",
- "react-native-screens": "~4.18.0",
- "react-native-svg": "15.12.1",
+ "react-native-mmkv": "4.1.1",
+ "react-native-nitro-modules": "0.33.1",
+ "react-native-pager-view": "8.0.1",
+ "react-native-qrcode-svg": "^6.3.21",
+ "react-native-reanimated": "4.3.1",
+ "react-native-reanimated-carousel": "4.0.3",
+ "react-native-safe-area-context": "~5.7.0",
+ "react-native-screens": "4.25.2",
+ "react-native-svg": "15.15.4",
+ "react-native-tab-view": "4.3.0",
+ "react-native-text-ticker": "^1.15.0",
+ "react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
"react-native-udp": "^4.1.7",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.3",
- "react-native-video": "6.16.1",
"react-native-volume-manager": "^2.0.8",
"react-native-web": "^0.21.0",
- "react-native-worklets": "0.5.1",
- "sonner-native": "^0.21.0",
+ "react-native-worklets": "0.8.3",
+ "sonner-native": "0.21.2",
"tailwindcss": "3.3.2",
"use-debounce": "^10.0.4",
- "zod": "^4.1.3",
+ "zod": "4.4.3",
},
"devDependencies": {
- "@babel/core": "7.28.5",
- "@biomejs/biome": "2.3.5",
- "@react-native-community/cli": "20.0.2",
- "@react-native-tvos/config-tv": "0.1.4",
+ "@babel/core": "7.29.7",
+ "@biomejs/biome": "2.4.16",
+ "@react-native-community/cli": "20.1.3",
+ "@react-native-tvos/config-tv": "0.1.6",
"@types/jest": "29.5.14",
- "@types/lodash": "4.17.20",
- "@types/react": "~19.1.10",
+ "@types/lodash": "4.17.24",
+ "@types/node": "^18.19.130",
+ "@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0",
- "expo-doctor": "1.17.11",
+ "expo-doctor": "1.20.0",
"husky": "9.1.7",
- "lint-staged": "16.2.6",
- "react-test-renderer": "19.1.1",
- "typescript": "5.9.3",
+ "lint-staged": "17.0.8",
+ "react-test-renderer": "19.2.3",
+ "tsx": "^4.22.4",
+ "typescript": "6.0.3",
},
},
},
- "overrides": {
- "expo-constants": "~18.0.10",
- "expo-task-manager": "~14.0.8",
- },
"packages": {
- "@0no-co/graphql.web": ["@0no-co/graphql.web@1.2.0", "", { "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw=="],
+ "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
- "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
+ "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
- "@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
+ "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
- "@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
+ "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
- "@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
+ "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
- "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="],
+ "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="],
- "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
+ "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
- "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.28.5", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.28.5", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ=="],
+ "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="],
- "@babel/helper-create-regexp-features-plugin": ["@babel/helper-create-regexp-features-plugin@7.28.5", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "regexpu-core": "^6.3.1", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw=="],
+ "@babel/helper-create-regexp-features-plugin": ["@babel/helper-create-regexp-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "regexpu-core": "^6.3.1", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg=="],
- "@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.5", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-plugin-utils": "^7.27.1", "debug": "^4.4.1", "lodash.debounce": "^4.0.8", "resolve": "^1.22.10" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg=="],
+ "@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.8", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "debug": "^4.4.3", "lodash.debounce": "^4.0.8", "resolve": "^1.22.11" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA=="],
- "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
+ "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
- "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="],
+ "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
- "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
+ "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
- "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="],
+ "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="],
- "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="],
+ "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
- "@babel/helper-remap-async-to-generator": ["@babel/helper-remap-async-to-generator@7.27.1", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-wrap-function": "^7.27.1", "@babel/traverse": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA=="],
+ "@babel/helper-remap-async-to-generator": ["@babel/helper-remap-async-to-generator@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-wrap-function": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og=="],
- "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.27.1", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.27.1", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA=="],
+ "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="],
- "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg=="],
+ "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="],
- "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
+ "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
- "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
+ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
- "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
+ "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
- "@babel/helper-wrap-function": ["@babel/helper-wrap-function@7.28.3", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.3", "@babel/types": "^7.28.2" } }, "sha512-zdf983tNfLZFletc0RRXYrHrucBEg95NIFMkn6K9dbeMYnsgHaSBGcQqdsCSStG2PYwRre0Qc2NNSCXbG+xc6g=="],
+ "@babel/helper-wrap-function": ["@babel/helper-wrap-function@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw=="],
- "@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="],
+ "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
- "@babel/highlight": ["@babel/highlight@7.25.9", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "chalk": "^2.4.2", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw=="],
+ "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
- "@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
+ "@babel/plugin-proposal-decorators": ["@babel/plugin-proposal-decorators@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-decorators": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg=="],
- "@babel/plugin-proposal-decorators": ["@babel/plugin-proposal-decorators@7.28.0", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-syntax-decorators": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zOiZqvANjWDUaUS9xMxbMcK/Zccztbe/6ikvUXaG9nsPH3w6qh5UaPGAnirI/WhIbZ8m3OHU0ReyPrknG+ZKeg=="],
+ "@babel/plugin-proposal-export-default-from": ["@babel/plugin-proposal-export-default-from@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ=="],
- "@babel/plugin-proposal-export-default-from": ["@babel/plugin-proposal-export-default-from@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw=="],
-
- "@babel/plugin-syntax-async-generators": ["@babel/plugin-syntax-async-generators@7.8.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw=="],
-
- "@babel/plugin-syntax-bigint": ["@babel/plugin-syntax-bigint@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg=="],
-
- "@babel/plugin-syntax-class-properties": ["@babel/plugin-syntax-class-properties@7.12.13", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.12.13" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA=="],
-
- "@babel/plugin-syntax-class-static-block": ["@babel/plugin-syntax-class-static-block@7.14.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw=="],
-
- "@babel/plugin-syntax-decorators": ["@babel/plugin-syntax-decorators@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-YMq8Z87Lhl8EGkmb0MwYkt36QnxC+fzCgrl66ereamPlYToRpIk5nUjKUY3QKLWq8mwUB1BgbeXcTJhZOCDg5A=="],
+ "@babel/plugin-syntax-decorators": ["@babel/plugin-syntax-decorators@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg=="],
"@babel/plugin-syntax-dynamic-import": ["@babel/plugin-syntax-dynamic-import@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ=="],
- "@babel/plugin-syntax-export-default-from": ["@babel/plugin-syntax-export-default-from@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-eBC/3KSekshx19+N40MzjWqJd7KTEdOoLesAfa4IDFI8eRz5a47i5Oszus6zG/cwIXN63YhgLOMSSNJx49sENg=="],
+ "@babel/plugin-syntax-export-default-from": ["@babel/plugin-syntax-export-default-from@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-foag0BB37ROhdeIX9O8G0jX7hw0UekJc04cHMrYLOnrErsnBKqJGHJ8eDRpoCFZBvEPPygmmtw4qyU97qa4oOw=="],
- "@babel/plugin-syntax-flow": ["@babel/plugin-syntax-flow@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA=="],
+ "@babel/plugin-syntax-flow": ["@babel/plugin-syntax-flow@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ=="],
- "@babel/plugin-syntax-import-attributes": ["@babel/plugin-syntax-import-attributes@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww=="],
-
- "@babel/plugin-syntax-import-meta": ["@babel/plugin-syntax-import-meta@7.10.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g=="],
-
- "@babel/plugin-syntax-json-strings": ["@babel/plugin-syntax-json-strings@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA=="],
-
- "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w=="],
-
- "@babel/plugin-syntax-logical-assignment-operators": ["@babel/plugin-syntax-logical-assignment-operators@7.10.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig=="],
+ "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="],
"@babel/plugin-syntax-nullish-coalescing-operator": ["@babel/plugin-syntax-nullish-coalescing-operator@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ=="],
- "@babel/plugin-syntax-numeric-separator": ["@babel/plugin-syntax-numeric-separator@7.10.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug=="],
-
- "@babel/plugin-syntax-object-rest-spread": ["@babel/plugin-syntax-object-rest-spread@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA=="],
-
- "@babel/plugin-syntax-optional-catch-binding": ["@babel/plugin-syntax-optional-catch-binding@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q=="],
-
"@babel/plugin-syntax-optional-chaining": ["@babel/plugin-syntax-optional-chaining@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg=="],
- "@babel/plugin-syntax-private-property-in-object": ["@babel/plugin-syntax-private-property-in-object@7.14.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg=="],
+ "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA=="],
- "@babel/plugin-syntax-top-level-await": ["@babel/plugin-syntax-top-level-await@7.14.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw=="],
+ "@babel/plugin-transform-arrow-functions": ["@babel/plugin-transform-arrow-functions@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ=="],
- "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ=="],
+ "@babel/plugin-transform-async-generator-functions": ["@babel/plugin-transform-async-generator-functions@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-remap-async-to-generator": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA=="],
- "@babel/plugin-transform-arrow-functions": ["@babel/plugin-transform-arrow-functions@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA=="],
+ "@babel/plugin-transform-async-to-generator": ["@babel/plugin-transform-async-to-generator@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-remap-async-to-generator": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w=="],
- "@babel/plugin-transform-async-generator-functions": ["@babel/plugin-transform-async-generator-functions@7.28.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-remap-async-to-generator": "^7.27.1", "@babel/traverse": "^7.28.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q=="],
+ "@babel/plugin-transform-block-scoping": ["@babel/plugin-transform-block-scoping@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ=="],
- "@babel/plugin-transform-async-to-generator": ["@babel/plugin-transform-async-to-generator@7.27.1", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-remap-async-to-generator": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA=="],
+ "@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA=="],
- "@babel/plugin-transform-block-scoping": ["@babel/plugin-transform-block-scoping@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-45DmULpySVvmq9Pj3X9B+62Xe+DJGov27QravQJU1LLcapR6/10i+gYVAucGGJpHBp5mYxIMK4nDAT/QDLr47g=="],
+ "@babel/plugin-transform-class-static-block": ["@babel/plugin-transform-class-static-block@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.12.0" } }, "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A=="],
- "@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.27.1", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA=="],
+ "@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g=="],
- "@babel/plugin-transform-class-static-block": ["@babel/plugin-transform-class-static-block@7.28.3", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.3", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.12.0" } }, "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg=="],
+ "@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg=="],
- "@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.28.4", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-globals": "^7.28.0", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/traverse": "^7.28.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA=="],
+ "@babel/plugin-transform-export-namespace-from": ["@babel/plugin-transform-export-namespace-from@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA=="],
- "@babel/plugin-transform-computed-properties": ["@babel/plugin-transform-computed-properties@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/template": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw=="],
+ "@babel/plugin-transform-flow-strip-types": ["@babel/plugin-transform-flow-strip-types@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-flow": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ=="],
- "@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/traverse": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw=="],
+ "@babel/plugin-transform-for-of": ["@babel/plugin-transform-for-of@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ=="],
- "@babel/plugin-transform-export-namespace-from": ["@babel/plugin-transform-export-namespace-from@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ=="],
+ "@babel/plugin-transform-logical-assignment-operators": ["@babel/plugin-transform-logical-assignment-operators@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q=="],
- "@babel/plugin-transform-flow-strip-types": ["@babel/plugin-transform-flow-strip-types@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-syntax-flow": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg=="],
+ "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="],
- "@babel/plugin-transform-for-of": ["@babel/plugin-transform-for-of@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw=="],
+ "@babel/plugin-transform-named-capturing-groups-regex": ["@babel/plugin-transform-named-capturing-groups-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ=="],
- "@babel/plugin-transform-function-name": ["@babel/plugin-transform-function-name@7.27.1", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "@babel/traverse": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ=="],
+ "@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg=="],
- "@babel/plugin-transform-literals": ["@babel/plugin-transform-literals@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA=="],
+ "@babel/plugin-transform-object-rest-spread": ["@babel/plugin-transform-object-rest-spread@7.29.7", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7", "@babel/plugin-transform-parameters": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A=="],
- "@babel/plugin-transform-logical-assignment-operators": ["@babel/plugin-transform-logical-assignment-operators@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-axUuqnUTBuXyHGcJEVVh9pORaN6wC5bYfE7FGzPiaWa3syib9m7g+/IT/4VgCOe2Upef43PHzeAvcrVek6QuuA=="],
+ "@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng=="],
- "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.27.1", "", { "dependencies": { "@babel/helper-module-transforms": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw=="],
+ "@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ=="],
- "@babel/plugin-transform-named-capturing-groups-regex": ["@babel/plugin-transform-named-capturing-groups-regex@7.27.1", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng=="],
+ "@babel/plugin-transform-parameters": ["@babel/plugin-transform-parameters@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g=="],
- "@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA=="],
+ "@babel/plugin-transform-private-methods": ["@babel/plugin-transform-private-methods@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug=="],
- "@babel/plugin-transform-numeric-separator": ["@babel/plugin-transform-numeric-separator@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw=="],
+ "@babel/plugin-transform-private-property-in-object": ["@babel/plugin-transform-private-property-in-object@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA=="],
- "@babel/plugin-transform-object-rest-spread": ["@babel/plugin-transform-object-rest-spread@7.28.4", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-transform-destructuring": "^7.28.0", "@babel/plugin-transform-parameters": "^7.27.7", "@babel/traverse": "^7.28.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-373KA2HQzKhQCYiRVIRr+3MjpCObqzDlyrM6u4I201wL8Mp2wHf7uB8GhDwis03k2ti8Zr65Zyyqs1xOxUF/Ew=="],
+ "@babel/plugin-transform-react-display-name": ["@babel/plugin-transform-react-display-name@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q=="],
- "@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q=="],
+ "@babel/plugin-transform-react-jsx": ["@babel/plugin-transform-react-jsx@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/types": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A=="],
- "@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-N6fut9IZlPnjPwgiQkXNhb+cT8wQKFlJNqcZkWlcTqkcqx6/kU4ynGmLFoa4LViBSirn05YAwk+sQBbPfxtYzQ=="],
+ "@babel/plugin-transform-react-jsx-development": ["@babel/plugin-transform-react-jsx-development@7.29.7", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g=="],
- "@babel/plugin-transform-parameters": ["@babel/plugin-transform-parameters@7.27.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg=="],
+ "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
- "@babel/plugin-transform-private-methods": ["@babel/plugin-transform-private-methods@7.27.1", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA=="],
+ "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
- "@babel/plugin-transform-private-property-in-object": ["@babel/plugin-transform-private-property-in-object@7.27.1", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ=="],
+ "@babel/plugin-transform-react-pure-annotations": ["@babel/plugin-transform-react-pure-annotations@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA=="],
- "@babel/plugin-transform-react-display-name": ["@babel/plugin-transform-react-display-name@7.28.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA=="],
+ "@babel/plugin-transform-regenerator": ["@babel/plugin-transform-regenerator@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw=="],
- "@babel/plugin-transform-react-jsx": ["@babel/plugin-transform-react-jsx@7.27.1", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-module-imports": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/types": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw=="],
+ "@babel/plugin-transform-runtime": ["@babel/plugin-transform-runtime@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "babel-plugin-polyfill-corejs2": "^0.4.14", "babel-plugin-polyfill-corejs3": "^0.13.0", "babel-plugin-polyfill-regenerator": "^0.6.5", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q=="],
- "@babel/plugin-transform-react-jsx-development": ["@babel/plugin-transform-react-jsx-development@7.27.1", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q=="],
+ "@babel/plugin-transform-shorthand-properties": ["@babel/plugin-transform-shorthand-properties@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg=="],
- "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
+ "@babel/plugin-transform-template-literals": ["@babel/plugin-transform-template-literals@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA=="],
- "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
+ "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw=="],
- "@babel/plugin-transform-react-pure-annotations": ["@babel/plugin-transform-react-pure-annotations@7.27.1", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA=="],
+ "@babel/plugin-transform-unicode-regex": ["@babel/plugin-transform-unicode-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA=="],
- "@babel/plugin-transform-regenerator": ["@babel/plugin-transform-regenerator@7.28.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+ZEdQlBoRg9m2NnzvEeLgtvBMO4tkFBw5SQIUgLICgTrumLoU7lr+Oghi6km2PFj+dbUt2u1oby2w3BDO9YQnA=="],
+ "@babel/preset-typescript": ["@babel/preset-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/plugin-transform-modules-commonjs": "^7.29.7", "@babel/plugin-transform-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ=="],
- "@babel/plugin-transform-runtime": ["@babel/plugin-transform-runtime@7.28.5", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "babel-plugin-polyfill-corejs2": "^0.4.14", "babel-plugin-polyfill-corejs3": "^0.13.0", "babel-plugin-polyfill-regenerator": "^0.6.5", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-20NUVgOrinudkIBzQ2bNxP08YpKprUkRTiRSd2/Z5GOdPImJGkoN4Z7IQe1T5AdyKI1i5L6RBmluqdSzvaq9/w=="],
+ "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
- "@babel/plugin-transform-shorthand-properties": ["@babel/plugin-transform-shorthand-properties@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ=="],
+ "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
- "@babel/plugin-transform-spread": ["@babel/plugin-transform-spread@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q=="],
+ "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
- "@babel/plugin-transform-sticky-regex": ["@babel/plugin-transform-sticky-regex@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g=="],
+ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
- "@babel/plugin-transform-template-literals": ["@babel/plugin-transform-template-literals@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg=="],
+ "@biomejs/biome": ["@biomejs/biome@2.4.16", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="],
- "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.5", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.5", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-x2Qa+v/CuEoX7Dr31iAfr0IhInrVOWZU/2vJMJ00FOR/2nM0BcBEclpaf9sWCDc+v5e9dMrhSH8/atq/kX7+bA=="],
+ "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="],
- "@babel/plugin-transform-unicode-regex": ["@babel/plugin-transform-unicode-regex@7.27.1", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw=="],
+ "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="],
- "@babel/preset-react": ["@babel/preset-react@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-transform-react-display-name": "^7.28.0", "@babel/plugin-transform-react-jsx": "^7.27.1", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@babel/plugin-transform-react-pure-annotations": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ=="],
+ "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="],
- "@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="],
+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="],
- "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
+ "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="],
- "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
+ "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg=="],
- "@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
+ "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="],
- "@babel/traverse--for-generate-function-map": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
+ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="],
- "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
-
- "@biomejs/biome": ["@biomejs/biome@2.3.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.5", "@biomejs/cli-darwin-x64": "2.3.5", "@biomejs/cli-linux-arm64": "2.3.5", "@biomejs/cli-linux-arm64-musl": "2.3.5", "@biomejs/cli-linux-x64": "2.3.5", "@biomejs/cli-linux-x64-musl": "2.3.5", "@biomejs/cli-win32-arm64": "2.3.5", "@biomejs/cli-win32-x64": "2.3.5" }, "bin": { "biome": "bin/biome" } }, "sha512-HvLhNlIlBIbAV77VysRIBEwp55oM/QAjQEin74QQX9Xb259/XP/D5AGGnZMOyF1el4zcvlNYYR3AyTMUV3ILhg=="],
-
- "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fLdTur8cJU33HxHUUsii3GLx/TR0BsfQx8FkeqIiW33cGMtUD56fAtrh+2Fx1uhiCsVZlFh6iLKUU3pniZREQw=="],
-
- "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-qpT8XDqeUlzrOW8zb4k3tjhT7rmvVRumhi2657I2aGcY4B+Ft5fNwDdZGACzn8zj7/K1fdWjgwYE3i2mSZ+vOA=="],
-
- "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-u/pybjTBPGBHB66ku4pK1gj+Dxgx7/+Z0jAriZISPX1ocTO8aHh8x8e7Kb1rB4Ms0nA/SzjtNOVJ4exVavQBCw=="],
-
- "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-eGUG7+hcLgGnMNl1KHVZUYxahYAhC462jF/wQolqu4qso2MSk32Q+QrpN7eN4jAHAg7FUMIo897muIhK4hXhqg=="],
-
- "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-XrIVi9YAW6ye0CGQ+yax0gLfx+BFOtKaNX74n+xHWla6Cl6huUmcKNO7HPx7BiKnJUzrxXY1qYlm7xMvi08X4g=="],
-
- "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-awVuycTPpVTH/+WDVnEEYSf6nbCBHf/4wB3lquwT7puhNg8R4XvonWNZzUsfHZrCkjkLhFH/vCZK5jHatD9FEg=="],
-
- "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-DlBiMlBZZ9eIq4H7RimDSGsYcOtfOIfZOaI5CqsWiSlbTfqbPVfWtCf92wNzx8GNMbu1s7/g3ZZESr6+GwM/SA=="],
-
- "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-nUmR8gb6yvrKhtRgzwo/gDimPwnO5a4sCydf8ZS2kHIJhEmSmk+STsusr1LHTuM//wXppBawvSQi2xFXJCdgKQ=="],
-
- "@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@1.0.2", "", { "dependencies": { "color": "^5.0.0" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-OrCw8s2NzFxO1TO5W2vyr7HNvh1Yjy00f72D/0BIPtImc0aj5CRrT9nFRE7YP0FWZb0AY5+0QU9jaoph1rBlSg=="],
+ "@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@1.2.0", "", { "dependencies": { "color": "^5.0.0" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-gEnLP7q9Iai0KlVxHDIdlrDgkvJ5vwPzL2+2ucz5BdPWd++Cf5GO1jPq92R4/85PrioviCZnlAD91Wx8WxPOjA=="],
"@dominicstop/ts-event-emitter": ["@dominicstop/ts-event-emitter@1.1.0", "", {}, "sha512-CcxmJIvUb1vsFheuGGVSQf4KdPZC44XolpUT34+vlal+LyQoBUOn31pjFET5M9ctOxEpt8xa0M3/2M7uUiAoJw=="],
+ "@douglowder/expo-av-route-picker-view": ["@douglowder/expo-av-route-picker-view@0.0.5", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-oT4wf8aYYNfLEuZEkwZIH7CtEHKnEHWnjs6/hNwbFGEC0FnfjjWBNrQEt4fo5/gkafqa2G5ILkxndMyBZvk5dg=="],
+
"@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="],
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
- "@expo/cli": ["@expo/cli@54.0.16", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@expo/code-signing-certificates": "^0.0.5", "@expo/config": "~12.0.10", "@expo/config-plugins": "~54.0.2", "@expo/devcert": "^1.1.2", "@expo/env": "~2.0.7", "@expo/image-utils": "^0.8.7", "@expo/json-file": "^10.0.7", "@expo/mcp-tunnel": "~0.1.0", "@expo/metro": "~54.1.0", "@expo/metro-config": "~54.0.9", "@expo/osascript": "^2.3.7", "@expo/package-manager": "^1.9.8", "@expo/plist": "^0.4.7", "@expo/prebuild-config": "^54.0.6", "@expo/schema-utils": "^0.1.7", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.3.0", "@react-native/dev-middleware": "0.81.5", "@urql/core": "^5.0.6", "@urql/exchange-retry": "^1.3.0", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", "expo-server": "^1.0.4", "freeport-async": "^2.0.0", "getenv": "^2.0.0", "glob": "^10.4.2", "lan-network": "^0.1.6", "minimatch": "^9.0.0", "node-forge": "^1.3.1", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^3.0.1", "pretty-bytes": "^5.6.0", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "qrcode-terminal": "0.11.0", "require-from-string": "^2.0.2", "requireg": "^0.2.2", "resolve": "^1.22.2", "resolve-from": "^5.0.0", "resolve.exports": "^2.0.3", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "tar": "^7.4.3", "terminal-link": "^2.1.1", "undici": "^6.18.2", "wrap-ansi": "^7.0.0", "ws": "^8.12.1" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "build/bin/cli" } }, "sha512-hY/OdRaJMs5WsVPuVSZ+RLH3VObJmL/pv5CGCHEZHN2PxZjSZSdctyKV8UcFBXTF0yIKNAJ9XLs1dlNYXHh4Cw=="],
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
- "@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.5", "", { "dependencies": { "node-forge": "^1.2.1", "nullthrows": "^1.1.1" } }, "sha512-BNhXkY1bblxKZpltzAx98G2Egj9g1Q+JRcvR7E99DOj862FTCX+ZPsAUtPTr7aHxwtrL7+fL3r0JSmM9kBm+Bw=="],
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
- "@expo/config": ["@expo/config@12.0.10", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "@expo/config-plugins": "~54.0.2", "@expo/config-types": "^54.0.8", "@expo/json-file": "^10.0.7", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^10.4.2", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4", "sucrase": "3.35.0" } }, "sha512-lJMof5Nqakq1DxGYlghYB/ogSBjmv4Fxn1ovyDmcjlRsQdFCXgu06gEUogkhPtc9wBt9WlTTfqENln5HHyLW6w=="],
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
- "@expo/config-plugins": ["@expo/config-plugins@54.0.2", "", { "dependencies": { "@expo/config-types": "^54.0.8", "@expo/json-file": "~10.0.7", "@expo/plist": "^0.4.7", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^10.4.2", "resolve-from": "^5.0.0", "semver": "^7.5.4", "slash": "^3.0.0", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-jD4qxFcURQUVsUFGMcbo63a/AnviK8WUGard+yrdQE3ZrB/aurn68SlApjirQQLEizhjI5Ar2ufqflOBlNpyPg=="],
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
- "@expo/config-types": ["@expo/config-types@54.0.8", "", {}, "sha512-lyIn/x/Yz0SgHL7IGWtgTLg6TJWC9vL7489++0hzCHZ4iGjVcfZmPTUfiragZ3HycFFj899qN0jlhl49IHa94A=="],
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
- "@expo/devcert": ["@expo/devcert@1.2.0", "", { "dependencies": { "@expo/sudo-prompt": "^9.3.1", "debug": "^3.1.0", "glob": "^10.4.2" } }, "sha512-Uilcv3xGELD5t/b0eM4cxBFEKQRIivB3v7i+VhWLV/gL98aw810unLKKJbGAxAIhY6Ipyz8ChWibFsKFXYwstA=="],
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
- "@expo/devtools": ["@expo/devtools@0.1.7", "", { "dependencies": { "chalk": "^4.1.2" }, "peerDependencies": { "react": "*", "react-native": "*" }, "optionalPeers": ["react", "react-native"] }, "sha512-dfIa9qMyXN+0RfU6SN4rKeXZyzKWsnz6xBSDccjL4IRiE+fQ0t84zg0yxgN4t/WK2JU5v6v4fby7W7Crv9gJvA=="],
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
- "@expo/env": ["@expo/env@2.0.7", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0" } }, "sha512-BNETbLEohk3HQ2LxwwezpG8pq+h7Fs7/vAMP3eAtFT1BCpprLYoBBFZH7gW4aqGfqOcVP4Lc91j014verrYNGg=="],
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
- "@expo/fingerprint": ["@expo/fingerprint@0.15.3", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "getenv": "^2.0.0", "glob": "^10.4.2", "ignore": "^5.3.1", "minimatch": "^9.0.0", "p-limit": "^3.1.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-8YPJpEYlmV171fi+t+cSLMX1nC5ngY9j2FiN70dHldLpd6Ct6ouGhk96svJ4BQZwsqwII2pokwzrDAwqo4Z0FQ=="],
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
- "@expo/image-utils": ["@expo/image-utils@0.8.7", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "resolve-from": "^5.0.0", "resolve-global": "^1.0.0", "semver": "^7.6.0", "temp-dir": "~2.0.0", "unique-string": "~2.0.0" } }, "sha512-SXOww4Wq3RVXLyOaXiCCuQFguCDh8mmaHBv54h/R29wGl4jRY8GEyQEx8SypV/iHt1FbzsU/X3Qbcd9afm2W2w=="],
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
- "@expo/json-file": ["@expo/json-file@10.0.7", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "json5": "^2.2.3" } }, "sha512-z2OTC0XNO6riZu98EjdNHC05l51ySeTto6GP7oSQrCvQgG9ARBwD1YvMQaVZ9wU7p/4LzSf1O7tckL3B45fPpw=="],
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
- "@expo/mcp-tunnel": ["@expo/mcp-tunnel@0.1.0", "", { "dependencies": { "ws": "^8.18.3", "zod": "^3.25.76", "zod-to-json-schema": "^3.24.6" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.13.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-rJ6hl0GnIZj9+ssaJvFsC7fwyrmndcGz+RGFzu+0gnlm78X01957yjtHgjcmnQAgL5hWEOR6pkT0ijY5nU5AWw=="],
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
- "@expo/metro": ["@expo/metro@54.1.0", "", { "dependencies": { "metro": "0.83.2", "metro-babel-transformer": "0.83.2", "metro-cache": "0.83.2", "metro-cache-key": "0.83.2", "metro-config": "0.83.2", "metro-core": "0.83.2", "metro-file-map": "0.83.2", "metro-resolver": "0.83.2", "metro-runtime": "0.83.2", "metro-source-map": "0.83.2", "metro-transform-plugins": "0.83.2", "metro-transform-worker": "0.83.2" } }, "sha512-MgdeRNT/LH0v1wcO0TZp9Qn8zEF0X2ACI0wliPtv5kXVbXWI+yK9GyrstwLAiTXlULKVIg3HVSCCvmLu0M3tnw=="],
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
- "@expo/metro-config": ["@expo/metro-config@54.0.9", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@expo/config": "~12.0.10", "@expo/env": "~2.0.7", "@expo/json-file": "~10.0.7", "@expo/metro": "~54.1.0", "@expo/spawn-async": "^1.7.2", "browserslist": "^4.25.0", "chalk": "^4.1.0", "debug": "^4.3.2", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0", "glob": "^10.4.2", "hermes-parser": "^0.29.1", "jsc-safe-url": "^0.2.4", "lightningcss": "^1.30.1", "minimatch": "^9.0.0", "postcss": "~8.4.32", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*" }, "optionalPeers": ["expo"] }, "sha512-CRI4WgFXrQ2Owyr8q0liEBJveUIF9DcYAKadMRsJV7NxGNBdrIIKzKvqreDfsGiRqivbLsw6UoNb3UE7/SvPfg=="],
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
- "@expo/metro-runtime": ["@expo/metro-runtime@6.1.2", "", { "dependencies": { "anser": "^1.4.9", "pretty-format": "^29.7.0", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom"] }, "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g=="],
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
- "@expo/osascript": ["@expo/osascript@2.3.7", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "exec-async": "^2.2.0" } }, "sha512-IClSOXxR0YUFxIriUJVqyYki7lLMIHrrzOaP01yxAL1G8pj2DWV5eW1y5jSzIcIfSCNhtGsshGd1tU/AYup5iQ=="],
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
- "@expo/package-manager": ["@expo/package-manager@1.9.8", "", { "dependencies": { "@expo/json-file": "^10.0.7", "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "resolve-workspace-root": "^2.0.0" } }, "sha512-4/I6OWquKXYnzo38pkISHCOCOXxfeEmu4uDoERq1Ei/9Ur/s9y3kLbAamEkitUkDC7gHk1INxRWEfFNzGbmOrA=="],
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
- "@expo/plist": ["@expo/plist@0.4.7", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.2.3", "xmlbuilder": "^15.1.1" } }, "sha512-dGxqHPvCZKeRKDU1sJZMmuyVtcASuSYh1LPFVaM1DuffqPL36n6FMEL0iUqq2Tx3xhWk8wCnWl34IKplUjJDdA=="],
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
- "@expo/prebuild-config": ["@expo/prebuild-config@54.0.6", "", { "dependencies": { "@expo/config": "~12.0.10", "@expo/config-plugins": "~54.0.2", "@expo/config-types": "^54.0.8", "@expo/image-utils": "^0.8.7", "@expo/json-file": "^10.0.7", "@react-native/normalize-colors": "0.81.5", "debug": "^4.3.1", "resolve-from": "^5.0.0", "semver": "^7.6.0", "xml2js": "0.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-xowuMmyPNy+WTNq+YX0m0EFO/Knc68swjThk4dKivgZa8zI1UjvFXOBIOp8RX4ljCXLzwxQJM5oBBTvyn+59ZA=="],
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
+
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
+
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
+
+ "@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="],
+
+ "@expo/cli": ["@expo/cli@56.1.16", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.9", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.1", "@expo/inline-modules": "^0.0.12", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.13", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.14", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.1", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.16", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.14", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^56.0.5", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw=="],
+
+ "@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.6", "", { "dependencies": { "node-forge": "^1.3.3" } }, "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w=="],
+
+ "@expo/config": ["@expo/config@56.0.9", "", { "dependencies": { "@expo/config-plugins": "~56.0.8", "@expo/config-types": "^56.0.5", "@expo/json-file": "^10.2.0", "@expo/require-utils": "^56.1.3", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4" } }, "sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw=="],
+
+ "@expo/config-plugins": ["@expo/config-plugins@56.0.9", "", { "dependencies": { "@expo/config-types": "^56.0.6", "@expo/json-file": "~10.2.0", "@expo/plist": "^0.7.0", "@expo/require-utils": "^56.1.3", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "semver": "^7.5.4", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA=="],
+
+ "@expo/config-types": ["@expo/config-types@56.0.6", "", {}, "sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ=="],
+
+ "@expo/devcert": ["@expo/devcert@1.2.1", "", { "dependencies": { "@expo/sudo-prompt": "^9.3.1", "debug": "^3.1.0" } }, "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA=="],
+
+ "@expo/devtools": ["@expo/devtools@56.0.2", "", { "dependencies": { "chalk": "^4.1.2" }, "peerDependencies": { "react": "*", "react-native": "*" }, "optionalPeers": ["react", "react-native"] }, "sha512-ANl4kPdbe0/HQYWkDEN79S6bQhI+i/ZCnPxuC853pPsB4svhINC7Ku9lmGOKPsUUWWnrHg1spkDGQBZ4sD6JxQ=="],
+
+ "@expo/dom-webview": ["@expo/dom-webview@56.0.5", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-UIEJxkLg6cHqofKrpWpkn9E6ApxVRtCgZhZkARPr9VV7rBVloJgeroTHs31YgU/JpbI5lLQOnfOlGo54W6C2Ew=="],
+
+ "@expo/env": ["@expo/env@2.3.0", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "getenv": "^2.0.0" } }, "sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg=="],
+
+ "@expo/expo-modules-macros-plugin": ["@expo/expo-modules-macros-plugin@0.2.2", "", {}, "sha512-4IMzPDIo/VOXREQjsJtliSfqYVZvfzU2SLFS/9sKMWF848S8CHx+e/E+Vf0TcMvpWCCKX5umyqxb13KJJ+YUzg=="],
+
+ "@expo/fingerprint": ["@expo/fingerprint@0.19.4", "", { "dependencies": { "@expo/env": "^2.3.0", "@expo/spawn-async": "^1.8.0", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", "minimatch": "^10.2.2", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-PsowRlO8+S7JlO8go7yhNEXp7sqlsWDE2AlCwoss7zH0dcajXFo74Fy0KdXEc4UXK7kKoHD37oDgsZ8aHSLr7A=="],
+
+ "@expo/image-utils": ["@expo/image-utils@0.10.1", "", { "dependencies": { "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "semver": "^7.6.0" } }, "sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg=="],
+
+ "@expo/inline-modules": ["@expo/inline-modules@0.0.12", "", { "dependencies": { "@expo/config-plugins": "~56.0.9" } }, "sha512-SNIZr/HWfIQPTZBwmukItxpc7ws1SgMUywYq1dnQvDknQDjJcuWAasIRFUjsK15yQ1xb4G5CP7VHtbN3V4lENg=="],
+
+ "@expo/json-file": ["@expo/json-file@10.2.0", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "json5": "^2.2.3" } }, "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ=="],
+
+ "@expo/local-build-cache-provider": ["@expo/local-build-cache-provider@56.0.8", "", { "dependencies": { "@expo/config": "~56.0.9", "chalk": "^4.1.2" } }, "sha512-UsuXwpNi57MNhzZ3be4XThc8xW6nzk3Wu37s1+2qcfZGeJcMLKDFfwO6n8YXeIiGlCsOi0Ee1rsTdgjrKt/YJQ=="],
+
+ "@expo/log-box": ["@expo/log-box@56.0.13", "", { "dependencies": { "@expo/dom-webview": "^56.0.5", "anser": "^1.4.9", "stacktrace-parser": "^0.1.10" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-QWRZSpWPyjkDLVQio4R7oAzg/Av2MOt/DciFkfjr8qQ3qxGVn1Rt1oHP/80hvcWDcHFV7N6PqpyxRXw6nbxzKQ=="],
+
+ "@expo/metro": ["@expo/metro@56.0.0", "", { "dependencies": { "metro": "0.84.4", "metro-babel-transformer": "0.84.4", "metro-cache": "0.84.4", "metro-cache-key": "0.84.4", "metro-config": "0.84.4", "metro-core": "0.84.4", "metro-file-map": "0.84.4", "metro-minify-terser": "0.84.4", "metro-resolver": "0.84.4", "metro-runtime": "0.84.4", "metro-source-map": "0.84.4", "metro-symbolicate": "0.84.4", "metro-transform-plugins": "0.84.4", "metro-transform-worker": "0.84.4" } }, "sha512-5gIgQHtEpjjvsjKfVtIv23a98LLRV0/y07PDShEwYSytAMlE3FSF8RHXqtHc1sUJL6dn7hnuIBpIbrLXXuVi0A=="],
+
+ "@expo/metro-config": ["@expo/metro-config@56.0.14", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@expo/config": "~56.0.9", "@expo/env": "~2.3.0", "@expo/json-file": "~10.2.0", "@expo/metro": "~56.0.0", "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", "@jridgewell/gen-mapping": "^0.3.13", "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.5.5", "browserslist": "^4.25.0", "chalk": "^4.1.0", "debug": "^4.3.2", "getenv": "^2.0.0", "glob": "^13.0.0", "hermes-parser": "^0.33.3", "jsc-safe-url": "^0.2.4", "lightningcss": "^1.30.1", "picomatch": "^4.0.4", "postcss": "^8.5.14", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*" }, "optionalPeers": ["expo"] }, "sha512-O3CIHruaTJhswPAf/nf3i8QQ3f2jl+mEwSea1eb3khuplabdy/wTQz+JvHN8VGUFyg7JKwUGU1QfO6T3JiSQqA=="],
+
+ "@expo/metro-file-map": ["@expo/metro-file-map@56.0.3", "", { "dependencies": { "debug": "^4.3.4", "fb-watchman": "^2.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "walker": "^1.0.8" } }, "sha512-5OGW3z8LgEYgMJOR7F3pC8llFLkb1fVqwAewbCl6S4Vkha8AFQMwOjT+9Wbka+V4rmpljpGqOnMhF4xZbD961w=="],
+
+ "@expo/metro-runtime": ["@expo/metro-runtime@56.0.15", "", { "dependencies": { "@expo/log-box": "^56.0.13", "anser": "^1.4.9", "pretty-format": "^29.7.0", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom"] }, "sha512-WIWeVsL6kCSB57oYZdUA4MTkH7c67UFMIjdNoQzKXwxZYwBFE/xL2cGPDC3z8RWt0femzJTVxAVZUOW/hiqRzA=="],
+
+ "@expo/osascript": ["@expo/osascript@2.6.0", "", { "dependencies": { "@expo/spawn-async": "^1.8.0" } }, "sha512-QvqDBlJXa8CS2vRORJ4wEflY1m0vVI07uSJdIRgBrLxRPBcsrXxrtU7+wXRXMqfq9zLwNP9XbvRsXF2omoDylg=="],
+
+ "@expo/package-manager": ["@expo/package-manager@1.12.1", "", { "dependencies": { "@expo/json-file": "^10.2.0", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "resolve-workspace-root": "^2.0.0" } }, "sha512-fQLiFAcFRWF53mtuLK32SUJQ1ahhrTcBZPZPedYTiUT5ha5FF+UO6bPtCc0Y/hgj0/m3HCGBAuSHjbg2kI9oPQ=="],
+
+ "@expo/plist": ["@expo/plist@0.7.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q=="],
+
+ "@expo/prebuild-config": ["@expo/prebuild-config@56.0.16", "", { "dependencies": { "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.9", "@expo/config-types": "^56.0.6", "@expo/image-utils": "^0.10.1", "@expo/json-file": "^10.2.0", "@react-native/normalize-colors": "0.85.3", "debug": "^4.3.1", "expo-modules-autolinking": "~56.0.16", "resolve-from": "^5.0.0", "semver": "^7.6.0" } }, "sha512-ce9ENfPWO4WUWUVQz0OaqL3KYZ7YofP8O35ncnn7CHCaKwQ7BqxcCGJbh+qvP1UjlWeNB3CjHPrXXJ3bnZwlJw=="],
"@expo/react-native-action-sheet": ["@expo/react-native-action-sheet@4.1.1", "", { "dependencies": { "@types/hoist-non-react-statics": "^3.3.1", "hoist-non-react-statics": "^3.3.0" }, "peerDependencies": { "react": ">=18.0.0" } }, "sha512-4KRaba2vhqDRR7ObBj6nrD5uJw8ePoNHdIOMETTpgGTX7StUbrF4j/sfrP1YUyaPEa1P8FXdwG6pB+2WtrJd1A=="],
- "@expo/schema-utils": ["@expo/schema-utils@0.1.7", "", {}, "sha512-jWHoSuwRb5ZczjahrychMJ3GWZu54jK9ulNdh1d4OzAEq672K9E5yOlnlBsfIHWHGzUAT+0CL7Yt1INiXTz68g=="],
+ "@expo/require-utils": ["@expo/require-utils@56.1.3", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8" }, "peerDependencies": { "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" }, "optionalPeers": ["typescript"] }, "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ=="],
+
+ "@expo/router-server": ["@expo/router-server@56.0.14", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "@expo/metro-runtime": "^56.0.15", "expo": "*", "expo-constants": "^56.0.18", "expo-font": "^56.0.6", "expo-router": "*", "expo-server": "^56.0.5", "react": "*", "react-dom": "*", "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" }, "optionalPeers": ["@expo/metro-runtime", "expo-router", "react-dom", "react-server-dom-webpack"] }, "sha512-2UCTtZfcq1ZPgp3wk8/+sq9DvFI9UxrPr1jcEKMAF2DGAJLosnpc8GWNNg2hkjt6SHUOdFHIPxujWPYyho2y3A=="],
+
+ "@expo/schema-utils": ["@expo/schema-utils@56.0.1", "", {}, "sha512-CZ/+mYbQmWeOnkCGlWy9K+lFxbJSMFY7+TqBZcKzBSTU5Q7IGRvn/sOG3TdNjIdLPmbA8xe7R/c3UUQ28R9i9w=="],
"@expo/sdk-runtime-versions": ["@expo/sdk-runtime-versions@1.0.0", "", {}, "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ=="],
- "@expo/spawn-async": ["@expo/spawn-async@1.7.2", "", { "dependencies": { "cross-spawn": "^7.0.3" } }, "sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew=="],
+ "@expo/spawn-async": ["@expo/spawn-async@1.8.0", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw=="],
"@expo/sudo-prompt": ["@expo/sudo-prompt@9.3.2", "", {}, "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw=="],
- "@expo/ui": ["@expo/ui@0.2.0-canary-20251031-b135dff", "", { "dependencies": { "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-L/TEKnv/hpQ/Q1sO8lJw0wxdcv88UoA1JShwRSYHLN88UstjxvBNvMqlKGk7SNkTUJtlrttWAundJA4jM2mDPw=="],
+ "@expo/ui": ["@expo/ui@56.0.18", "", { "dependencies": { "sf-symbols-typescript": "^2.1.0", "vaul": "^1.1.2" }, "peerDependencies": { "@babel/core": "*", "expo": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-reanimated": "*", "react-native-worklets": "*" }, "optionalPeers": ["@babel/core", "react-dom", "react-native-reanimated", "react-native-worklets"] }, "sha512-2XgH5obigGtXm8zlb/V3g87NSiIcBcJ1xoQOEQYPoExL1DCNsHzaIecTh1XG/f/45ardo4OZNJwpbfYJ9X3qrQ=="],
- "@expo/vector-icons": ["@expo/vector-icons@15.0.3", "", { "peerDependencies": { "expo-font": ">=14.0.4", "react": "*", "react-native": "*" } }, "sha512-SBUyYKphmlfUBqxSfDdJ3jAdEVSALS2VUPOUyqn48oZmb2TL/O7t7/PQm5v4NQujYEPLPMTLn9KVw6H7twwbTA=="],
+ "@expo/vector-icons": ["@expo/vector-icons@15.1.1", "", { "peerDependencies": { "expo-font": ">=14.0.4", "react": "*", "react-native": "*" } }, "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw=="],
- "@expo/ws-tunnel": ["@expo/ws-tunnel@1.0.6", "", {}, "sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q=="],
+ "@expo/ws-tunnel": ["@expo/ws-tunnel@2.0.0", "", { "peerDependencies": { "ws": "^8.0.0" } }, "sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw=="],
- "@expo/xcpretty": ["@expo/xcpretty@4.3.2", "", { "dependencies": { "@babel/code-frame": "7.10.4", "chalk": "^4.1.0", "find-up": "^5.0.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-ReZxZ8pdnoI3tP/dNnJdnmAk7uLT4FjsKDGW7YeDdvdOMz2XCQSmSCM9IWlrXuWtMF9zeSB6WJtEhCQ41gQOfw=="],
+ "@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="],
- "@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.6", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-vmruJxdiUGDg+ZYcDmS30XDhq/h/+QkINOI5LY/uGjx8cPGwgJW0H6AB902gNTKtccbiKe/rr94EwdmIEz+LAQ=="],
+ "@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.14", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-uLQFlDjp9z+jrOFcMSEldPqL5JdaXL3vXOh+juhwoNvXgTsEorJLjHTugXu+YccAG/0KJnShzKCrb71MHBsvJg=="],
"@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],
@@ -389,32 +427,16 @@
"@hapi/topo": ["@hapi/topo@5.1.0", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg=="],
- "@ide/backoff": ["@ide/backoff@1.0.0", "", {}, "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g=="],
-
- "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
-
- "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
+ "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],
"@isaacs/ttlcache": ["@isaacs/ttlcache@1.4.1", "", {}, "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA=="],
- "@istanbuljs/load-nyc-config": ["@istanbuljs/load-nyc-config@1.1.0", "", { "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", "get-package-type": "^0.1.0", "js-yaml": "^3.13.1", "resolve-from": "^5.0.0" } }, "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ=="],
-
- "@istanbuljs/schema": ["@istanbuljs/schema@0.1.3", "", {}, "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA=="],
-
"@jellyfin/sdk": ["@jellyfin/sdk@0.13.0", "", { "peerDependencies": { "axios": "^1.12.0" } }, "sha512-oiBAOXH6s+dKdReSsYgNktBDzbxtg4JVWhEzIxZSxKcWMdSKmBtK41MhXRO7IWAC40DguKUm3nU/Z493qPAlWA=="],
- "@jest/create-cache-key-function": ["@jest/create-cache-key-function@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3" } }, "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA=="],
-
- "@jest/environment": ["@jest/environment@29.7.0", "", { "dependencies": { "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0" } }, "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw=="],
-
"@jest/expect-utils": ["@jest/expect-utils@29.7.0", "", { "dependencies": { "jest-get-type": "^29.6.3" } }, "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA=="],
- "@jest/fake-timers": ["@jest/fake-timers@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@sinonjs/fake-timers": "^10.0.2", "@types/node": "*", "jest-message-util": "^29.7.0", "jest-mock": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ=="],
-
"@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
- "@jest/transform": ["@jest/transform@29.7.0", "", { "dependencies": { "@babel/core": "^7.11.6", "@jest/types": "^29.6.3", "@jridgewell/trace-mapping": "^0.3.18", "babel-plugin-istanbul": "^6.1.1", "chalk": "^4.0.0", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.9", "jest-haste-map": "^29.7.0", "jest-regex-util": "^29.6.3", "jest-util": "^29.7.0", "micromatch": "^4.0.4", "pirates": "^4.0.4", "slash": "^3.0.0", "write-file-atomic": "^4.0.2" } }, "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw=="],
-
"@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="],
"@jimp/bmp": ["@jimp/bmp@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "bmp-js": "^0.1.0" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-aeI64HD0npropd+AR76MCcvvRaa+Qck6loCOS03CkkxGHN5/r336qTM5HPUdHKMDOGzqknuVPA8+kK1t03z12g=="],
@@ -449,121 +471,125 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+ "@nodable/entities": ["@nodable/entities@2.2.0", "", {}, "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg=="],
+
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
- "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
+ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.4", "", {}, "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ=="],
- "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
+ "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.10", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g=="],
- "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
+ "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA=="],
- "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
+ "@radix-ui/react-context": ["@radix-ui/react-context@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg=="],
- "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
+ "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw=="],
- "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
+ "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA=="],
- "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
+ "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-escape-keydown": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg=="],
- "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
+ "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q=="],
- "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
+ "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.10", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw=="],
- "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
+ "@radix-ui/react-id": ["@radix-ui/react-id@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA=="],
- "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
+ "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.12", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw=="],
- "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
+ "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.6", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ=="],
- "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
+ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.6", "", { "dependencies": { "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g=="],
- "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
+ "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw=="],
- "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
+ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
- "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w=="],
+ "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg=="],
- "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
+ "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="],
- "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
+ "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.3", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA=="],
- "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
+ "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.3", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA=="],
- "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
+ "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.2", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw=="],
- "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
+ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="],
- "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
+ "@react-native-community/cli": ["@react-native-community/cli@20.1.3", "", { "dependencies": { "@react-native-community/cli-clean": "20.1.3", "@react-native-community/cli-config": "20.1.3", "@react-native-community/cli-doctor": "20.1.3", "@react-native-community/cli-server-api": "20.1.3", "@react-native-community/cli-tools": "20.1.3", "@react-native-community/cli-types": "20.1.3", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-sLo8cu9JyFNfuuF1C+8NJ4DHE/PEFaXGd4enkcxi/OJjGG8+sOQrdjNQ4i+cVh/2c+ah1mEMwsYjc3z0+/MqSg=="],
- "@react-native-community/cli": ["@react-native-community/cli@20.0.2", "", { "dependencies": { "@react-native-community/cli-clean": "20.0.2", "@react-native-community/cli-config": "20.0.2", "@react-native-community/cli-doctor": "20.0.2", "@react-native-community/cli-server-api": "20.0.2", "@react-native-community/cli-tools": "20.0.2", "@react-native-community/cli-types": "20.0.2", "chalk": "^4.1.2", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-ocgRFKRLX8b5rEK38SJfpr0AMl6SqseWljk6c5LxCG/zpCfPPNQdXq1OsDvmEwsqO4OEQ6tmOaSm9OgTm6FhbQ=="],
+ "@react-native-community/cli-clean": ["@react-native-community/cli-clean@20.1.3", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-sFLdLzapfC0scjgzBJJWYDY2RhHPjuuPkA5r6q0gc/UQH/izXpMpLrhh1DW84cMDraNACK0U62tU7ebNaQ1LMQ=="],
- "@react-native-community/cli-clean": ["@react-native-community/cli-clean@20.0.2", "", { "dependencies": { "@react-native-community/cli-tools": "20.0.2", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-glob": "^3.3.2" } }, "sha512-hfbC69fTD0fqZCCep8aqnVztBXUhAckNhi76lEV7USENtgBRwNq2s1wATgKAzOhxKuAL9TEkf5TZ/Dhp/YLhCQ=="],
+ "@react-native-community/cli-config": ["@react-native-community/cli-config@20.1.3", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.3", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", "joi": "^17.2.1", "picocolors": "^1.1.1" } }, "sha512-n73nW0cG92oNF0r994pPqm0DjAShOm3F8LSffDYhJqNAno+h/csmv/37iL4NtSpmKIO8xqsG3uVTXz9X/hzNaQ=="],
- "@react-native-community/cli-config": ["@react-native-community/cli-config@20.0.2", "", { "dependencies": { "@react-native-community/cli-tools": "20.0.2", "chalk": "^4.1.2", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", "joi": "^17.2.1" } }, "sha512-OuSAyqTv0MBbRqSyO+80IKasHnwLESydZBTrLjIGwGhDokMH07mZo8Io2H8X300WWa57LC2L8vQf73TzGS3ikQ=="],
+ "@react-native-community/cli-config-android": ["@react-native-community/cli-config-android@20.1.3", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.3", "fast-glob": "^3.3.2", "fast-xml-parser": "^5.3.6", "picocolors": "^1.1.1" } }, "sha512-DNHDP+OWLyhKShGciBqPcxhxfp1Z/7GQcb4F+TGyCeKQAr+JdnUjRXN3X+YCU/v+g2kbYYyRJKlGabzkVvdrAw=="],
- "@react-native-community/cli-config-android": ["@react-native-community/cli-config-android@20.0.2", "", { "dependencies": { "@react-native-community/cli-tools": "20.0.2", "chalk": "^4.1.2", "fast-glob": "^3.3.2", "fast-xml-parser": "^4.4.1" } }, "sha512-5yZ2Grr89omnMptV36ilV4EIrRLrIYQAsTTVU/hNI2vL7lz6WB8rPhP5QuovXk3TIjl1Wz2r9A6ZNO2SNJ8nig=="],
+ "@react-native-community/cli-config-apple": ["@react-native-community/cli-config-apple@20.1.3", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-QX9B83nAfCPs0KiaYz61kAEHWr9sttooxzRzNdQwvZTwnsIpvWOT9GvMMj/19OeXiQzMJBzZX0Pgt6+spiUsDQ=="],
- "@react-native-community/cli-config-apple": ["@react-native-community/cli-config-apple@20.0.2", "", { "dependencies": { "@react-native-community/cli-tools": "20.0.2", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-glob": "^3.3.2" } }, "sha512-6MLL9Duu/JytqI6XfYuc78LSkRGfJoCqTSfqTJzBNSnz6S7XJps9spGBlgvrGh/j0howBpQlFH0J8Ws4N4mCxA=="],
+ "@react-native-community/cli-doctor": ["@react-native-community/cli-doctor@20.1.3", "", { "dependencies": { "@react-native-community/cli-config": "20.1.3", "@react-native-community/cli-platform-android": "20.1.3", "@react-native-community/cli-platform-apple": "20.1.3", "@react-native-community/cli-platform-ios": "20.1.3", "@react-native-community/cli-tools": "20.1.3", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", "envinfo": "^7.13.0", "execa": "^5.0.0", "node-stream-zip": "^1.9.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "semver": "^7.5.2", "wcwidth": "^1.0.1", "yaml": "^2.2.1" } }, "sha512-EI+mAPWn255/WZ4CQohy1I049yiaxVr41C3BeQ2BCyhxODIDR8XRsLzYb1t9MfqK/C3ZncUN2mPSRXFeKPPI1w=="],
- "@react-native-community/cli-doctor": ["@react-native-community/cli-doctor@20.0.2", "", { "dependencies": { "@react-native-community/cli-config": "20.0.2", "@react-native-community/cli-platform-android": "20.0.2", "@react-native-community/cli-platform-apple": "20.0.2", "@react-native-community/cli-platform-ios": "20.0.2", "@react-native-community/cli-tools": "20.0.2", "chalk": "^4.1.2", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", "envinfo": "^7.13.0", "execa": "^5.0.0", "node-stream-zip": "^1.9.1", "ora": "^5.4.1", "semver": "^7.5.2", "wcwidth": "^1.0.1", "yaml": "^2.2.1" } }, "sha512-PQ8BdoNDE2OaMGLH66HZE7FV4qj0iWBHi0lkPUTb8eJJ+vlvzUtBf0N9QSv2TAzFjA59a2FElk6jBWnDC/ql1A=="],
+ "@react-native-community/cli-platform-android": ["@react-native-community/cli-platform-android@20.1.3", "", { "dependencies": { "@react-native-community/cli-config-android": "20.1.3", "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", "logkitty": "^0.7.1", "picocolors": "^1.1.1" } }, "sha512-bzB9ELPOISuqgtDZXFPQlkuxx1YFkNx3cNgslc5ElCrk+5LeCLQLIBh/dmIuK8rwUrPcrramjeBj++Noc+TaAA=="],
- "@react-native-community/cli-platform-android": ["@react-native-community/cli-platform-android@20.0.2", "", { "dependencies": { "@react-native-community/cli-config-android": "20.0.2", "@react-native-community/cli-tools": "20.0.2", "chalk": "^4.1.2", "execa": "^5.0.0", "logkitty": "^0.7.1" } }, "sha512-Wo2AIkdv3PMEMT4k7QiNm3smNpWK6rd+glVH4Nm6Hco1EgLQ4I9x+gwcS1yN53UHYtq9YnguDCXk2L8duUESDQ=="],
+ "@react-native-community/cli-platform-apple": ["@react-native-community/cli-platform-apple@20.1.3", "", { "dependencies": { "@react-native-community/cli-config-apple": "20.1.3", "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", "fast-xml-parser": "^5.3.6", "picocolors": "^1.1.1" } }, "sha512-XJ+DqAD4hkplWVXK5AMgN7pP9+4yRSe5KfZ/b42+ofkDBI55ALlUmX+9HWE3fMuRjcotTCoNZqX2ov97cFDXpQ=="],
- "@react-native-community/cli-platform-apple": ["@react-native-community/cli-platform-apple@20.0.2", "", { "dependencies": { "@react-native-community/cli-config-apple": "20.0.2", "@react-native-community/cli-tools": "20.0.2", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-xml-parser": "^4.4.1" } }, "sha512-PdsQVFLY+wGnAN1kZ38XzzWiUlqaG1cXdpkQ1rYaiiNu3PVTc2/KtteLcPG/wbApbfoPggQ/ffh+JGg7NL+HNw=="],
+ "@react-native-community/cli-platform-ios": ["@react-native-community/cli-platform-ios@20.1.3", "", { "dependencies": { "@react-native-community/cli-platform-apple": "20.1.3" } }, "sha512-2qL48SINotuHbZO73cgqSwqd/OWNx0xTbFSdujhpogV4p8BNwYYypfjh4vJY5qJEB5PxuoVkMXT+aCADpg9nBg=="],
- "@react-native-community/cli-platform-ios": ["@react-native-community/cli-platform-ios@20.0.2", "", { "dependencies": { "@react-native-community/cli-platform-apple": "20.0.2" } }, "sha512-bVOqLsBztT+xVV65uztJ7R/dtjj4vaPXJU1RLi35zLtr1APAxzf+2ydiixxtBjNFylM3AZlF8iL5WXjeWVqrmA=="],
+ "@react-native-community/cli-server-api": ["@react-native-community/cli-server-api@20.1.3", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.3", "body-parser": "^2.2.2", "compression": "^1.7.1", "connect": "^3.6.5", "errorhandler": "^1.5.1", "nocache": "^3.0.1", "open": "^6.2.0", "pretty-format": "^29.7.0", "serve-static": "^1.13.1", "strict-url-sanitise": "0.0.1", "ws": "^6.2.3" } }, "sha512-hsNsdUKZDd2T99OuNuiXz4VuvLa1UN0zcxefmPjXQgI0byrBLzzDr+o7p03sKuODSzKi2h+BMnUxiS07HACQLA=="],
- "@react-native-community/cli-server-api": ["@react-native-community/cli-server-api@20.0.2", "", { "dependencies": { "@react-native-community/cli-tools": "20.0.2", "body-parser": "^1.20.3", "compression": "^1.7.1", "connect": "^3.6.5", "errorhandler": "^1.5.1", "nocache": "^3.0.1", "open": "^6.2.0", "pretty-format": "^29.7.0", "serve-static": "^1.13.1", "ws": "^6.2.3" } }, "sha512-u4tUzWnc+qthaDvd1NxdCqCNMY7Px6dAH1ODAXMtt+N27llGMJOl0J3slMx03dScftOWbGM61KA5cCpaxphYVQ=="],
+ "@react-native-community/cli-tools": ["@react-native-community/cli-tools@20.1.3", "", { "dependencies": { "@vscode/sudo-prompt": "^9.0.0", "appdirsjs": "^1.2.4", "execa": "^5.0.0", "find-up": "^5.0.0", "launch-editor": "^2.9.1", "mime": "^2.4.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" } }, "sha512-EAn0vPCMxtHhfWk2UwLmSUfPfLUnFgC7NjiVJVTKJyVk5qGnkPfoT8te/1IUXFTysUB0F0RIi+NgDB4usFOLeA=="],
- "@react-native-community/cli-tools": ["@react-native-community/cli-tools@20.0.2", "", { "dependencies": { "@vscode/sudo-prompt": "^9.0.0", "appdirsjs": "^1.2.4", "chalk": "^4.1.2", "execa": "^5.0.0", "find-up": "^5.0.0", "launch-editor": "^2.9.1", "mime": "^2.4.1", "ora": "^5.4.1", "prompts": "^2.4.2", "semver": "^7.5.2" } }, "sha512-bPYhRYggW9IIM8pvrZF/0r6HaxCyEWDn6zfPQPMWlkQUwkzFZ8GBY/M7yiHgDzozWKPT4DqZPumrq806Vcksow=="],
+ "@react-native-community/cli-types": ["@react-native-community/cli-types@20.1.3", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-IdAcegf0pH1hVraxWTG1ACLkYC0LDQfqtaEf42ESyLIF3Xap70JzL/9tAlxw7lSCPZPFWhrcgU0TBc4SkC/ecw=="],
- "@react-native-community/cli-types": ["@react-native-community/cli-types@20.0.2", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-OZzy6U4M8Szg8iiF459OoTjRKggxLrdhZVHKfRhrAUfojhjRiWbJNkkPxJtOIPeNSgsB0heizgpE4QwCgnYeuQ=="],
+ "@react-native-community/netinfo": ["@react-native-community/netinfo@12.0.1", "", { "peerDependencies": { "react": "*", "react-native": ">=0.59" } }, "sha512-P/3caXIvfYSJG8AWJVefukg+ZGRPs+M4Lp3pNJtgcTYoJxCjWrKQGNnCkj/Cz//zWa/avGed0i/wzm0T8vV2IQ=="],
- "@react-native-community/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="],
+ "@react-native-masked-view/masked-view": ["@react-native-masked-view/masked-view@0.3.2", "", { "peerDependencies": { "react": ">=16", "react-native": ">=0.57" } }, "sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ=="],
- "@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.4", "", { "dependencies": { "getenv": "^1.0.0" }, "peerDependencies": { "expo": ">=52.0.0" } }, "sha512-xfVDqSFjEUsb+xcMk0hE2Z/M6QZH0QzAJOSQZwo7W/ZRaLrd+xFQnx0LaXqt3kxlR3P7wskKHByDP/FSoUZnbA=="],
+ "@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.6", "", { "dependencies": { "getenv": "^1.0.0", "glob": "^11.0.0" }, "peerDependencies": { "expo": ">=52.0.0" } }, "sha512-VxMSIcro+U1EVb64pYShZsc+uE3HNGhfHppoUhTyGwx9ELQkhWvReRTOI4gpb/qeRWEcT+UbUc9Gd9Zlwm572w=="],
- "@react-native-tvos/virtualized-lists": ["@react-native-tvos/virtualized-lists@0.81.5-1", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-v77jJvzH2jzMj3G8pthdaRjiUhmdQ3S/OGiTX45Tn1J+whLaPOEkVRCel9xPHhrTPIEwrOOwGNiAFN/s1hzWZA=="],
+ "@react-native-tvos/virtualized-lists": ["@react-native-tvos/virtualized-lists@0.85.3-0", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.2.0", "react": "*", "react-native": "0.85.3" }, "optionalPeers": ["@types/react"] }, "sha512-4Ifp8SCnvJnH+4SGwhpwFa1dzt3dh0uQ3+tdLKVDKL3yuOmbNCjUQ09q7i0+5r57tPoFKb4xmaW+7yKHaSTsfA=="],
- "@react-native/assets-registry": ["@react-native/assets-registry@0.81.5", "", {}, "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w=="],
+ "@react-native/assets-registry": ["@react-native/assets-registry@0.85.3", "", {}, "sha512-u9ZiYP23vA2IFtdFQFmetzSmk6SM0xgKIoiOsr1hXNHjHaLhOm+/Ph1ud57wX6+Dbwdzx8coJgnzSKL3W21PCg=="],
- "@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.81.5", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.81.5" } }, "sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ=="],
+ "@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.85.3", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@react-native/codegen": "0.85.3" } }, "sha512-Wc94zGfeFG8Njf9SHMPfYZP04kjigkOps6F1TYTvd7ZVXuGxqseCDgxc50LWcOhOCLypI9n3oVVqz81C3p44ZA=="],
- "@react-native/babel-preset": ["@react-native/babel-preset@0.81.5", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-arrow-functions": "^7.24.7", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-computed-properties": "^7.24.7", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-function-name": "^7.25.1", "@babel/plugin-transform-literals": "^7.25.2", "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-numeric-separator": "^7.24.7", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.25.2", "@babel/plugin-transform-react-jsx-self": "^7.24.7", "@babel/plugin-transform-react-jsx-source": "^7.24.7", "@babel/plugin-transform-regenerator": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-shorthand-properties": "^7.24.7", "@babel/plugin-transform-spread": "^7.24.7", "@babel/plugin-transform-sticky-regex": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/template": "^7.25.0", "@react-native/babel-plugin-codegen": "0.81.5", "babel-plugin-syntax-hermes-parser": "0.29.1", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.14.0" } }, "sha512-UoI/x/5tCmi+pZ3c1+Ypr1DaRMDLI3y+Q70pVLLVgrnC3DHsHRIbHcCHIeG/IJvoeFqFM2sTdhSOLJrf8lOPrA=="],
+ "@react-native/babel-preset": ["@react-native/babel-preset@0.86.0", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.25.2", "@babel/plugin-transform-react-jsx-self": "^7.24.7", "@babel/plugin-transform-react-jsx-source": "^7.24.7", "@babel/plugin-transform-regenerator": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@react-native/babel-plugin-codegen": "0.86.0", "babel-plugin-syntax-hermes-parser": "0.36.0", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.14.0" } }, "sha512-bYQcWiPySNvF4dns9Ls9gMmwgq66ohvM9Fwc/Kn8r85t66UNHxch3p1QwPiSorDelFauZwJbgo9+ReibTgvpbA=="],
- "@react-native/codegen": ["@react-native/codegen@0.81.5", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.25.3", "glob": "^7.1.1", "hermes-parser": "0.29.1", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "yargs": "^17.6.2" } }, "sha512-a2TDA03Up8lpSa9sh5VRGCQDXgCTOyDOFH+aqyinxp1HChG8uk89/G+nkJ9FPd0rqgi25eCTR16TWdS3b+fA6g=="],
+ "@react-native/codegen": ["@react-native/codegen@0.85.3", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.29.0", "hermes-parser": "0.33.3", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "tinyglobby": "^0.2.15", "yargs": "^17.6.2" } }, "sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA=="],
- "@react-native/community-cli-plugin": ["@react-native/community-cli-plugin@0.81.5", "", { "dependencies": { "@react-native/dev-middleware": "0.81.5", "debug": "^4.4.0", "invariant": "^2.2.4", "metro": "^0.83.1", "metro-config": "^0.83.1", "metro-core": "^0.83.1", "semver": "^7.1.3" }, "peerDependencies": { "@react-native-community/cli": "*", "@react-native/metro-config": "*" }, "optionalPeers": ["@react-native-community/cli", "@react-native/metro-config"] }, "sha512-yWRlmEOtcyvSZ4+OvqPabt+NS36vg0K/WADTQLhrYrm9qdZSuXmq8PmdJWz/68wAqKQ+4KTILiq2kjRQwnyhQw=="],
+ "@react-native/community-cli-plugin": ["@react-native/community-cli-plugin@0.85.3", "", { "dependencies": { "@react-native/dev-middleware": "0.85.3", "debug": "^4.4.0", "invariant": "^2.2.4", "metro": "^0.84.3", "metro-config": "^0.84.3", "metro-core": "^0.84.3", "semver": "^7.1.3" }, "peerDependencies": { "@react-native-community/cli": "*", "@react-native/metro-config": "0.85.3" }, "optionalPeers": ["@react-native-community/cli", "@react-native/metro-config"] }, "sha512-fs85dmbIqNmtzEixDb0g+q6R3Vt4H9eAt8/inIZdDKfjN76+sUJA2r1nxODQ76bU23MrIbz8sI7KFBPaWk/zQw=="],
- "@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.81.5", "", {}, "sha512-bnd9FSdWKx2ncklOetCgrlwqSGhMHP2zOxObJbOWXoj7GHEmih4MKarBo5/a8gX8EfA1EwRATdfNBQ81DY+h+w=="],
+ "@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.85.3", "", {}, "sha512-uAu7rM5o/Np1zgp6fi5zM1sP1aB8DcS7DdOLcj/TkSutOAjkMqqd2lWt1/+3S7qXexRHVK5XcP+o3VXo4L/V0A=="],
- "@react-native/dev-middleware": ["@react-native/dev-middleware@0.81.5", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.81.5", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.2.0", "connect": "^3.6.5", "debug": "^4.4.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "serve-static": "^1.16.2", "ws": "^6.2.3" } }, "sha512-WfPfZzboYgo/TUtysuD5xyANzzfka8Ebni6RIb2wDxhb56ERi7qDrE4xGhtPsjCL4pQBXSVxyIlCy0d8I6EgGA=="],
+ "@react-native/debugger-shell": ["@react-native/debugger-shell@0.85.3", "", { "dependencies": { "cross-spawn": "^7.0.6", "debug": "^4.4.0", "fb-dotslash": "0.5.8" } }, "sha512-/jRAaT9boiCttIcEwS02WPwYkUihqsjSaK/TMtHz05vT6uMgac9PaQt5kzBQLIABv5aEIa5gtrMmKVz49MjkjQ=="],
- "@react-native/gradle-plugin": ["@react-native/gradle-plugin@0.81.5", "", {}, "sha512-hORRlNBj+ReNMLo9jme3yQ6JQf4GZpVEBLxmTXGGlIL78MAezDZr5/uq9dwElSbcGmLEgeiax6e174Fie6qPLg=="],
+ "@react-native/dev-middleware": ["@react-native/dev-middleware@0.85.3", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.85.3", "@react-native/debugger-shell": "0.85.3", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.3.0", "connect": "^3.6.5", "debug": "^4.4.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "serve-static": "^1.16.2", "ws": "^7.5.10" } }, "sha512-JYzBiT4A8w+KQt+dOD5v+ti+tDrGoPnsSTuApq3Ls4RB5sfWbDlYMyz3dbc8qBIHz9tv0sQ5+eOu6Xwqzr5AQA=="],
- "@react-native/js-polyfills": ["@react-native/js-polyfills@0.81.5", "", {}, "sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w=="],
+ "@react-native/gradle-plugin": ["@react-native/gradle-plugin@0.85.3", "", {}, "sha512-39dY2j50Q1pntejzwt3XL7vwXtrj8jcIfHq6E+gyu3jzYxZJVvMkMutQ39vSg6zinIQOX36oQDhidXUbCXzgoA=="],
- "@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="],
+ "@react-native/js-polyfills": ["@react-native/js-polyfills@0.85.3", "", {}, "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A=="],
- "@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.4", "", { "dependencies": { "@react-navigation/elements": "^2.8.1", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-Ie+7EgUxfZmVXm4RCiJ96oaiwJVFgVE8NJoeUKLLcYEB/99wKbhuKPJNtbkpR99PHfhq64SE7476BpcP4xOFhw=="],
+ "@react-native/metro-babel-transformer": ["@react-native/metro-babel-transformer@0.86.0", "", { "dependencies": { "@babel/core": "^7.25.2", "@react-native/babel-preset": "0.86.0", "hermes-parser": "0.36.0", "nullthrows": "^1.1.1" } }, "sha512-SjKej3E5qIahqo/G+rSOrmJUQM44RyKtWtO+VfmKAAMoJWkBFomM22hTLKCIS5cdbIAJ9COAmU+KAi2wVSO0wQ=="],
- "@react-navigation/core": ["@react-navigation/core@7.13.0", "", { "dependencies": { "@react-navigation/routers": "^7.5.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-Fc/SO23HnlGnkou/z8JQUzwEMvhxuUhr4rdPTIZp/c8q1atq3k632Nfh8fEiGtk+MP1wtIvXdN2a5hBIWpLq3g=="],
+ "@react-native/metro-config": ["@react-native/metro-config@0.86.0", "", { "dependencies": { "@react-native/js-polyfills": "0.86.0", "@react-native/metro-babel-transformer": "0.86.0", "metro-config": "^0.84.3", "metro-runtime": "^0.84.3" } }, "sha512-7v+xbTeEci9ZcQ/Z1OqI4RXcqN69wSMDYL5BAMvOReZ7U04+aDQ0/SQhClYPn6x2/RxM4WzMKSAuNyLKqvYVtw=="],
- "@react-navigation/elements": ["@react-navigation/elements@2.8.1", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-MLmuS5kPAeAFFOylw89WGjgEFBqGj/KBK6ZrFrAOqLnTqEzk52/SO1olb5GB00k6ZUCDZKJOp1BrLXslxE6TgQ=="],
+ "@react-native/normalize-colors": ["@react-native/normalize-colors@0.85.3", "", {}, "sha512-hj0PScZEhIbcOvQV5yMKX3ha4XEIOy/SVE1Rrpp0beW0dpNLOgSC7KDxGewmDnIHK9YdQUXGY9eMEfShUMIaZw=="],
- "@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.2", "", { "dependencies": { "@react-navigation/elements": "^2.8.1", "color": "^4.2.3", "react-native-tab-view": "^4.2.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-LB/bCDhdaKsexA5w0otgZEDBysGbiCr2l0hW6z41rJQ0JqAOVybH0cBuFr3Awasv0mQh9iTJNha4VsuUb7Q0Xw=="],
+ "@react-navigation/core": ["@react-navigation/core@7.21.1", "", { "dependencies": { "@react-navigation/routers": "^7.6.0", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-9eOK71VFEyJjm1bP8o4Gf7YYppWPZ+RdNIjTc+WbSM7AFEH0XXEIHkkhpIBuB416sDlRZ1CRxHk2frh1HPBZqw=="],
- "@react-navigation/native": ["@react-navigation/native@7.1.19", "", { "dependencies": { "@react-navigation/core": "^7.13.0", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-fM7q8di4Q8sp2WUhiUWOe7bEDRyRhbzsKQOd5N2k+lHeCx3UncsRYuw4Q/KN0EovM3wWKqMMmhy/YWuEO04kgw=="],
+ "@react-navigation/elements": ["@react-navigation/elements@2.9.25", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.3.3", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-cV7Hny55aE/uge6f4UB0pbGQbFl3XjXLMW+lTBNuJs8P7a9tuf7dkkPHxxgnLIvxE+KJVI2K8CGabfDk4Ht0Sg=="],
- "@react-navigation/native-stack": ["@react-navigation/native-stack@7.6.2", "", { "dependencies": { "@react-navigation/elements": "^2.8.1", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-CB6chGNLwJYiyOeyCNUKx33yT7XJSwRZIeKHf4S1vs+Oqu3u9zMnvGUIsesNgbgX0xy16gBqYsrWgr0ZczBTtA=="],
+ "@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.28", "", { "dependencies": { "@react-navigation/elements": "^2.9.19", "color": "^4.2.3", "react-native-tab-view": "^4.3.0" }, "peerDependencies": { "@react-navigation/native": "^7.2.5", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-WZHJSGV2PQOD2Vr9LF8apGvcsbDKukzF3Fhh8xVNIesqaSi9TPProv4dRw6YkenUkjvFVZYkOjvwAJOToePVpA=="],
- "@react-navigation/routers": ["@react-navigation/routers@7.5.1", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-pxipMW/iEBSUrjxz2cDD7fNwkqR4xoi0E/PcfTQGCcdJwLoaxzab5kSadBLj1MTJyT0YRrOXL9umHpXtp+Dv4w=="],
+ "@react-navigation/native": ["@react-navigation/native@7.3.3", "", { "dependencies": { "@react-navigation/core": "^7.21.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "standard-navigation": "^0.0.7", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-VjZngfeiyJestZPT99CKHRi3qOR6XwnEEPWb6uHF/L7fBI6RBVP842iJf61MUHRTzsWPUT+epJqdf1wm8N5YkQ=="],
- "@shopify/flash-list": ["@shopify/flash-list@2.0.2", "", { "dependencies": { "tslib": "2.8.1" }, "peerDependencies": { "@babel/runtime": "*", "react": "*", "react-native": "*" } }, "sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w=="],
+ "@react-navigation/routers": ["@react-navigation/routers@7.6.0", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-lblhDXfS75jLc7G2K7BZGM+7cjqQXk13X/MA4fq/12r62zM+fBhhreLzYflSitrDDXFRJpSvJXy0ziiGU04Xow=="],
+
+ "@shopify/flash-list": ["@shopify/flash-list@2.0.3", "", { "dependencies": { "tslib": "2.8.1" }, "peerDependencies": { "@babel/runtime": "*", "react": "*", "react-native": "*" } }, "sha512-jUlHuZFoPdqRCDvOqsb2YkTttRPyV8Tb/EjCx3gE2wjr4UTM+fE0Ltv9bwBg0K7yo/SxRNXaW7xu5utusRb0xA=="],
"@sideway/address": ["@sideway/address@4.1.5", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q=="],
@@ -571,27 +597,39 @@
"@sideway/pinpoint": ["@sideway/pinpoint@2.0.0", "", {}, "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="],
- "@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
+ "@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="],
- "@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="],
+ "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="],
- "@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="],
+ "@tanstack/pacer": ["@tanstack/pacer@0.18.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/store": "^0.8.0" } }, "sha512-qhCRSFei0hokQr3xYcQXqxsRD/LKlgHCxHXtKHrQoImp4x2Zu6tUOpUGVH4y2qexIrzSu3aibQBNNfC3Eay6Mg=="],
- "@tanstack/query-core": ["@tanstack/query-core@5.90.7", "", {}, "sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ=="],
+ "@tanstack/query-core": ["@tanstack/query-core@5.101.0", "", {}, "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow=="],
- "@tanstack/react-query": ["@tanstack/react-query@5.90.7", "", { "dependencies": { "@tanstack/query-core": "5.90.7" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ=="],
+ "@tanstack/query-persist-client-core": ["@tanstack/query-persist-client-core@5.101.0", "", { "dependencies": { "@tanstack/query-core": "5.101.0" } }, "sha512-LH99WepGVLwlLfuOcQcPK7f3Xg/Gf+xlMMIj9xWu/8oQ3egnDzjr+a4HvEmi6PGob5SmGXvmDKZaH5+In9dzjw=="],
+
+ "@tanstack/query-sync-storage-persister": ["@tanstack/query-sync-storage-persister@5.101.0", "", { "dependencies": { "@tanstack/query-core": "5.101.0", "@tanstack/query-persist-client-core": "5.101.0" } }, "sha512-UAGbsIJe9vkV/rbFxUBOPH27Eu6K17KuVLfK1mSy8qoSUJ4qt6JKGMxA8NMUoowbPG0ZnTfRt1Y5SFsqfqlfcA=="],
+
+ "@tanstack/react-pacer": ["@tanstack/react-pacer@0.19.4", "", { "dependencies": { "@tanstack/pacer": "0.18.0", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-coj8ULAuR0qFpjAKD44gTgRuZyjxU6Xu+IX5MwwYvr4e61OtZcJshaExoOBKpCGde0Edb12jDnzzj2Im13Qm9Q=="],
+
+ "@tanstack/react-query": ["@tanstack/react-query@5.100.14", "", { "dependencies": { "@tanstack/query-core": "5.100.14" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw=="],
+
+ "@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.101.0", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.101.0" }, "peerDependencies": { "@tanstack/react-query": "^5.101.0", "react": "^18 || ^19" } }, "sha512-AUcdBgz8V6sM9axzdqkVmWjYSOETkhr6yAZSBnEFyZT2jo6vkFq3UrpRuxGs6fmhKMWv8FA+ZJGcbaKPaoAElQ=="],
+
+ "@tanstack/react-store": ["@tanstack/react-store@0.8.1", "", { "dependencies": { "@tanstack/store": "0.8.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig=="],
+
+ "@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="],
+
+ "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
+
+ "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
+
+ "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
- "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
+ "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
- "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
-
- "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
-
- "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
-
- "@types/graceful-fs": ["@types/graceful-fs@4.1.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ=="],
+ "@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="],
"@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="],
@@ -605,51 +643,47 @@
"@types/jest": ["@types/jest@29.5.14", "", { "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" } }, "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ=="],
- "@types/lodash": ["@types/lodash@4.17.20", "", {}, "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA=="],
+ "@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
- "@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
+ "@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
- "@types/react": ["@types/react@19.1.17", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA=="],
+ "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
"@types/react-test-renderer": ["@types/react-test-renderer@19.1.0", "", { "dependencies": { "@types/react": "*" } }, "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ=="],
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
- "@types/yargs": ["@types/yargs@17.0.34", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-KExbHVa92aJpw9WDQvzBaGVE2/Pz+pLZQloT2hjL8IqsZnV62rlPOYvNnLmf/L2dyllfVUOVBj64M0z/46eR2A=="],
+ "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="],
"@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="],
- "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
+ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="],
- "@urql/core": ["@urql/core@5.2.0", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.13", "wonka": "^6.3.2" } }, "sha512-/n0ieD0mvvDnVAXEQgX/7qJiVcvYvNkOHeBvkwtylfjydar123caCXcl58PXFY11oU1oquJocVXHxLAbtv4x1A=="],
+ "@vibrant/color": ["@vibrant/color@4.0.4", "", {}, "sha512-Fq2tAszz4QOPWfHZ+KuEAchXUD8i594BM2fOJt8dI/fvYbiVoBycBF/BlNH6F4IWBubxXoPqD4JmmAHvFYbNew=="],
- "@urql/exchange-retry": ["@urql/exchange-retry@1.3.2", "", { "dependencies": { "@urql/core": "^5.1.2", "wonka": "^6.3.2" } }, "sha512-TQMCz2pFJMfpNxmSfX1VSfTjwUIFx/mL+p1bnfM1xjjdla7Z+KnGMW/EhFbpckp3LyWAH4PgOsMwOMnIN+MBFg=="],
+ "@vibrant/core": ["@vibrant/core@4.0.4", "", { "dependencies": { "@vibrant/color": "^4.0.4", "@vibrant/generator": "^4.0.4", "@vibrant/image": "^4.0.4", "@vibrant/quantizer": "^4.0.4", "@vibrant/worker": "^4.0.4" } }, "sha512-yZ0XSpW2biKyaJPpBC31AVYgn7NseKSO2q3KNMmDrkL2qC6TEWsBMnSQ28n0m///chZELXpQLx1CCOsWg5pj8w=="],
- "@vibrant/color": ["@vibrant/color@4.0.0", "", {}, "sha512-S9ItdqS1135wTXoIIqAJu8df9dqlOo6Boc5Y4MGsBTu9UmUOvOwfj5b4Ga6S5yrLAKmKYIactkz7zYJdMddkig=="],
+ "@vibrant/generator": ["@vibrant/generator@4.0.4", "", { "dependencies": { "@vibrant/color": "^4.0.4", "@vibrant/types": "^4.0.4" } }, "sha512-rwq8PnlpKdch4YqaA1FAwdm71gKE2cMrUsbu72TqRFGa8rpP1roaZlQCVXIIwElXVc3r9axZyAcqyTLaMjhrTg=="],
- "@vibrant/core": ["@vibrant/core@4.0.0", "", { "dependencies": { "@vibrant/color": "^4.0.0", "@vibrant/generator": "^4.0.0", "@vibrant/image": "^4.0.0", "@vibrant/quantizer": "^4.0.0", "@vibrant/worker": "^4.0.0" } }, "sha512-fqlVRUTDjEws9VNKvI3cDXM4wUT7fMFS+cVqEjJk3im+R5EvjJzPF6OAbNhfPzW04NvHNE555eY9FfhYuX3PRw=="],
+ "@vibrant/generator-default": ["@vibrant/generator-default@4.0.4", "", { "dependencies": { "@vibrant/color": "^4.0.4", "@vibrant/generator": "^4.0.4" } }, "sha512-QeVDeH2dz9lityvJCb84Ml4hlBTElwCpU7SVpiDFBh6gPoCLnzcb1H9G4NgG3hOlAPyrBM+Ivq1Pg+1lZj5Ywg=="],
- "@vibrant/generator": ["@vibrant/generator@4.0.0", "", { "dependencies": { "@vibrant/color": "^4.0.0", "@vibrant/types": "^4.0.0" } }, "sha512-CqKAjmgHVDXJVo3Q5+9pUJOvksR7cN3bzx/6MbURYh7lA4rhsIewkUK155M6q0vfcUN3ETi/eTneCi0tLuM2Sg=="],
+ "@vibrant/image": ["@vibrant/image@4.0.4", "", { "dependencies": { "@vibrant/color": "^4.0.4" } }, "sha512-NBIJj7umfDRVpFjJHQo1AFSCWCzQyjfil+Yxu7W62PEL72GPCif0CDiglPkvVF8QhDLmnx/x1k3LIBb9jWF2sw=="],
- "@vibrant/generator-default": ["@vibrant/generator-default@4.0.3", "", { "dependencies": { "@vibrant/color": "^4.0.0", "@vibrant/generator": "^4.0.0" } }, "sha512-HZlfp19sDokODEkZF4p70QceARHgjP3a1Dmxg+dlblYMJM98jPq+azA0fzqKNR7R17JJNHxexpJEepEsNlG0gw=="],
+ "@vibrant/image-browser": ["@vibrant/image-browser@4.0.4", "", { "dependencies": { "@vibrant/image": "^4.0.4" } }, "sha512-7qVyAm+z9t98iwMDzUgGCwgRg0KBB5RXQFgiO2Um5Izd1wO7BKC0SHVEz2k7sRx3XNfBf+JExp8quPrvSz17gg=="],
- "@vibrant/image": ["@vibrant/image@4.0.0", "", { "dependencies": { "@vibrant/color": "^4.0.0" } }, "sha512-Asv/7R/L701norosgvbjOVkodFiwcFihkXixA/gbAd6C+5GCts1Wm1NPk14FNKnM7eKkfAN+0wwPkdOH+PY/YA=="],
+ "@vibrant/image-node": ["@vibrant/image-node@4.0.4", "", { "dependencies": { "@jimp/custom": "^0.22.12", "@jimp/plugin-resize": "^0.22.12", "@jimp/types": "^0.22.12", "@vibrant/image": "^4.0.4" } }, "sha512-aG8Ukt9oTa6FWaAV5oBKsBetkKASWH31hZiFJ2R1291f3TZlphUyQTJz5TubucIRsCEl4dgG1xyxFPgse2IABA=="],
- "@vibrant/image-browser": ["@vibrant/image-browser@4.0.0", "", { "dependencies": { "@vibrant/image": "^4.0.0" } }, "sha512-mXckzvJWiP575Y/wNtP87W/TPgyJoGlPBjW4E9YmNS6n4Jb6RqyHQA0ZVulqDslOxjSsihDzY7gpAORRclaoLg=="],
+ "@vibrant/quantizer": ["@vibrant/quantizer@4.0.4", "", { "dependencies": { "@vibrant/color": "^4.0.4", "@vibrant/image": "^4.0.4", "@vibrant/types": "^4.0.4" } }, "sha512-722CooC2W4mlBiv+zyAsIrIvARnMCN/P2Muo8bnWd0SQlVWFtQnFxJWGOUPOPS4DGe3pGoqmNfvS0let4dICZQ=="],
- "@vibrant/image-node": ["@vibrant/image-node@4.0.0", "", { "dependencies": { "@jimp/custom": "^0.22.12", "@jimp/plugin-resize": "^0.22.12", "@jimp/types": "^0.22.12", "@vibrant/image": "^4.0.0" } }, "sha512-m7yfnQtmo2y8z+tOjRFBx6q/qGnhl/ax2uCaj4TBkm4TtXfR4Dsn90wT6OWXmCFFzxIKHXKKEBShkxR+4RHseA=="],
+ "@vibrant/quantizer-mmcq": ["@vibrant/quantizer-mmcq@4.0.4", "", { "dependencies": { "@vibrant/color": "^4.0.4", "@vibrant/image": "^4.0.4", "@vibrant/quantizer": "^4.0.4" } }, "sha512-/1CNnM96J8K+OBCWNUzywo6VdnmdFJyiKO+ty/nkfe8H0NseOEHIL7PrVtWGgtsb0rh2uTAq2rjXv65TfgPy8g=="],
- "@vibrant/quantizer": ["@vibrant/quantizer@4.0.0", "", { "dependencies": { "@vibrant/color": "^4.0.0", "@vibrant/image": "^4.0.0", "@vibrant/types": "^4.0.0" } }, "sha512-YDGxmCv/RvHFtZghDlVRwH5GMxdGGozWS1JpUOUt73/F5zAKGiiier8F31K1npIXARn6/Gspvg/Rbg7qqyEr2A=="],
+ "@vibrant/types": ["@vibrant/types@4.0.4", "", {}, "sha512-Qq3mVTJamn7yD4OBgBEUKaxfDlm3sxBK55N7dH3XzI9Ey7KR00R06uwtqOcEJMsziWTEXdYN3VUlYaj2Tkt7hw=="],
- "@vibrant/quantizer-mmcq": ["@vibrant/quantizer-mmcq@4.0.0", "", { "dependencies": { "@vibrant/color": "^4.0.0", "@vibrant/image": "^4.0.0", "@vibrant/quantizer": "^4.0.0" } }, "sha512-TZqNiRoGGyCP8fH1XE6rvhFwLNv9D8MP1Xhz3K8tsuUweC6buWax3qLfrfEnkhtQnPJHaqvTfTOlIIXVMfRpow=="],
+ "@vibrant/worker": ["@vibrant/worker@4.0.4", "", { "dependencies": { "@vibrant/types": "^4.0.4" } }, "sha512-Q/R6PYhSMWCXEk/IcXbWIzIu7Z4b58ABkGvcdF8Y+q/7g+KnpxKW5x/jfQ/6ciyYSby13wZZoEdNr3QQVgsdBQ=="],
- "@vibrant/types": ["@vibrant/types@4.0.0", "", {}, "sha512-tA5TAbuROXcPkt+PWjmGfoaiEXyySVaNnCZovf6vXhCbMdrTTCQXvNCde2geiVl6YwtuU/Qrj9iZxS5jZ6yVIw=="],
+ "@vscode/sudo-prompt": ["@vscode/sudo-prompt@9.3.2", "", {}, "sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw=="],
- "@vibrant/worker": ["@vibrant/worker@4.0.0", "", { "dependencies": { "@vibrant/types": "^4.0.0" } }, "sha512-nSaZZwWQKOgN/nPYUAIRF0/uoa7KpK91A+gjLmZZDgfN1enqxaiihmn+75ayNadW0c6cxAEpEFEHTONR5u9tMw=="],
-
- "@vscode/sudo-prompt": ["@vscode/sudo-prompt@9.3.1", "", {}, "sha512-9ORTwwS74VaTn38tNbQhsA5U44zkJfcb0BdTSyyG6frP4e8KMtHuTXYmwefe5dpL8XB1aGSIVTaLjD3BbWb5iA=="],
-
- "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
+ "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
"@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="],
@@ -657,21 +691,19 @@
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
- "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
+ "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
- "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
-
- "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
+ "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"anser": ["anser@1.4.10", "", {}, "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww=="],
- "ansi-escapes": ["ansi-escapes@7.2.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw=="],
+ "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
"ansi-fragments": ["ansi-fragments@0.2.1", "", { "dependencies": { "colorette": "^1.0.7", "slice-ansi": "^2.0.0", "strip-ansi": "^5.0.0" } }, "sha512-DykbNHxuXQwUDRv5ibc2b0x7uw7wmwOGLBUd5RmaQ5z8Lhx19vwvKV+FAsM5rEA6dEcHxX+/Ad5s9eF2k2bB+w=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
- "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+ "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="],
@@ -679,6 +711,8 @@
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
+ "anynum": ["anynum@1.0.0", "", {}, "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA=="],
+
"appdirsjs": ["appdirsjs@1.2.7", "", {}, "sha512-Quji6+8kLBC3NnBeo14nPDq0+2jUs5s3/xEye+udFHumHhRk4M7aAMXp/PBJqkKYGuuyR9M/6Dq7d2AViiGmhw=="],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
@@ -687,9 +721,9 @@
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
- "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
+ "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
- "assert": ["assert@2.1.0", "", { "dependencies": { "call-bind": "^1.0.2", "is-nan": "^1.3.2", "object-is": "^1.1.5", "object.assign": "^4.1.4", "util": "^0.12.5" } }, "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw=="],
+ "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
"astral-regex": ["astral-regex@1.0.0", "", {}, "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg=="],
@@ -697,45 +731,33 @@
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
- "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
+ "axios": ["axios@1.18.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw=="],
- "axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="],
-
- "babel-jest": ["babel-jest@29.7.0", "", { "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", "babel-plugin-istanbul": "^6.1.1", "babel-preset-jest": "^29.6.3", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.8.0" } }, "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg=="],
-
- "babel-plugin-istanbul": ["babel-plugin-istanbul@6.1.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", "@istanbuljs/schema": "^0.1.2", "istanbul-lib-instrument": "^5.0.4", "test-exclude": "^6.0.0" } }, "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA=="],
-
- "babel-plugin-jest-hoist": ["babel-plugin-jest-hoist@29.6.3", "", { "dependencies": { "@babel/template": "^7.3.3", "@babel/types": "^7.3.3", "@types/babel__core": "^7.1.14", "@types/babel__traverse": "^7.0.6" } }, "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg=="],
-
- "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.14", "", { "dependencies": { "@babel/compat-data": "^7.27.7", "@babel/helper-define-polyfill-provider": "^0.6.5", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg=="],
+ "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.17", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w=="],
"babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.13.0", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5", "core-js-compat": "^3.43.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A=="],
- "babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.5", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg=="],
+ "babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.8", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg=="],
"babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
"babel-plugin-react-native-web": ["babel-plugin-react-native-web@0.21.2", "", {}, "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA=="],
- "babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.29.1", "", { "dependencies": { "hermes-parser": "0.29.1" } }, "sha512-2WFYnoWGdmih1I1J5eIqxATOeycOqRwYxAQBu3cUu/rhwInwHUg7k60AFNbuGjSDL8tje5GDrAnxzRLcu2pYcA=="],
+ "babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.33.3", "", { "dependencies": { "hermes-parser": "0.33.3" } }, "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA=="],
"babel-plugin-transform-flow-enums": ["babel-plugin-transform-flow-enums@0.0.2", "", { "dependencies": { "@babel/plugin-syntax-flow": "^7.12.1" } }, "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ=="],
- "babel-preset-current-node-syntax": ["babel-preset-current-node-syntax@1.2.0", "", { "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-numeric-separator": "^7.10.4", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0 || ^8.0.0-0" } }, "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg=="],
-
- "babel-preset-expo": ["babel-preset-expo@54.0.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-transform-class-static-block": "^7.27.1", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-preset": "0.81.5", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.29.1", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4", "resolve-from": "^5.0.0" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", "react-refresh": ">=0.14.0 <1.0.0" }, "optionalPeers": ["@babel/runtime", "expo"] }, "sha512-JENWk0bvxW4I1ftveO8GRtX2t2TH6N4Z0TPvIHxroZ/4SswUfyNsUNbbP7Fm4erj3ar/JHGri5kTZ+s3xdjHZw=="],
-
- "babel-preset-jest": ["babel-preset-jest@29.6.3", "", { "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA=="],
+ "babel-preset-expo": ["babel-preset-expo@56.0.15", "", { "dependencies": { "@babel/generator": "^7.20.5", "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-class-static-block": "^7.27.1", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.28.6", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@babel/plugin-transform-react-pure-annotations": "^7.27.1", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-plugin-codegen": "0.85.3", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.33.3", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", "expo-widgets": "^56.0.18", "react-refresh": ">=0.14.0 <1.0.0" }, "optionalPeers": ["@babel/runtime", "expo", "expo-widgets"] }, "sha512-0MqbQoM6nBUbKvgu2xJ4VixZnUTGTq3HB2WwvOikdO4CiPxbQ+wGA25fOoHHSni5iEFW39wy6y1ookTWlq3wVw=="],
"badgin": ["badgin@1.2.3", "", {}, "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
+ "barcode-detector": ["barcode-detector@3.2.0", "", { "dependencies": { "zxing-wasm": "3.1.0" } }, "sha512-MrT5TT058ptG5YB157pHLfXKVpp0BKEfQBOb8QvzTbatzmLDu85JJ0Gd/sCYwbwdwStJvxsYflrSN6D6E4Ndyw=="],
+
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
- "baseline-browser-mapping": ["baseline-browser-mapping@2.8.25", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA=="],
-
- "better-opn": ["better-opn@3.0.2", "", { "dependencies": { "open": "^8.0.4" } }, "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ=="],
+ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.37", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig=="],
"big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
@@ -745,7 +767,7 @@
"bmp-js": ["bmp-js@0.1.0", "", {}, "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw=="],
- "body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="],
+ "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
@@ -753,11 +775,11 @@
"bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="],
- "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
+ "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
- "browserslist": ["browserslist@4.27.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", "electron-to-chromium": "^1.5.238", "node-releases": "^2.0.26", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw=="],
+ "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"bser": ["bser@2.1.1", "", { "dependencies": { "node-int64": "^0.4.0" } }, "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ=="],
@@ -767,7 +789,7 @@
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
- "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
+ "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
@@ -781,17 +803,15 @@
"camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="],
- "caniuse-lite": ["caniuse-lite@1.0.30001754", "", {}, "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg=="],
+ "caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
- "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
-
"chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="],
- "chromium-edge-launcher": ["chromium-edge-launcher@0.2.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0", "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg=="],
+ "chromium-edge-launcher": ["chromium-edge-launcher@0.3.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0", "mkdirp": "^1.0.4" } }, "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA=="],
"ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="],
@@ -799,7 +819,7 @@
"cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
- "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="],
+ "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
@@ -807,15 +827,15 @@
"clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
- "color": ["color@5.0.2", "", { "dependencies": { "color-convert": "^3.0.1", "color-string": "^2.0.0" } }, "sha512-e2hz5BzbUPcYlIRHo8ieAhYgoajrJr+hWoceg6E345TPsATMUKqDgzt8fSXZJJbxfpiPzkWyphz8yn8At7q3fA=="],
+ "color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="],
- "color-convert": ["color-convert@3.1.2", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-UNqkvCDXstVck3kdowtOTWROIJQwafjOfXSmddoDrXo4cewMKmusCeF22Q24zvjR8nwWib/3S/dfyzPItPEiJg=="],
+ "color-convert": ["color-convert@3.1.3", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg=="],
- "color-name": ["color-name@2.0.2", "", {}, "sha512-9vEt7gE16EW7Eu7pvZnR0abW9z6ufzhXxGXZEVU9IqPdlsUiMwJeJfRtq0zePUmnbHGT9zajca7mX8zgoayo4A=="],
+ "color-name": ["color-name@2.1.0", "", {}, "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg=="],
- "color-string": ["color-string@2.1.2", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA=="],
+ "color-string": ["color-string@2.1.4", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg=="],
- "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
+ "colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
@@ -827,17 +847,15 @@
"compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="],
- "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
-
"connect": ["connect@3.7.0", "", { "dependencies": { "debug": "2.6.9", "finalhandler": "1.1.2", "parseurl": "~1.3.3", "utils-merge": "1.0.1" } }, "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ=="],
- "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
+ "content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
- "core-js-compat": ["core-js-compat@3.46.0", "", { "dependencies": { "browserslist": "^4.26.3" } }, "sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law=="],
+ "core-js-compat": ["core-js-compat@3.49.0", "", { "dependencies": { "browserslist": "^4.28.1" } }, "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA=="],
- "cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="],
+ "cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="],
"cross-env": ["cross-env@10.1.0", "", { "dependencies": { "@epic-web/invariant": "^1.0.0", "cross-spawn": "^7.0.6" }, "bin": { "cross-env": "dist/bin/cross-env.js", "cross-env-shell": "dist/bin/cross-env-shell.js" } }, "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw=="],
@@ -845,8 +863,6 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
- "crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="],
-
"css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="],
"css-in-js-utils": ["css-in-js-utils@3.1.0", "", { "dependencies": { "hyphenate-style-name": "^1.0.3" } }, "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A=="],
@@ -861,34 +877,32 @@
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
+ "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
+
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
- "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
- "dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="],
+ "dayjs": ["dayjs@1.11.21", "", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="],
- "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
"decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="],
- "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
-
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
- "define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
-
- "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
-
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
+ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
+
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -899,8 +913,14 @@
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
+ "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
+
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
+ "dnssd-advertise": ["dnssd-advertise@1.1.6", "", {}, "sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg=="],
+
+ "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
+
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
@@ -909,17 +929,11 @@
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
- "dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
-
- "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
-
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
- "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
-
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
- "electron-to-chromium": ["electron-to-chromium@1.5.249", "", {}, "sha512-5vcfL3BBe++qZ5kuFhD/p8WOM1N9m3nwvJPULJx+4xf2usSlZFJ0qoNYO2fOX4hi3ocuDcmDobtA+5SFr4OmBg=="],
+ "electron-to-chromium": ["electron-to-chromium@1.5.374", "", {}, "sha512-HCF5i7izveksHSGqa7mhDh6tr3Uz9Dar2RAjwuh69bw3QGPVObjQIgLwQWeO/Rxp9/r0KdboKy9RbpQDl97fjg=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -927,11 +941,9 @@
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
- "env-editor": ["env-editor@0.4.2", "", {}, "sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA=="],
-
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
- "envinfo": ["envinfo@7.20.0", "", { "bin": { "envinfo": "dist/cli.js" } }, "sha512-+zUomDcLXsVkQ37vUqWBvQwLaLlj8eZPSi61llaEFAVBY5mhcXdaSw1pSJVl4yTYD5g/gEfpNl28YYk4IPvrrg=="],
+ "envinfo": ["envinfo@7.21.0", "", { "bin": { "envinfo": "dist/cli.js" } }, "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow=="],
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
@@ -939,115 +951,127 @@
"error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="],
- "errorhandler": ["errorhandler@1.5.1", "", { "dependencies": { "accepts": "~1.3.7", "escape-html": "~1.0.3" } }, "sha512-rcOwbfvP1WTViVoUjcfZicVzjhjTuhSMntHh6mW3IrEiyE6mJyXvsToJUJGlGlw/2xU9P5whlWNGlIDVeCiT4A=="],
+ "errorhandler": ["errorhandler@1.5.2", "", { "dependencies": { "accepts": "~1.3.8", "escape-html": "~1.0.3" } }, "sha512-kNAL7hESndBCrWwS72QyV3IVOTrVmj9D062FV5BQswNL5zEdeRmz/WJFyh6Aj/plvvSOrzddkxW57HgkZcR9Fw=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
- "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
+ "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
+ "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
+
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
- "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
-
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
- "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
+ "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
- "exec-async": ["exec-async@2.2.0", "", {}, "sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw=="],
-
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="],
"expect": ["expect@29.7.0", "", { "dependencies": { "@jest/expect-utils": "^29.7.0", "jest-get-type": "^29.6.3", "jest-matcher-utils": "^29.7.0", "jest-message-util": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw=="],
- "expo": ["expo@54.0.23", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.16", "@expo/config": "~12.0.10", "@expo/config-plugins": "~54.0.2", "@expo/devtools": "0.1.7", "@expo/fingerprint": "0.15.3", "@expo/metro": "~54.1.0", "@expo/metro-config": "54.0.9", "@expo/vector-icons": "^15.0.3", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~54.0.7", "expo-asset": "~12.0.9", "expo-constants": "~18.0.10", "expo-file-system": "~19.0.17", "expo-font": "~14.0.9", "expo-keep-awake": "~15.0.7", "expo-modules-autolinking": "3.0.21", "expo-modules-core": "3.0.25", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-b4uQoiRwQ6nwqsT2709RS15CWYNGF3eJtyr1KyLw9WuMAK7u4jjofkhRiO0+3o1C2NbV+WooyYTOZGubQQMBaQ=="],
+ "expo": ["expo@56.0.12", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "^56.1.16", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.9", "@expo/devtools": "~56.0.2", "@expo/dom-webview": "~56.0.5", "@expo/fingerprint": "^0.19.4", "@expo/local-build-cache-provider": "^56.0.8", "@expo/log-box": "^56.0.13", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.14", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~56.0.15", "expo-asset": "~56.0.17", "expo-constants": "~56.0.18", "expo-file-system": "~56.0.8", "expo-font": "~56.0.7", "expo-keep-awake": "~56.0.3", "expo-modules-autolinking": "~56.0.16", "expo-modules-core": "~56.0.17", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" }, "peerDependencies": { "@expo/metro-runtime": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-web": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/metro-runtime", "react-dom", "react-native-web", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-FxgdI/Yqva6iJOThZIHfvxlKPxs4EC4uScUnEswwSArR/Fj9k430O13R590LcOQTsdNsjIs+GBHwjfoAY6vmAQ=="],
- "expo-application": ["expo-application@7.0.7", "", { "peerDependencies": { "expo": "*" } }, "sha512-Jt1/qqnoDUbZ+bK91+dHaZ1vrPDtRBOltRa681EeedkisqguuEeUx4UHqwVyDK2oHWsK6lO3ojetoA4h8OmNcg=="],
+ "expo-application": ["expo-application@56.0.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw=="],
- "expo-asset": ["expo-asset@12.0.9", "", { "dependencies": { "@expo/image-utils": "^0.8.7", "expo-constants": "~18.0.9" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-vrdRoyhGhBmd0nJcssTSk1Ypx3Mbn/eXaaBCQVkL0MJ8IOZpAObAjfD5CTy8+8RofcHEQdh3wwZVCs7crvfOeg=="],
+ "expo-asset": ["expo-asset@56.0.17", "", { "dependencies": { "@expo/image-utils": "^0.10.1", "expo-constants": "~56.0.18" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-GFN5j+8SPkyv0nfsiFHewmdB/D0tL237TsBE/gSfFOFy/J3a52py7IulcSqkA3sQE/u/UlD5BmvP5ssS4//nUg=="],
- "expo-background-task": ["expo-background-task@1.0.8", "", { "dependencies": { "expo-task-manager": "~14.0.7" }, "peerDependencies": { "expo": "*" } }, "sha512-G6WnljBhO0K9j0ntmytF5rZLtYUpwh8n2+hcgmxM1ISPAVVZSPHZhkF9YjBOKpdPWZxmukBgEwejfcGckb8TQQ=="],
+ "expo-audio": ["expo-audio@56.0.12", "", { "peerDependencies": { "expo": "*", "expo-asset": "*", "react": "*", "react-native": "*" } }, "sha512-ne2UIO/HsQoBL9e+tGs5N9Sf3NyW5sJMm4sDkexbSJRc2IchLDG+9Msu/+l5N4RlZ8SiF42wRyWsh/Usg+SwOw=="],
- "expo-blur": ["expo-blur@15.0.7", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-SugQQbQd+zRPy8z2G5qDD4NqhcD7srBF7fN7O7yq6q7ZFK59VWvpDxtMoUkmSfdxgqONsrBN/rLdk00USADrMg=="],
+ "expo-background-task": ["expo-background-task@56.0.19", "", { "dependencies": { "expo-task-manager": "~56.0.19" }, "peerDependencies": { "expo": "*" } }, "sha512-TuFqorlz4HXtWAwQGcg+/URXpBwjwNsAxdis/6m5mhtgBIVbX2gYYv/s6eIf8lh2cAI1zFkb+/bHHuC2aOAGGA=="],
- "expo-brightness": ["expo-brightness@14.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-wccb/NdQEd45UF0lgNEksZt3E8uzlIcxIx1ZqZYWbHyNvcS3LUj5wxB6+ZgKTLeWu4vLQ+oHe+F0QrkC9ojrig=="],
+ "expo-blur": ["expo-blur@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-KDDtrpWc2tYlm1WCPaOgBtv+YEGqe5ELheFPIgSNgHt28NQUDcfBcFsA9Us2StDh6osmSD6NbKxOt5bU6PcDbQ=="],
- "expo-build-properties": ["expo-build-properties@1.0.9", "", { "dependencies": { "ajv": "^8.11.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-2icttCy3OPTk/GWIFt+vwA+0hup53jnmYb7JKRbvNvrrOrz+WblzpeoiaOleI2dYG/vjwpNO8to8qVyKhYJtrQ=="],
+ "expo-brightness": ["expo-brightness@56.0.5", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-AkCGW+Lj8I4o2+Yjs1bzjIJz44cgNXfAN+pf01uDwmA1/1JTIy8x1eISvmz6d2r/1OhdyBZxeDkACNLVMDx5zA=="],
- "expo-constants": ["expo-constants@18.0.10", "", { "dependencies": { "@expo/config": "~12.0.10", "@expo/env": "~2.0.7" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-Rhtv+X974k0Cahmvx6p7ER5+pNhBC0XbP1lRviL2J1Xl4sT2FBaIuIxF/0I0CbhOsySf0ksqc5caFweAy9Ewiw=="],
+ "expo-build-properties": ["expo-build-properties@56.0.19", "", { "dependencies": { "@expo/schema-utils": "^56.0.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-InoviXcxWosNp4cC7L3SWoiY99Xr2HdgN+LYHb6mUm/BBVxy1mIMrZR+3PJ2gwDZzW6EJNDz8ioASWGHBTmzpA=="],
- "expo-dev-client": ["expo-dev-client@6.0.17", "", { "dependencies": { "expo-dev-launcher": "6.0.17", "expo-dev-menu": "7.0.16", "expo-dev-menu-interface": "2.0.0", "expo-manifests": "~1.0.8", "expo-updates-interface": "~2.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-zVilIum3sqXFbhYhPT6TuxR3ddH/IfHL82FiOTqJUiYaTQqun1I6ogSvU1djhY1eXUYhfYIBieQNWMVjXPxMvw=="],
+ "expo-camera": ["expo-camera@56.0.8", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ=="],
- "expo-dev-launcher": ["expo-dev-launcher@6.0.17", "", { "dependencies": { "expo-dev-menu": "7.0.16", "expo-manifests": "~1.0.8" }, "peerDependencies": { "expo": "*" } }, "sha512-riLxFXaw6Nvgb27TiQtUvoHkW/zTz0aO7M+qxDBBaEbJMJSFl51KSwOJJBTItVQIE9f9jB8x5L1CfLw81/McZw=="],
+ "expo-constants": ["expo-constants@56.0.18", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw=="],
- "expo-dev-menu": ["expo-dev-menu@7.0.16", "", { "dependencies": { "expo-dev-menu-interface": "2.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-/kjTjk5tcZV0ixYnV3JyzPXKlMimpBNYaDo4XxBbRFIkTf/vmb/9e1BTR2nALnoa/D3MRwtR43gZYT+W/wfKXw=="],
+ "expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
- "expo-dev-menu-interface": ["expo-dev-menu-interface@2.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-BvAMPt6x+vyXpThsyjjOYyjwfjREV4OOpQkZ0tNl+nGpsPfcY9mc6DRACoWnH9KpLzyIt3BOgh3cuy/h/OxQjw=="],
+ "expo-dev-client": ["expo-dev-client@56.0.20", "", { "dependencies": { "expo-dev-launcher": "~56.0.20", "expo-dev-menu": "~56.0.17", "expo-dev-menu-interface": "~56.0.0", "expo-manifests": "~56.0.4", "expo-updates-interface": "~56.0.1" }, "peerDependencies": { "expo": "*" } }, "sha512-KebW4r8HhIiRrPzs6ZqVhp/so8buyglAO1h4No0Ibr5C2XRnlIoGWCN4zC6rW7IsI3iKUXcofLAQV9OjoxjiwQ=="],
- "expo-device": ["expo-device@8.0.9", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-XqRpaljDNAYZGZzMpC+b9KZfzfydtkwx3pJAp6ODDH+O/5wjAw+mLc5wQMGJCx8/aqVmMsAokec7iebxDPFZDA=="],
+ "expo-dev-launcher": ["expo-dev-launcher@56.0.20", "", { "dependencies": { "@expo/schema-utils": "^56.0.0", "expo-dev-menu": "~56.0.17", "expo-manifests": "~56.0.4" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-cTuC3GkPl9CTwO3CKnVmEm9qoQ0WairhwvTh6qMlg+zr/QU/tdiU++uDBX67hf9+FuxQOkWGp5khFNosT+0cIg=="],
- "expo-doctor": ["expo-doctor@1.17.11", "", { "bin": { "expo-doctor": "build/index.js" } }, "sha512-4eYZPJm4op2aRQWvd6RA6dZt1mVQQe79n7iqqFi6P927K8w2ld8kZ2D7m/4ahjj9/HBW9NS98m4qGomKJFDuPg=="],
+ "expo-dev-menu": ["expo-dev-menu@56.0.17", "", { "dependencies": { "expo-dev-menu-interface": "~56.0.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-OofRkOOZnaDriSav3JDN4NP2lsLt2eOa/Ryptr5nMD62SwnFyK4R6n6PkPVaDU3LSsZqndAJHmN6inS+oziayQ=="],
- "expo-file-system": ["expo-file-system@19.0.17", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-WwaS01SUFrxBnExn87pg0sCTJjZpf2KAOzfImG0o8yhkU7fbYpihpl/oocXBEsNbj58a8hVt1Y4CVV5c1tzu/g=="],
+ "expo-dev-menu-interface": ["expo-dev-menu-interface@56.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-odATx0ZL/Kis10sKSBiKiGQxAB6coSi/KQtKcMhnQVNno6FkRh5/4e5BqcEvpq2rNMTiQp4ytNAQHtdwbPXvGA=="],
- "expo-font": ["expo-font@14.0.9", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-xCoQbR/36qqB6tew/LQ6GWICpaBmHLhg/Loix5Rku/0ZtNaXMJv08M9o1AcrdiGTn/Xf/BnLu6DgS45cWQEHZg=="],
+ "expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="],
- "expo-haptics": ["expo-haptics@15.0.7", "", { "peerDependencies": { "expo": "*" } }, "sha512-7flWsYPrwjJxZ8x82RiJtzsnk1Xp9ahnbd9PhCy3NnsemyMApoWIEUr4waPqFr80DtiLZfhD9VMLL1CKa8AImQ=="],
+ "expo-doctor": ["expo-doctor@1.20.0", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-YL0uGQGZ65tWOCOiDH6BFzBkx/9N46MyBUdRfa4t6iuG4tbLg/phRrhmuaNm99QJVMqYtt3ksRbFCzuYOuX20Q=="],
- "expo-image": ["expo-image@3.0.10", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-i4qNCEf9Ur7vDqdfDdFfWnNCAF2efDTdahuDy9iELPS2nzMKBLeeGA2KxYEPuRylGCS96Rwm+SOZJu6INc2ADQ=="],
+ "expo-file-system": ["expo-file-system@56.0.8", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-NrH41/8snGIBSbYicwVLB4txPdgCATd7ZYhMAGS3YJZ9GbnduhlAoV4/YCbGayjrbpE9bJb/6wegPL/zmvRMnQ=="],
- "expo-json-utils": ["expo-json-utils@0.15.0", "", {}, "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ=="],
+ "expo-font": ["expo-font@56.0.7", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-hpU/vRwPzsby9lPGkA4blDqLIIXYzoWnCZHr6PxvcWbY/uPObAiyhh6q+e0WYsB65SthK+PLH95jEnVag7fwEg=="],
- "expo-keep-awake": ["expo-keep-awake@15.0.7", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CgBNcWVPnrIVII5G54QDqoE125l+zmqR4HR8q+MQaCfHet+dYpS5vX5zii/RMayzGN4jPgA4XYIQ28ePKFjHoA=="],
+ "expo-glass-effect": ["expo-glass-effect@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-xI9rXtDwi7RW82uAlfyaXO6+k21ApWJ2tHAWYqPr/FjfmZbKsgNJ4Q0iZzGPCwboqjTGxaRZ61SZxBl8hDt5iA=="],
- "expo-linear-gradient": ["expo-linear-gradient@15.0.7", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-yF+y+9Shpr/OQFfy/wglB/0bykFMbwHBTuMRa5Of/r2P1wbkcacx8rg0JsUWkXH/rn2i2iWdubyqlxSJa3ggZA=="],
+ "expo-haptics": ["expo-haptics@56.0.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-ycoahZJnR9tWAVh/0mJYxbETtHRYaWjiWS8cHlP6aDGU6Q6Y8rZ5NKsuBwWw6HR2Pe30mfVFgbF2HrBR6gtYmw=="],
- "expo-linking": ["expo-linking@8.0.8", "", { "dependencies": { "expo-constants": "~18.0.8", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MyeMcbFDKhXh4sDD1EHwd0uxFQNAc6VCrwBkNvvvufUsTYFq3glTA9Y8a+x78CPpjNqwNAamu74yIaIz7IEJyg=="],
+ "expo-image": ["expo-image@56.0.11", "", { "dependencies": { "sf-symbols-typescript": "^2.2.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-k2xwxGk14xi6zxmEGAU4rUTb1lK5qf0y0Qb8+Jaggnul0KaJJxcq9qvyDp9iyJBW35cp9isONAUnNtIiooZ/Pw=="],
- "expo-localization": ["expo-localization@17.0.7", "", { "dependencies": { "rtl-detect": "^1.0.2" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-ACg1B0tJLNa+f8mZfAaNrMyNzrrzHAARVH1sHHvh+LolKdQpgSKX69Uroz1Llv4C71furpwBklVStbNcEwVVVA=="],
+ "expo-json-utils": ["expo-json-utils@56.0.0", "", {}, "sha512-lUqyv9aIGDbYTQ5Nux2FnH2/Dz0w5uJ8Pr080eS0StXi2jr5OmuMNErpzUnpfnYOU55xKotd4AHv68PfV/ludg=="],
- "expo-manifests": ["expo-manifests@1.0.8", "", { "dependencies": { "@expo/config": "~12.0.8", "expo-json-utils": "~0.15.0" }, "peerDependencies": { "expo": "*" } }, "sha512-nA5PwU2uiUd+2nkDWf9e71AuFAtbrb330g/ecvuu52bmaXtN8J8oiilc9BDvAX0gg2fbtOaZdEdjBYopt1jdlQ=="],
+ "expo-keep-awake": ["expo-keep-awake@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA=="],
- "expo-modules-autolinking": ["expo-modules-autolinking@3.0.21", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-pOtPDLln3Ju8DW1zRW4OwZ702YqZ8g+kM/tEY1sWfv22kWUtxkvK+ytRDRpRdnKEnC28okbhWqeMnmVkSFzP6Q=="],
+ "expo-linear-gradient": ["expo-linear-gradient@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-KUp1dNSRtuMyiExhf6FJf5YUtmw2cRaPytl10HQi7isj5Yac38udmD55T2tglNYTZlvgT5+oflpyFoH15hmOcw=="],
- "expo-modules-core": ["expo-modules-core@3.0.25", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-0P8PT8UV6c5/+p8zeVM/FXvBgn/ErtGcMaasqUgbzzBUg94ktbkIrij9t9reGCrir03BYt/Bcpv+EQtYC8JOug=="],
+ "expo-linking": ["expo-linking@56.0.14", "", { "dependencies": { "expo-constants": "~56.0.18", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-IvVQHWC+Cj4fK5qD3iEVYqpU2a4rLW0IpAAlGJ4MH+H1fyZiHh3eN6qg2WmoclOEPfYATSuEa+dQT6wfgVpXlQ=="],
- "expo-notifications": ["expo-notifications@0.32.12", "", { "dependencies": { "@expo/image-utils": "^0.8.7", "@ide/backoff": "^1.0.0", "abort-controller": "^3.0.0", "assert": "^2.0.0", "badgin": "^1.1.5", "expo-application": "~7.0.7", "expo-constants": "~18.0.9" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-FVJ5W4rOpKvmrLJ1Sd5pxiVTV4a7ApgTlKro+E5X8M2TBbXmEVOjs09klzdalXTjlzmU/Gu8aRw9xr7Ea/gZdw=="],
+ "expo-localization": ["expo-localization@56.0.6", "", { "dependencies": { "rtl-detect": "^1.0.2" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-zzBVoUFHCVNBywcxGsspoZeIXebihOo/AnmQYE4jMv8gHCSKlLNFT+ft+0+mWcZCMs9necvUs8S8TDonAu/xBA=="],
- "expo-router": ["expo-router@6.0.14", "", { "dependencies": { "@expo/metro-runtime": "^6.1.2", "@expo/schema-utils": "^0.1.7", "@radix-ui/react-slot": "1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/native": "^7.1.8", "@react-navigation/native-stack": "^7.3.16", "client-only": "^0.0.1", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-server": "^1.0.3", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.1.6", "semver": "~7.6.3", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "use-latest-callback": "^0.2.1", "vaul": "^1.1.2" }, "peerDependencies": { "@react-navigation/drawer": "^7.5.0", "@testing-library/react-native": ">= 12.0.0", "expo": "*", "expo-constants": "^18.0.10", "expo-linking": "^8.0.8", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-screens": "*", "react-native-web": "*", "react-server-dom-webpack": ">= 19.0.0" }, "optionalPeers": ["@react-navigation/drawer", "@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-vizLO4SgnMEL+PPs2dXr+etEOuksjue7yUQBCtfCEdqoDkQlB0r35zI7rS34Wt53sxKWSlM2p+038qQEpxtiFw=="],
+ "expo-location": ["expo-location@56.0.18", "", { "dependencies": { "@expo/image-utils": "^0.10.1" }, "peerDependencies": { "expo": "*" } }, "sha512-6xP0UwGy8a7EEHAMeigYAp3HNo3yWHAg05tVPUfwrOWepWPpFXmjsfUBUxQdkpfpjddJ9r+f4PplxZqKI0LtjA=="],
- "expo-screen-orientation": ["expo-screen-orientation@9.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-UH/XlB9eMw+I2cyHSkXhAHRAPk83WyA3k5bst7GLu14wRuWiTch9fb6I7qEJK5CN6+XelcWxlBJymys6Fr/FKA=="],
+ "expo-manifests": ["expo-manifests@56.0.4", "", { "dependencies": { "expo-json-utils": "~56.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-Fokawl2UkiExIF0bqGoblRFA8lYpROVD+EpvDwSW4LgqQyPwNua1gLSgHZjdl5GsVugfRMMWE3LHaibDyX93hw=="],
- "expo-sensors": ["expo-sensors@15.0.7", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-TGUxRx/Ss7KGgfWo453YF64ENucw6oYryPiu/8I3ZZuf114xQPRxAbsZohPLaVUUGuaUyWbDsb0eRsmuKUzBnQ=="],
+ "expo-modules-autolinking": ["expo-modules-autolinking@56.0.16", "", { "dependencies": { "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-9JnL4N46P8ubDpDIfWolDn7nxU2j1rY67xY/dNVuyH0m+HG+r/JI16VYtjIf4COpZtEuFo4D3h3MBeFzGucMnw=="],
- "expo-server": ["expo-server@1.0.4", "", {}, "sha512-IN06r3oPxFh3plSXdvBL7dx0x6k+0/g0bgxJlNISs6qL5Z+gyPuWS750dpTzOeu37KyBG0RcyO9cXUKzjYgd4A=="],
+ "expo-modules-core": ["expo-modules-core@56.0.17", "", { "dependencies": { "@expo/expo-modules-macros-plugin": "0.2.2", "expo-modules-jsi": "~56.0.10", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": "^0.7.4 || ^0.8.0" }, "optionalPeers": ["react-native-worklets"] }, "sha512-5J8whnT7Ccp+BrFClLmpF76omBqn95VZExroTm01Dgjm4vpty1Rb7U3we+ZUceNHtRd07Lw30u7FNfDgIhEbRQ=="],
- "expo-sharing": ["expo-sharing@14.0.7", "", { "peerDependencies": { "expo": "*" } }, "sha512-t/5tR8ZJNH6tMkHXlF7453UafNIfrpfTG+THN9EMLC4Wsi4bJuESPm3NdmWDg2D4LDALJI/LQo0iEnLAd5Sp4g=="],
+ "expo-modules-jsi": ["expo-modules-jsi@56.0.10", "", { "peerDependencies": { "react-native": "*" } }, "sha512-fHZcFpYO/o62GYa6fJyAQJZcAShzhoN0iMMDzbr7vD3ewET6e1vAlTonbEakN9F0VHEgBFJ4NREy87uwVcpCuA=="],
- "expo-splash-screen": ["expo-splash-screen@31.0.10", "", { "dependencies": { "@expo/prebuild-config": "^54.0.3" }, "peerDependencies": { "expo": "*" } }, "sha512-i6g9IK798mae4yvflstQ1HkgahIJ6exzTCTw4vEdxV0J2SwiW3Tj+CwRjf0te7Zsb+7dDQhBTmGZwdv00VER2A=="],
+ "expo-notifications": ["expo-notifications@56.0.18", "", { "dependencies": { "@expo/image-utils": "^0.10.1", "abort-controller": "^3.0.0", "badgin": "^1.1.5", "expo-application": "~56.0.3", "expo-constants": "~56.0.18" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-HHnrwyCLC5srFojcHYS2KskbNroy9o2fwPKdyhjrdjjrBu4sNRKm4LepcuZjDy98cZKEm89WIPW8O45vut8Rgw=="],
- "expo-status-bar": ["expo-status-bar@3.0.8", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-L248XKPhum7tvREoS1VfE0H6dPCaGtoUWzRsUv7hGKdiB4cus33Rc0sxkWkoQ77wE8stlnUlL5lvmT0oqZ3ZBw=="],
+ "expo-router": ["expo-router@56.2.11", "", { "dependencies": { "@expo/log-box": "^56.0.13", "@expo/metro-runtime": "^56.0.15", "@expo/schema-utils": "^56.0.0", "@expo/ui": "^56.0.18", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "client-only": "^0.0.1", "color": "^4.2.3", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-glass-effect": "^56.0.4", "expo-server": "^56.0.5", "expo-symbols": "^56.0.6", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-is": "^19.1.0", "react-native-drawer-layout": "^4.2.2", "react-native-screens": "^4.25.2", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "standard-navigation": "^0.0.5", "vaul": "^1.1.2" }, "peerDependencies": { "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^56.0.18", "expo-linking": "^56.0.14", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-web": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-08DBTrKv3QanOc9u1JNxSEChW9c/qNFbQ0dO28OLvufWWfdSRkSdHmh365D2FgoZg1qaOzZPCDuL3tM6nGSfkQ=="],
- "expo-system-ui": ["expo-system-ui@6.0.8", "", { "dependencies": { "@react-native/normalize-colors": "0.81.5", "debug": "^4.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-DzJYqG2fibBSLzPDL4BybGCiilYOtnI1OWhcYFwoM4k0pnEzMBt1Vj8Z67bXglDDuz2HCQPGNtB3tQft5saKqQ=="],
+ "expo-screen-orientation": ["expo-screen-orientation@56.0.5", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-Puf4L/cgM8z45Z2fwZzJtlVGSk0ZM/l3gBqXm50bKTACmUk8P8fr7HVbDfs8reyoZuEKKFZJ0VlnKo5i6cSotQ=="],
- "expo-task-manager": ["expo-task-manager@14.0.8", "", { "dependencies": { "unimodules-app-loader": "~6.0.7" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-HxhyvmulM8px+LQvqIKS85KVx2UodZf5RO+FE2ltpC4mQ5IFkX/ESqiK0grzDa4pVFLyxvs8LjuUKsfB5c39PQ=="],
+ "expo-secure-store": ["expo-secure-store@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-hjEi/gmpdFFJ9lYbdp3k3p/WchV7Gi0Qt8jt/m/0WJadqQrskafHAlDxbZkII1cN3Yd7zp9Lvkeq3UfGhSwirQ=="],
- "expo-updates-interface": ["expo-updates-interface@2.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg=="],
+ "expo-server": ["expo-server@56.0.5", "", {}, "sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ=="],
- "expo-web-browser": ["expo-web-browser@15.0.9", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-Dj8kNFO+oXsxqCDNlUT/GhOrJnm10kAElH++3RplLydogFm5jTzXYWDEeNIDmV+F+BzGYs+sIhxiBf7RyaxXZg=="],
+ "expo-sharing": ["expo-sharing@56.0.18", "", { "dependencies": { "@expo/config-plugins": "^56.0.9", "@expo/config-types": "^56.0.6", "@expo/plist": "^0.7.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-45w4BWNFmdTczp+fJX6YfwJrn9sX+VeRWz2VWLhauygcCrym44HtVDXX5yVYPB9TW9ZesLcEI+CCrCBNWL7smQ=="],
+
+ "expo-splash-screen": ["expo-splash-screen@56.0.10", "", { "dependencies": { "@expo/config-plugins": "~56.0.8", "@expo/image-utils": "^0.10.1", "xml2js": "0.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-vDIlo8hzt9HlCZQ0kSY66v83D1WEXOJbVMeyPDfXDu9tbDdPMNUyDpi4WGJXikAjxnAKfbt5Mv5NnEbxINy+VA=="],
+
+ "expo-status-bar": ["expo-status-bar@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-IGs/fDfkHXofy2ZQrGiXayhFK04HB85FZXorhcEhDZEcqASKgSqpak+HwUtAaR0MeTJwWyHNF7I6VmVbbp8EcA=="],
+
+ "expo-symbols": ["expo-symbols@56.0.6", "", { "dependencies": { "@expo-google-fonts/material-symbols": "^0.4.1", "sf-symbols-typescript": "^2.0.0" }, "peerDependencies": { "expo": "*", "expo-font": "*", "react": "*", "react-native": "*" } }, "sha512-BrA81DjcNafdj7gXVhdrExb9LtUiSVyOf/NavyMmDAHgHMY1GqeR5cnn1PSAZeYKnSgQhee/H89XUpAxtog5hg=="],
+
+ "expo-system-ui": ["expo-system-ui@56.0.5", "", { "dependencies": { "@react-native/normalize-colors": "0.85.3", "debug": "^4.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-n1MmnUArV4cc3gVed9fGtluPme00PE9axKVx+NHbKxHFMam5l4GcOI7PxbYKFNx8o7WA1LRD7eLW33agmZrxGg=="],
+
+ "expo-task-manager": ["expo-task-manager@56.0.19", "", { "dependencies": { "unimodules-app-loader": "~56.0.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-eDeBR8vXlOAXBck29AzPl+tv4QlJus+cgkQ0TGOBqAlgRPWPWd7ebhqrhb0KRieUZIvzlycfcxjHR/+AiEiMHA=="],
+
+ "expo-updates-interface": ["expo-updates-interface@56.0.2", "", { "peerDependencies": { "expo": "*" } }, "sha512-eWTwSZ9y8vrULG2oBn2TQSSIwBGSq/TxGJ3jY6tuVS2FWH/ASRIiKs3zkUZTRoC3ZuV2alz0mUClYV7nNrFx8g=="],
+
+ "expo-web-browser": ["expo-web-browser@56.0.5", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-kaN+wcR5lHwPCH1IgrU1XyPUQvBRzdF1TMp65uAF9iUCyipqYnmrvV87eqAmrdkFFopWVgU7FcxPu1UZw+gvUQ=="],
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
@@ -1057,13 +1081,13 @@
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
- "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
+ "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="],
- "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
+ "fast-xml-parser": ["fast-xml-parser@5.9.0", "", { "dependencies": { "@nodable/entities": "^2.2.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^1.0.1", "path-expression-matcher": "^1.5.0", "strnum": "^2.4.0", "xml-naming": "^0.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-duBuXbyIhEeNO4GjFuVqr0nF047oNwr18aum+zJyqo0MUG/n7Afgs3Qv3D6VN3ONedUKxiuFlPiMGIa0Z11chA=="],
- "fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="],
+ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
- "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
+ "fb-dotslash": ["fb-dotslash@0.5.8", "", { "bin": { "dotslash": "bin/dotslash" } }, "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA=="],
"fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="],
@@ -1071,6 +1095,10 @@
"fbjs-css-vars": ["fbjs-css-vars@1.0.2", "", {}, "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ=="],
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+
+ "fetch-nodeshim": ["fetch-nodeshim@0.4.10", "", {}, "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w=="],
+
"file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -1085,42 +1113,32 @@
"flow-enums-runtime": ["flow-enums-runtime@0.0.6", "", {}, "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw=="],
- "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
+ "follow-redirects": ["follow-redirects@1.16.0", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
"fontfaceobserver": ["fontfaceobserver@2.3.0", "", {}, "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg=="],
- "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
-
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
- "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="],
-
- "freeport-async": ["freeport-async@2.0.0", "", {}, "sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ=="],
+ "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
"fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
- "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
-
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
- "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
-
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
- "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
+ "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
- "get-package-type": ["get-package-type@0.1.0", "", {}, "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q=="],
-
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
@@ -1129,12 +1147,10 @@
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
- "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
+ "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
- "global-dirs": ["global-dirs@0.1.1", "", { "dependencies": { "ini": "^1.3.4" } }, "sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg=="],
-
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
@@ -1147,11 +1163,13 @@
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
- "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
+ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
- "hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="],
+ "hermes-compiler": ["hermes-compiler@250829098.0.10", "", {}, "sha512-TcRlZ0/TlyfJqquRFAWoyElVNnkdYRi/sEp4/Qy8/GYxjg8j2cS9D4MjuaQ+qimkmLN7AmO+44IznRf06mAr0w=="],
- "hermes-parser": ["hermes-parser@0.29.1", "", { "dependencies": { "hermes-estree": "0.29.1" } }, "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA=="],
+ "hermes-estree": ["hermes-estree@0.33.3", "", {}, "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg=="],
+
+ "hermes-parser": ["hermes-parser@0.33.3", "", { "dependencies": { "hermes-estree": "0.33.3" } }, "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA=="],
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
@@ -1159,9 +1177,9 @@
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
- "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
+ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
- "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
+ "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
@@ -1169,9 +1187,9 @@
"hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="],
- "i18next": ["i18next@25.6.1", "", { "dependencies": { "@babel/runtime": "^7.27.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-yUWvdXtalZztmKrKw3yz/AvSP3yKyqIkVPx/wyvoYy9lkLmwzItLxp0iHZLG5hfVQ539Jor4XLO+U+NHIXg7pw=="],
+ "i18next": ["i18next@26.3.1", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ=="],
- "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
+ "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -1183,27 +1201,19 @@
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
- "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
-
- "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
+ "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
- "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
-
"inline-style-prefixer": ["inline-style-prefixer@7.0.1", "", { "dependencies": { "css-in-js-utils": "^3.1.0" } }, "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw=="],
"invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="],
- "is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="],
-
"is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
- "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
-
- "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
+ "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
@@ -1211,24 +1221,18 @@
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
- "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
-
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="],
- "is-nan": ["is-nan@1.3.2", "", { "dependencies": { "call-bind": "^1.0.0", "define-properties": "^1.1.3" } }, "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w=="],
-
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
- "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
-
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
- "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
-
"is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="],
+ "is-unsafe": ["is-unsafe@1.0.1", "", {}, "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA=="],
+
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
@@ -1237,28 +1241,16 @@
"isomorphic-fetch": ["isomorphic-fetch@3.0.0", "", { "dependencies": { "node-fetch": "^2.6.1", "whatwg-fetch": "^3.4.1" } }, "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA=="],
- "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
-
- "istanbul-lib-instrument": ["istanbul-lib-instrument@5.2.1", "", { "dependencies": { "@babel/core": "^7.12.3", "@babel/parser": "^7.14.7", "@istanbuljs/schema": "^0.1.2", "istanbul-lib-coverage": "^3.2.0", "semver": "^6.3.0" } }, "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg=="],
-
- "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
+ "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="],
"jest-diff": ["jest-diff@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw=="],
- "jest-environment-node": ["jest-environment-node@29.7.0", "", { "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw=="],
-
"jest-get-type": ["jest-get-type@29.6.3", "", {}, "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw=="],
- "jest-haste-map": ["jest-haste-map@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/graceful-fs": "^4.1.3", "@types/node": "*", "anymatch": "^3.0.3", "fb-watchman": "^2.0.0", "graceful-fs": "^4.2.9", "jest-regex-util": "^29.6.3", "jest-util": "^29.7.0", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "walker": "^1.0.8" }, "optionalDependencies": { "fsevents": "^2.3.2" } }, "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA=="],
-
"jest-matcher-utils": ["jest-matcher-utils@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "jest-diff": "^29.7.0", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g=="],
"jest-message-util": ["jest-message-util@29.7.0", "", { "dependencies": { "@babel/code-frame": "^7.12.13", "@jest/types": "^29.6.3", "@types/stack-utils": "^2.0.0", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "micromatch": "^4.0.4", "pretty-format": "^29.7.0", "slash": "^3.0.0", "stack-utils": "^2.0.3" } }, "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w=="],
- "jest-mock": ["jest-mock@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "jest-util": "^29.7.0" } }, "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw=="],
-
- "jest-regex-util": ["jest-regex-util@29.6.3", "", {}, "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg=="],
-
"jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="],
"jest-validate": ["jest-validate@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", "chalk": "^4.0.0", "jest-get-type": "^29.6.3", "leven": "^3.1.0", "pretty-format": "^29.7.0" } }, "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw=="],
@@ -1269,15 +1261,15 @@
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
- "joi": ["joi@17.13.3", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA=="],
+ "joi": ["joi@17.13.4", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ=="],
- "jotai": ["jotai@2.15.1", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-yHT1HAZ3ba2Q8wgaUQ+xfBzEtcS8ie687I8XVCBinfg4bNniyqLIN+utPXWKQE93LMF5fPbQSVRZqgpcN5yd6Q=="],
+ "jotai": ["jotai@2.20.0", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-b5GAqgmXmXzB4WPaTH26ppk9Sl7AA9WSQX7yfdM+gJ1rFROiWcVbi97gFuN/yVCojOcbcvop2sfLL+fjxW0JVg=="],
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
- "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
+ "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="],
"jsc-safe-url": ["jsc-safe-url@0.2.4", "", {}, "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q=="],
@@ -1285,8 +1277,6 @@
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
- "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
-
"json-stable-stringify": ["json-stable-stringify@1.3.0", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "isarray": "^2.0.5", "jsonify": "^0.0.1", "object-keys": "^1.1.1" } }, "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
@@ -1299,49 +1289,49 @@
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
- "lan-network": ["lan-network@0.1.7", "", { "bin": { "lan-network": "dist/lan-network-cli.js" } }, "sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ=="],
+ "lan-network": ["lan-network@0.2.1", "", { "bin": { "lan-network": "dist/lan-network-cli.js" } }, "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A=="],
- "launch-editor": ["launch-editor@2.12.0", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.3" } }, "sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg=="],
+ "launch-editor": ["launch-editor@2.14.1", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.4" } }, "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA=="],
"leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
"lighthouse-logger": ["lighthouse-logger@1.4.2", "", { "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" } }, "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g=="],
- "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
+ "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
- "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
- "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
- "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
- "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
- "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
- "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
- "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
- "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
- "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
- "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
- "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
- "lint-staged": ["lint-staged@16.2.6", "", { "dependencies": { "commander": "^14.0.1", "listr2": "^9.0.5", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-s1gphtDbV4bmW1eylXpVMk2u7is7YsrLl8hzrtvC70h4ByhcMLZFY01Fx05ZUDNuv1H8HO4E+e2zgejV1jVwNw=="],
+ "lint-staged": ["lint-staged@17.0.8", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA=="],
- "listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="],
+ "listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
- "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
+ "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
@@ -1357,6 +1347,8 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
+ "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
+
"makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="],
"marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="],
@@ -1365,7 +1357,7 @@
"mdn-data": ["mdn-data@2.0.14", "", {}, "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow=="],
- "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
+ "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
@@ -1373,33 +1365,33 @@
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
- "metro": ["metro@0.83.2", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.32.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.83.2", "metro-cache": "0.83.2", "metro-cache-key": "0.83.2", "metro-config": "0.83.2", "metro-core": "0.83.2", "metro-file-map": "0.83.2", "metro-resolver": "0.83.2", "metro-runtime": "0.83.2", "metro-source-map": "0.83.2", "metro-symbolicate": "0.83.2", "metro-transform-plugins": "0.83.2", "metro-transform-worker": "0.83.2", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-HQgs9H1FyVbRptNSMy/ImchTTE5vS2MSqLoOo7hbDoBq6hPPZokwJvBMwrYSxdjQZmLXz2JFZtdvS+ZfgTc9yw=="],
+ "metro": ["metro@0.84.4", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "accepts": "^2.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.35.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.84.4", "metro-cache": "0.84.4", "metro-cache-key": "0.84.4", "metro-config": "0.84.4", "metro-core": "0.84.4", "metro-file-map": "0.84.4", "metro-resolver": "0.84.4", "metro-runtime": "0.84.4", "metro-source-map": "0.84.4", "metro-symbolicate": "0.84.4", "metro-transform-plugins": "0.84.4", "metro-transform-worker": "0.84.4", "mime-types": "^3.0.1", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA=="],
- "metro-babel-transformer": ["metro-babel-transformer@0.83.2", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.32.0", "nullthrows": "^1.1.1" } }, "sha512-rirY1QMFlA1uxH3ZiNauBninwTioOgwChnRdDcbB4tgRZ+bGX9DiXoh9QdpppiaVKXdJsII932OwWXGGV4+Nlw=="],
+ "metro-babel-transformer": ["metro-babel-transformer@0.84.4", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.35.0", "metro-cache-key": "0.84.4", "nullthrows": "^1.1.1" } }, "sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g=="],
- "metro-cache": ["metro-cache@0.83.2", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.2" } }, "sha512-Z43IodutUZeIS7OTH+yQFjc59QlFJ6s5OvM8p2AP9alr0+F8UKr8ADzFzoGKoHefZSKGa4bJx7MZJLF6GwPDHQ=="],
+ "metro-cache": ["metro-cache@0.84.4", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.84.4" } }, "sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg=="],
- "metro-cache-key": ["metro-cache-key@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-3EMG/GkGKYoTaf5RqguGLSWRqGTwO7NQ0qXKmNBjr0y6qD9s3VBXYlwB+MszGtmOKsqE9q3FPrE5Nd9Ipv7rZw=="],
+ "metro-cache-key": ["metro-cache-key@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw=="],
- "metro-config": ["metro-config@0.83.2", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.83.2", "metro-cache": "0.83.2", "metro-core": "0.83.2", "metro-runtime": "0.83.2", "yaml": "^2.6.1" } }, "sha512-1FjCcdBe3e3D08gSSiU9u3Vtxd7alGH3x/DNFqWDFf5NouX4kLgbVloDDClr1UrLz62c0fHh2Vfr9ecmrOZp+g=="],
+ "metro-config": ["metro-config@0.84.4", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.84.4", "metro-cache": "0.84.4", "metro-core": "0.84.4", "metro-runtime": "0.84.4", "yaml": "^2.6.1" } }, "sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q=="],
- "metro-core": ["metro-core@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.83.2" } }, "sha512-8DRb0O82Br0IW77cNgKMLYWUkx48lWxUkvNUxVISyMkcNwE/9ywf1MYQUE88HaKwSrqne6kFgCSA/UWZoUT0Iw=="],
+ "metro-core": ["metro-core@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.84.4" } }, "sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ=="],
- "metro-file-map": ["metro-file-map@0.83.2", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-cMSWnEqZrp/dzZIEd7DEDdk72PXz6w5NOKriJoDN9p1TDQ5nAYrY2lHi8d6mwbcGLoSlWmpPyny9HZYFfPWcGQ=="],
+ "metro-file-map": ["metro-file-map@0.84.4", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ=="],
- "metro-minify-terser": ["metro-minify-terser@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-zvIxnh7U0JQ7vT4quasKsijId3dOAWgq+ip2jF/8TMrPUqQabGrs04L2dd0haQJ+PA+d4VvK/bPOY8X/vL2PWw=="],
+ "metro-minify-terser": ["metro-minify-terser@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ=="],
- "metro-resolver": ["metro-resolver@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Yf5mjyuiRE/Y+KvqfsZxrbHDA15NZxyfg8pIk0qg47LfAJhpMVEX+36e6ZRBq7KVBqy6VDX5Sq55iHGM4xSm7Q=="],
+ "metro-resolver": ["metro-resolver@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A=="],
- "metro-runtime": ["metro-runtime@0.83.3", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw=="],
+ "metro-runtime": ["metro-runtime@0.84.4", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q=="],
- "metro-source-map": ["metro-source-map@0.83.3", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.3", "nullthrows": "^1.1.1", "ob1": "0.83.3", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-xkC3qwUBh2psVZgVavo8+r2C9Igkk3DibiOXSAht1aYRRcztEZNFtAMtfSB7sdO2iFMx2Mlyu++cBxz/fhdzQg=="],
+ "metro-source-map": ["metro-source-map@0.84.4", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.84.4", "nullthrows": "^1.1.1", "ob1": "0.84.4", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g=="],
- "metro-symbolicate": ["metro-symbolicate@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.3", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-F/YChgKd6KbFK3eUR5HdUsfBqVsanf5lNTwFd4Ca7uuxnHgBC3kR/Hba/RGkenR3pZaGNp5Bu9ZqqP52Wyhomw=="],
+ "metro-symbolicate": ["metro-symbolicate@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.84.4", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA=="],
- "metro-transform-plugins": ["metro-transform-plugins@0.83.2", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-5WlW25WKPkiJk2yA9d8bMuZrgW7vfA4f4MBb9ZeHbTB3eIAoNN8vS8NENgG/X/90vpTB06X66OBvxhT3nHwP6A=="],
+ "metro-transform-plugins": ["metro-transform-plugins@0.84.4", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow=="],
- "metro-transform-worker": ["metro-transform-worker@0.83.2", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "metro": "0.83.2", "metro-babel-transformer": "0.83.2", "metro-cache": "0.83.2", "metro-cache-key": "0.83.2", "metro-minify-terser": "0.83.2", "metro-source-map": "0.83.2", "metro-transform-plugins": "0.83.2", "nullthrows": "^1.1.1" } }, "sha512-G5DsIg+cMZ2KNfrdLnWMvtppb3+Rp1GMyj7Bvd9GgYc/8gRmvq1XVEF9XuO87Shhb03kFhGqMTgZerz3hZ1v4Q=="],
+ "metro-transform-worker": ["metro-transform-worker@0.84.4", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "metro": "0.84.4", "metro-babel-transformer": "0.84.4", "metro-cache": "0.84.4", "metro-cache-key": "0.84.4", "metro-minify-terser": "0.84.4", "metro-source-map": "0.84.4", "metro-transform-plugins": "0.84.4", "nullthrows": "^1.1.1" } }, "sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
@@ -1413,43 +1405,41 @@
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
- "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
+ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
+
+ "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
- "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
-
- "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
+ "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+ "multitars": ["multitars@1.0.0", "", {}, "sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg=="],
+
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
- "nano-spawn": ["nano-spawn@2.0.0", "", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="],
-
- "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+ "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"nativewind": ["nativewind@2.0.11", "", { "dependencies": { "@babel/generator": "^7.18.7", "@babel/helper-module-imports": "7.18.6", "@babel/types": "7.19.0", "css-mediaquery": "^0.1.2", "css-to-react-native": "^3.0.0", "micromatch": "^4.0.5", "postcss": "^8.4.12", "postcss-calc": "^8.2.4", "postcss-color-functional-notation": "^4.2.2", "postcss-css-variables": "^0.18.0", "postcss-nested": "^5.0.6", "react-is": "^18.1.0", "use-sync-external-store": "^1.1.0" }, "peerDependencies": { "tailwindcss": "~3" } }, "sha512-qCEXUwKW21RYJ33KRAJl3zXq2bCq82WoI564fI21D/TiqhfmstZOqPN53RF8qK1NDK6PGl56b2xaTxgObEePEg=="],
"negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="],
- "nested-error-stacks": ["nested-error-stacks@2.0.1", "", {}, "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A=="],
-
"nocache": ["nocache@3.0.4", "", {}, "sha512-WDD0bdg9mbq6F4mRxEYcPWwfA1vxd0mrvKOyxI7Xj/atfRHVeutzuWByG//jfm4uPzp0y4Kj051EORCBSQMycw=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
- "node-forge": ["node-forge@1.3.1", "", {}, "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA=="],
+ "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="],
"node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="],
- "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
+ "node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="],
"node-stream-zip": ["node-stream-zip@1.15.0", "", {}, "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw=="],
- "node-vibrant": ["node-vibrant@4.0.3", "", { "dependencies": { "@types/node": "^18.15.3", "@vibrant/core": "^4.0.0", "@vibrant/generator-default": "^4.0.3", "@vibrant/image-browser": "^4.0.0", "@vibrant/image-node": "^4.0.0", "@vibrant/quantizer-mmcq": "^4.0.0" } }, "sha512-kzoIuJK90BH/k65Avt077JCX4Nhqz1LNc8cIOm2rnYEvFdJIYd8b3SQwU1MTpzcHtr8z8jxkl1qdaCfbP3olFg=="],
+ "node-vibrant": ["node-vibrant@4.0.4", "", { "dependencies": { "@types/node": "^18.15.3", "@vibrant/core": "^4.0.4", "@vibrant/generator-default": "^4.0.4", "@vibrant/image-browser": "^4.0.4", "@vibrant/image-node": "^4.0.4", "@vibrant/quantizer-mmcq": "^4.0.4" } }, "sha512-hA/pUXBE9TJ41G9FlTkzeqD5JdxgvvPGYZb/HNpdkaxxXUEnP36imSolZ644JuPun+lTd+FpWWtBpTYdp2noQA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
@@ -1461,7 +1451,7 @@
"nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="],
- "ob1": ["ob1@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA=="],
+ "ob1": ["ob1@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -1469,20 +1459,14 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
- "object-is": ["object-is@1.1.6", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1" } }, "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q=="],
-
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
- "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="],
-
"omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="],
- "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
-
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
@@ -1511,21 +1495,19 @@
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
- "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
+ "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
- "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
+ "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
"peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
- "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
-
- "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="],
+ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
@@ -1533,13 +1515,11 @@
"pixelmatch": ["pixelmatch@4.0.2", "", { "dependencies": { "pngjs": "^3.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-J8B6xqiO37sU/gkcMglv6h5Jbd9xNER7aHzpfRdNmV4IbQBzBpe4l9XmbG+xPF/znacgu2jfEw+wHffaq/YkXA=="],
- "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
+ "plist": ["plist@3.1.1", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA=="],
- "pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="],
+ "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
- "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
-
- "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
+ "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
"postcss-calc": ["postcss-calc@8.2.4", "", { "dependencies": { "postcss-selector-parser": "^6.0.9", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.2.2" } }, "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q=="],
@@ -1555,12 +1535,10 @@
"postcss-nested": ["postcss-nested@5.0.6", "", { "dependencies": { "postcss-selector-parser": "^6.0.6" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA=="],
- "postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
+ "postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
- "pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="],
-
"pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
"proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="],
@@ -1575,13 +1553,13 @@
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
- "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
+ "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
- "qrcode-terminal": ["qrcode-terminal@0.11.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ=="],
+ "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
- "qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="],
+ "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
"query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
@@ -1591,29 +1569,27 @@
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
- "raw-body": ["raw-body@2.5.2", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "unpipe": "1.0.0" } }, "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA=="],
+ "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
- "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
-
- "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
+ "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
"react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="],
- "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
+ "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
"react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="],
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
- "react-i18next": ["react-i18next@16.0.0", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 25.5.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-JQ+dFfLnFSKJQt7W01lJHWRC0SX7eDPobI+MSTJ3/gP39xH2g33AuTE7iddAfXYHamJdAeMGM0VFboPaD3G68Q=="],
+ "react-i18next": ["react-i18next@17.0.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5 || ^6" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw=="],
- "react-is": ["react-is@19.2.0", "", {}, "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA=="],
+ "react-is": ["react-is@19.2.7", "", {}, "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A=="],
- "react-native": ["react-native-tvos@0.81.5-1", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native-tvos/virtualized-lists": "0.81.5-1", "@react-native/assets-registry": "0.81.5", "@react-native/codegen": "0.81.5", "@react-native/community-cli-plugin": "0.81.5", "@react-native/gradle-plugin": "0.81.5", "@react-native/js-polyfills": "0.81.5", "@react-native/normalize-colors": "0.81.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.29.1", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.1", "metro-source-map": "^0.83.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.26.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "^19.1.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-jEZ5S8Urjaxkb/pQsfxXslTtKGfeBdaXwEObTyAF3PvCT0wYKD4NbftVJC5Iid9/jKeoBfWTuAOTFfaivqx7IA=="],
+ "react-native": ["react-native-tvos@0.85.3-0", "", { "dependencies": { "@react-native-tvos/virtualized-lists": "0.85.3-0", "@react-native/assets-registry": "0.85.3", "@react-native/codegen": "0.85.3", "@react-native/community-cli-plugin": "0.85.3", "@react-native/gradle-plugin": "0.85.3", "@react-native/js-polyfills": "0.85.3", "@react-native/normalize-colors": "0.85.3", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-plugin-syntax-hermes-parser": "0.33.3", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "hermes-compiler": "250829098.0.10", "invariant": "^2.2.4", "memoize-one": "^5.0.0", "metro-runtime": "^0.84.3", "metro-source-map": "^0.84.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "tinyglobby": "^0.2.15", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@react-native/jest-preset": "0.85.3", "@types/react": "^19.1.1", "react": "^19.2.3" }, "optionalPeers": ["@react-native/jest-preset", "@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-Q9gUndppXbGEiYlQ8eudkdH7rDXdY+KM74Btd5xqMvXHgo7ZXdwI1hKvStmI47KmTaDn0NOmcRl2yBwHfc5+5A=="],
"react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="],
- "react-native-bottom-tabs": ["react-native-bottom-tabs@1.0.2", "", { "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-eWNuTpJVefKRaROda4ZeWHvW1cUEb0mw8L7FyLEcPPsd7Tp3rfLRrhptl/O/3mAki9gvpzYE8ASE3GwUrjfp+Q=="],
+ "react-native-bottom-tabs": ["react-native-bottom-tabs@1.2.0", "", { "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ScVPko86ts+m6JMNtI24MCSYJCOZc1aZkn9qwS9ly3o0ubajRWDpCzgRJfRFysi08bKrcqAXKVCHZNHvNb2PTA=="],
"react-native-circular-progress": ["react-native-circular-progress@1.4.1", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=16.0.0", "react-native": ">=0.50.0", "react-native-svg": ">=7.0.0" } }, "sha512-HEzvI0WPuWvsCgWE3Ff2HBTMgAEQB2GvTFw0KHyD/t1STAlDDRiolu0mEGhVvihKR3jJu3v3V4qzvSklY/7XzQ=="],
@@ -1621,63 +1597,73 @@
"react-native-country-flag": ["react-native-country-flag@2.0.2", "", {}, "sha512-5LMWxS79ZQ0Q9ntYgDYzWp794+HcQGXQmzzZNBR1AT7z5HcJHtX7rlk8RHi7RVzfp5gW6plWSZ4dKjRpu/OafQ=="],
- "react-native-device-info": ["react-native-device-info@15.0.1", "", { "peerDependencies": { "react-native": "*" } }, "sha512-U5waZRXtT3l1SgZpZMlIvMKPTkFZPH8W7Ks6GrJhdH723aUIPxjVer7cRSij1mvQdOAAYFJV/9BDzlC8apG89A=="],
+ "react-native-device-info": ["react-native-device-info@15.0.2", "", { "peerDependencies": { "react-native": "*" } }, "sha512-dd71eXG2l3Cwp66IvKNadMTB8fhU3PEjyVddI97sYan+D4bgIAUmgGDhbSOFvHcGavksb2U17kiQYaDiK2WK2g=="],
- "react-native-edge-to-edge": ["react-native-edge-to-edge@1.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ERegbsq28yoMndn/Uq49i4h6aAhMvTEjOfkFh50yX9H/dMjjCr/Tix/es/9JcPRvC+q7VzCMWfxWDUb6Jrq1OQ=="],
+ "react-native-draggable-flatlist": ["react-native-draggable-flatlist@4.0.3", "", { "dependencies": { "@babel/preset-typescript": "^7.17.12" }, "peerDependencies": { "react-native": ">=0.64.0", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=2.8.0" } }, "sha512-2F4x5BFieWdGq9SetD2nSAR7s7oQCSgNllYgERRXXtNfSOuAGAVbDb/3H3lP0y5f7rEyNwabKorZAD/SyyNbDw=="],
- "react-native-gesture-handler": ["react-native-gesture-handler@2.28.0", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A=="],
+ "react-native-drawer-layout": ["react-native-drawer-layout@4.2.5", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-gesture-handler": ">= 2.0.0", "react-native-reanimated": ">= 2.0.0" } }, "sha512-Yl82uLkXjXuq7222hWGIDsq5A6R/bsCeCEgdIxQUxAEHf00oRdDnRByLx3Fsij3qwtmYNPGrHV1NH8G8hbCbLQ=="],
+
+ "react-native-edge-to-edge": ["react-native-edge-to-edge@1.8.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-bhvsKqeX9PGkY9wBUk9vni/tJNJdKtLPbs/j3e/3CdV4JmUWfTXYYoL+4Hc8Wmej+5eJxkc8KOFa454ruFWBCA=="],
+
+ "react-native-gesture-handler": ["react-native-gesture-handler@2.31.2", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "@types/react-test-renderer": "^19.1.0", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-rw5q74i2AfS7YGYdbxQDhOU7xqgY6WRM1132/CCm3erqjblhECZDZFHIm0tteHoC9ih24wogVBVVzcTBQtZ+5A=="],
+
+ "react-native-glass-effect-view": ["react-native-glass-effect-view@1.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ABYG0oIiqbXsxe2R/cMhNgDn3YgwDLz/2TIN2XOxQopXC+MiGsG9C32VYQvO2sYehcu5JmI3h3EzwLwl6lJhhA=="],
"react-native-google-cast": ["react-native-google-cast@4.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/HvIKAaWHtG6aTNCxrNrqA2ftWGkfH0M/2iN+28pdGUXpKmueb33mgL1m8D4zzwEODQMcmpfoCsym1IwDvugBQ=="],
- "react-native-image-colors": ["react-native-image-colors@2.5.0", "", { "dependencies": { "node-vibrant": "^4.0.3" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-3zSDgNj5HaZ0PDWaXkc4BpWpZRM5N4gBsoPC7DBfM/+op69Yvwbc0S1T7CnxBWbvShtOvRE+b2BUBadVn+6z/g=="],
+ "react-native-image-colors": ["react-native-image-colors@2.6.0", "", { "dependencies": { "node-vibrant": "^4.0.3" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-MbBPmRpp2yy8h5W7KUreByP96pey0J9habHaRSN/67O0hlR/5Izpt370BNHQVQogfHrRXfV4d8n6ZLn/2ga7Bg=="],
"react-native-ios-context-menu": ["react-native-ios-context-menu@3.2.1", "", { "dependencies": { "@dominicstop/ts-event-emitter": "^1.1.0" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-ios-utilities": "*" } }, "sha512-OBQbb3I/VUx2wQgz4cqN614kt3nJ+qx5wxEdtGN1Aj4nYYL1orp7VLFkV6axof6DgOyv0YD6af2RUTok6a2xDQ=="],
"react-native-ios-utilities": ["react-native-ios-utilities@5.2.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-RTw1Gk8rQhBL43+U80I+Nu8T7mLTNkj5RaG8vTs3ETEDqphS3L0Mrzk79RX0Jmm64HMad70GXHctXFlW1n0V8w=="],
- "react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="],
+ "react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.3.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA=="],
- "react-native-mmkv": ["react-native-mmkv@4.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-Osoy8as2ZLzO1TTsKxc4tX14Qk19qRVMWnS4ZVBwxie9Re5cjt7rqlpDkJczK3H/y3z70EQ6rmKI/cNMCLGAYQ=="],
+ "react-native-mmkv": ["react-native-mmkv@4.1.1", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-nYFjM27l7zVhIiyAqWEFRagGASecb13JMIlzAuOeakRRz9GMJ49hCQntUBE2aeuZRE4u4ehSqTOomB0mTF56Ew=="],
- "react-native-nitro-modules": ["react-native-nitro-modules@0.31.5", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-h/IbVsK5IH7JkvseihAoz/o5dy6CafvGo7j4jTvAa+gnxZWFtXQZg8EDvu0en88LFAumKd/pcF20dzxMiNOmug=="],
+ "react-native-nitro-modules": ["react-native-nitro-modules@0.33.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-Kdo8qiqlkGAEs7fq29i0yiZs0Gf7ucmMiFsH8PH4uzsnSGEt2CQRBJGnQKKMl9vJYL8e7rzA0TZKRwO/L8G/Sg=="],
- "react-native-pager-view": ["react-native-pager-view@6.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-uUT0MMMbNtoSbxe9pRvdJJKEi9snjuJ3fXlZhG8F2vVMOBJVt/AFtqMPUHu9yMflmqOr08PewKzj9EPl/Yj+Gw=="],
+ "react-native-pager-view": ["react-native-pager-view@8.0.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-pGOne2o0y0HOQLrlTLcGgOE48uJlqSZHRRwdW8nL6JJozMkPGJYi/G9e0EsJoWFpXYONjiDgr8IwxC4F6/r7Lg=="],
- "react-native-reanimated": ["react-native-reanimated@4.1.3", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", "semver": "7.7.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0", "react": "*", "react-native": "*", "react-native-worklets": ">=0.5.0" } }, "sha512-GP8wsi1u3nqvC1fMab/m8gfFwFyldawElCcUSBJQgfrXeLmsPPUOpDw44lbLeCpcwUuLa05WTVePdTEwCLTUZg=="],
+ "react-native-qrcode-svg": ["react-native-qrcode-svg@6.3.21", "", { "dependencies": { "prop-types": "^15.8.0", "qrcode": "^1.5.4", "text-encoding": "^0.7.0" }, "peerDependencies": { "react": "*", "react-native": ">=0.63.4", "react-native-svg": ">=14.0.0" } }, "sha512-6vcj4rcdpWedvphDR+NSJcudJykNuLgNGFwm2p4xYjR8RdyTzlrELKI5LkO4ANS9cQUbqsfkpippPv64Q2tUtA=="],
- "react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.2", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-vNpCfPlFoOVKHd+oB7B0luoJswp+nyz0NdJD8+LCrf25JiNQXfM22RSJhLaksBHqk3fm8R4fKWPNcfy5w7wL1Q=="],
+ "react-native-reanimated": ["react-native-reanimated@4.3.1", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" }, "peerDependencies": { "react": "*", "react-native": "0.81 - 0.85", "react-native-worklets": "0.8.x" } }, "sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q=="],
- "react-native-safe-area-context": ["react-native-safe-area-context@5.6.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg=="],
+ "react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.3", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-YZXlvZNghR5shFcI9hTA7h7bEhh97pfUSLZvLBAshpbkuYwJDKmQXejO/199T6hqGq0wCRwR0CWf2P4Vs6A4Fw=="],
- "react-native-screens": ["react-native-screens@4.18.0", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-mRTLWL7Uc1p/RFNveEIIrhP22oxHduC2ZnLr/2iHwBeYpGXR0rJZ7Bgc0ktxQSHRjWTPT70qc/7yd4r9960PBQ=="],
+ "react-native-safe-area-context": ["react-native-safe-area-context@5.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ=="],
- "react-native-svg": ["react-native-svg@15.12.1", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g=="],
+ "react-native-screens": ["react-native-screens@4.25.2", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": ">=0.82.0" } }, "sha512-1Nj1fusFd+rIMKU/qC9yGKVG+3ofh11d3OdBQKL1iVvQfKvcB8vhvTGQf2TkfxW3bamxN+hCZIXmNuU0mRkyDg=="],
- "react-native-tab-view": ["react-native-tab-view@4.2.0", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-TUbh7Yr0tE/99t1pJQLbQ+4/Px67xkT7/r3AhfV+93Q3WoUira0Lx7yuKUP2C118doqxub8NCLERwcqsHr29nQ=="],
+ "react-native-svg": ["react-native-svg@15.15.4", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A=="],
+
+ "react-native-tab-view": ["react-native-tab-view@4.3.0", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-qPMF75uz/7+MuVG2g+YETdGMzlWZnhC6iI4h/7EBbwIBwNBIBi2z4OA6KhY3IOOBwGHXEIz5IyA6doDqifYBHg=="],
+
+ "react-native-text-ticker": ["react-native-text-ticker@1.16.0", "", {}, "sha512-UHZcIrXzNQ3CnGSA6d5foR9weqSCEk5w+eJGnUf5OQkKbuq/xtt1ZaNVnsgcG5+3f9EnRrNCt52/a46LXVKGGw=="],
+
+ "react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd", "sha512-vfkld2jUj7EPkAjIc/Vbx4Q4MtOOLmYtCYCE2dWJsyLnPqgj1f0xVzBxbeVP7dfT+eSh4KIXfdxESXaHgrXIlw=="],
"react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="],
"react-native-url-polyfill": ["react-native-url-polyfill@2.0.0", "", { "dependencies": { "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "react-native": "*" } }, "sha512-My330Do7/DvKnEvwQc0WdcBnFPploYKp9CYlefDXzIdEaA+PAhDYllkvGeEroEzvc4Kzzj2O4yVdz8v6fjRvhA=="],
- "react-native-uuid": ["react-native-uuid@2.0.3", "", {}, "sha512-f/YfIS2f5UB+gut7t/9BKGSCYbRA9/74A5R1MDp+FLYsuS+OSWoiM/D8Jko6OJB6Jcu3v6ONuddvZKHdIGpeiw=="],
-
- "react-native-video": ["react-native-video@6.16.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-+G6tVVGbwFqNTyPivqb+PhQzWr5OudDQ1dgvBNyBRAgcS8rOcbwuS6oX+m8cxOsXHn1UT9ofQnjQEwkGOsvomg=="],
+ "react-native-uuid": ["react-native-uuid@2.0.4", "", {}, "sha512-LSJNeh559qC17fgVPBsWuTSW/OygFp2dwTcf94IQBLYft5FzIQS9pCsuT36OPvyvDOMb6yiGr6TafaJDnz9PPQ=="],
"react-native-volume-manager": ["react-native-volume-manager@2.0.8", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-aZM47/mYkdQ4CbXpKYO6Ajiczv7fxbQXZ9c0H8gRuQUaS3OCz/MZABer6o9aDWq0KMNsQ7q7GVFLRPnSSeeMmw=="],
"react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="],
- "react-native-worklets": ["react-native-worklets@0.5.1", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0", "@babel/plugin-transform-classes": "^7.0.0-0", "@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0", "@babel/plugin-transform-optional-chaining": "^7.0.0-0", "@babel/plugin-transform-shorthand-properties": "^7.0.0-0", "@babel/plugin-transform-template-literals": "^7.0.0-0", "@babel/plugin-transform-unicode-regex": "^7.0.0-0", "@babel/preset-typescript": "^7.16.7", "convert-source-map": "^2.0.0", "semver": "7.7.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0", "react": "*", "react-native": "*" } }, "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w=="],
+ "react-native-worklets": ["react-native-worklets@0.8.3", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.27.1", "@babel/plugin-transform-classes": "^7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", "@babel/plugin-transform-optional-chaining": "^7.27.1", "@babel/plugin-transform-shorthand-properties": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "convert-source-map": "^2.0.0", "semver": "^7.7.3" }, "peerDependencies": { "@babel/core": "*", "@react-native/metro-config": "*", "react": "*", "react-native": "0.81 - 0.85" } }, "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg=="],
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
- "react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="],
+ "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
- "react-test-renderer": ["react-test-renderer@19.1.1", "", { "dependencies": { "react-is": "^19.1.1", "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-aGRXI+zcBTtg0diHofc7+Vy97nomBs9WHHFY1Csl3iV0x6xucjNYZZAkiVKGiNYUv23ecOex5jE67t8ZzqYObA=="],
+ "react-test-renderer": ["react-test-renderer@19.2.3", "", { "dependencies": { "react-is": "^19.2.3", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-TMR1LnSFiWZMJkCgNf5ATSvAheTT2NvKIwiVwdBPHxjBI7n/JbWd4gaZ16DVd9foAXdvDz+sB5yxZTwMjPRxpw=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
@@ -1687,6 +1673,8 @@
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
+ "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
+
"regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="],
"regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="],
@@ -1697,25 +1685,17 @@
"regjsgen": ["regjsgen@0.8.0", "", {}, "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q=="],
- "regjsparser": ["regjsparser@0.13.0", "", { "dependencies": { "jsesc": "~3.1.0" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q=="],
+ "regjsparser": ["regjsparser@0.13.2", "", { "dependencies": { "jsesc": "~3.1.0" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
- "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
-
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
- "requireg": ["requireg@0.2.2", "", { "dependencies": { "nested-error-stacks": "~2.0.1", "rc": "~1.2.7", "resolve": "~1.7.1" } }, "sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg=="],
-
- "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
+ "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
- "resolve-global": ["resolve-global@1.0.0", "", { "dependencies": { "global-dirs": "^0.1.1" } }, "sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw=="],
-
- "resolve-workspace-root": ["resolve-workspace-root@2.0.0", "", {}, "sha512-IsaBUZETJD5WsI11Wt8PKHwaIe45or6pwNc8yflvLJ4DWtImK9kuLoH5kUva/2Mmx/RdIyr4aONNSa2v9LTJsw=="],
-
- "resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
+ "resolve-workspace-root": ["resolve-workspace-root@2.0.1", "", {}, "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w=="],
"restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="],
@@ -1723,29 +1703,25 @@
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
- "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
-
"rtl-detect": ["rtl-detect@1.1.2", "", {}, "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
- "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
-
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
- "sax": ["sax@1.4.3", "", {}, "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ=="],
+ "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
- "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
+ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
- "send": ["send@0.19.1", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg=="],
+ "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
"serialize-error": ["serialize-error@2.1.0", "", {}, "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw=="],
- "serve-static": ["serve-static@1.16.2", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "0.19.0" } }, "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw=="],
+ "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
@@ -1757,7 +1733,7 @@
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
- "sf-symbols-typescript": ["sf-symbols-typescript@2.1.0", "", {}, "sha512-ezT7gu/SHTPIOEEoG6TF+O0m5eewl0ZDAO4AtdBi5HjsrUI6JdCG17+Q8+aKp0heM06wZKApRCn5olNbs0Wb/A=="],
+ "sf-symbols-typescript": ["sf-symbols-typescript@2.2.0", "", {}, "sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw=="],
"shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="],
@@ -1765,11 +1741,11 @@
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
- "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
+ "shell-quote": ["shell-quote@1.8.4", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="],
- "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
+ "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
- "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
+ "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
@@ -1785,11 +1761,11 @@
"slash": ["slash@2.0.0", "", {}, "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="],
- "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
+ "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="],
- "slugify": ["slugify@1.6.6", "", {}, "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="],
+ "slugify": ["slugify@1.6.9", "", {}, "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg=="],
- "sonner-native": ["sonner-native@0.21.1", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.10.1", "react-native-safe-area-context": ">=4.10.5", "react-native-screens": ">=3.31.1", "react-native-svg": ">=15.6.0" } }, "sha512-00RSmfVBd/XfQdRh7sqgFUjftx09HRgEMnZei4CVKcRKeqRcq9DXn5o1nJhz3aA4Cyf5k2+0kK4spdWtAtNqSA=="],
+ "sonner-native": ["sonner-native@0.21.2", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.10.1", "react-native-safe-area-context": ">=4.10.5", "react-native-screens": ">=3.31.1", "react-native-svg": ">=15.6.0" } }, "sha512-LnGPmfgzrNIwcc+FvcLJqx8aH1dEHePRzvNR8aIR4kl9spySRkXK160GmQIazjfm6mSMlPqZwRa5eycvrzg/eQ=="],
"source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],
@@ -1799,37 +1775,35 @@
"split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
- "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
-
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
"stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="],
"stacktrace-parser": ["stacktrace-parser@0.1.11", "", { "dependencies": { "type-fest": "^0.7.1" } }, "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg=="],
- "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
+ "standard-navigation": ["standard-navigation@0.0.7", "", {}, "sha512-NCGLCNyuXrFOkGHxdNZFnpsehGtiq1oXbPhKl7ZuxFO5J//H2evqqOchmD4YwEUJnkjO4kH9Xp4hQX6hdAYCKQ=="],
+
+ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"stream-buffers": ["stream-buffers@2.2.0", "", {}, "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg=="],
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
+ "strict-url-sanitise": ["strict-url-sanitise@0.0.1", "", {}, "sha512-nuFtF539K8jZg3FjaWH/L8eocCR6gegz5RDOsaWxfdbF5Jqr2VXWxZayjTwUzsWJDC91k2EbnJXp6FuWW+Z4hg=="],
+
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
- "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
-
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
- "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
-
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
- "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
+ "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
- "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="],
+ "strnum": ["strnum@2.4.0", "", { "dependencies": { "anynum": "^1.0.0" } }, "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg=="],
"strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="],
@@ -1837,7 +1811,7 @@
"styleq": ["styleq@0.1.3", "", {}, "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA=="],
- "sucrase": ["sucrase@3.35.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA=="],
+ "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
@@ -1845,17 +1819,15 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
+ "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
+
"tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
- "tar": ["tar@7.5.2", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg=="],
-
- "temp-dir": ["temp-dir@2.0.0", "", {}, "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg=="],
-
"terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="],
- "terser": ["terser@5.44.1", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw=="],
+ "terser": ["terser@5.48.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q=="],
- "test-exclude": ["test-exclude@6.0.0", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", "minimatch": "^3.0.4" } }, "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w=="],
+ "text-encoding": ["text-encoding@0.7.0", "", {}, "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
@@ -1867,7 +1839,11 @@
"tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
- "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
+ "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
+
+ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
+
+ "tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="],
"tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="],
@@ -1879,25 +1855,25 @@
"token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="],
+ "toqr": ["toqr@0.1.1", "", {}, "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA=="],
+
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
- "type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="],
+ "tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="],
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
- "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
+ "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
- "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"ua-parser-js": ["ua-parser-js@0.7.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg=="],
- "undici": ["undici@6.22.0", "", {}, "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw=="],
-
- "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+ "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="],
@@ -1907,19 +1883,17 @@
"unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.2.0", "", {}, "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ=="],
- "unimodules-app-loader": ["unimodules-app-loader@6.0.7", "", {}, "sha512-23iwxmh6/y54PRGJt/xjsOpPK8vlfusBisi3yaVSK22pxg5DmiL/+IHCtbb/crHC+gqdItcy1OoRsZQHfNSBaw=="],
-
- "unique-string": ["unique-string@2.0.0", "", { "dependencies": { "crypto-random-string": "^2.0.0" } }, "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg=="],
+ "unimodules-app-loader": ["unimodules-app-loader@56.0.1", "", {}, "sha512-Z801jeBOQMUF/ExklxT1BqhEV/oF2/Bii7PFYAj/8Sauxl7oKvZbf70peRzzAU0mG7UQ3yU/UO/EpD1JyJ2WcA=="],
"universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
- "update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="],
+ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
- "use-debounce": ["use-debounce@10.0.6", "", { "peerDependencies": { "react": "*" } }, "sha512-C5OtPyhAZgVoteO9heXMTdW7v/IbFI+8bSVKYCJrSmiWWCLsbUxiBSp4t9v0hNBTGY97bT72ydDIDyGSFWfwXg=="],
+ "use-debounce": ["use-debounce@10.1.1", "", { "peerDependencies": { "react": "*" } }, "sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ=="],
"use-latest-callback": ["use-latest-callback@0.2.6", "", { "peerDependencies": { "react": ">=16.8" } }, "sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg=="],
@@ -1929,8 +1903,6 @@
"utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="],
- "util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
-
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
@@ -1959,28 +1931,22 @@
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
+ "whatwg-url-minimum": ["whatwg-url-minimum@0.1.2", "", {}, "sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A=="],
+
"whatwg-url-without-unicode": ["whatwg-url-without-unicode@8.0.0-3", "", { "dependencies": { "buffer": "^5.4.3", "punycode": "^2.1.1", "webidl-conversions": "^5.0.0" } }, "sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
- "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="],
+ "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="],
- "wonka": ["wonka@6.3.5", "", {}, "sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw=="],
-
- "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
-
- "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
-
- "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
-
- "write-file-atomic": ["write-file-atomic@4.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" } }, "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg=="],
-
- "ws": ["ws@6.2.3", "", { "dependencies": { "async-limiter": "~1.0.0" } }, "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA=="],
+ "ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="],
"xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="],
+ "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="],
+
"xml2js": ["xml2js@0.6.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w=="],
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
@@ -1989,7 +1955,7 @@
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
- "yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
+ "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
@@ -1997,152 +1963,124 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
- "zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
+ "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
- "zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="],
+ "zxing-wasm": ["zxing-wasm@3.1.0", "", { "dependencies": { "@types/emscripten": "^1.41.5", "type-fest": "^5.7.0" } }, "sha512-5+3V1wPRx4gvbeLH2jB7n2cKrYJ1q4i3QgjnBUtrDPeqxJSi6BdzKJg4y6aF6bgW8zfntnYJyrkqFMevDhL2NA=="],
- "@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
+ "@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
- "@babel/highlight/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
+ "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
- "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
+ "@babel/plugin-transform-react-jsx/@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
- "@babel/plugin-transform-react-jsx/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
-
- "@babel/plugin-transform-runtime/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
+ "@babel/plugin-transform-runtime/@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@expo/cli/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
- "@expo/cli/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
+ "@expo/cli/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
"@expo/cli/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="],
- "@expo/cli/picomatch": ["picomatch@3.0.1", "", {}, "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag=="],
-
- "@expo/cli/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
+ "@expo/cli/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
"@expo/cli/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
- "@expo/cli/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
+ "@expo/cli/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
- "@expo/config/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
+ "@expo/cli/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@expo/config/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
- "@expo/config/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
+ "@expo/config/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
- "@expo/config/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
+ "@expo/config/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
"@expo/config-plugins/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
- "@expo/config-plugins/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
+ "@expo/config-plugins/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
- "@expo/config-plugins/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
-
- "@expo/config-plugins/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
+ "@expo/config-plugins/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
"@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
- "@expo/devcert/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
-
"@expo/env/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
"@expo/fingerprint/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
- "@expo/fingerprint/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
+ "@expo/fingerprint/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
- "@expo/fingerprint/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
+ "@expo/fingerprint/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
"@expo/image-utils/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
- "@expo/image-utils/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
-
- "@expo/json-file/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
-
- "@expo/mcp-tunnel/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
-
- "@expo/mcp-tunnel/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
-
- "@expo/metro/metro-runtime": ["metro-runtime@0.83.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A=="],
-
- "@expo/metro/metro-source-map": ["metro-source-map@0.83.2", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.2", "nullthrows": "^1.1.1", "ob1": "0.83.2", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-5FL/6BSQvshIKjXOennt9upFngq2lFvDakZn5LfauIVq8+L4sxXewIlSTcxAtzbtjAIaXeOSVMtCJ5DdfCt9AA=="],
+ "@expo/image-utils/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
"@expo/metro-config/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
- "@expo/metro-config/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
-
- "@expo/metro-config/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
+ "@expo/metro-config/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
"@expo/package-manager/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="],
- "@expo/prebuild-config/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
+ "@expo/prebuild-config/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
- "@expo/xcpretty/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
-
- "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
-
- "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
-
- "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
-
- "@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
-
- "@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
-
- "@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="],
-
- "@jest/transform/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
+ "@expo/ws-tunnel/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"@jimp/png/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
- "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+ "@react-native-community/cli/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
- "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
-
- "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
-
- "@react-native-community/cli/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
-
- "@react-native-community/cli-doctor/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
+ "@react-native-community/cli-doctor/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
"@react-native-community/cli-server-api/open": ["open@6.4.0", "", { "dependencies": { "is-wsl": "^1.1.0" } }, "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg=="],
- "@react-native-community/cli-tools/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
+ "@react-native-community/cli-server-api/ws": ["ws@6.2.4", "", { "dependencies": { "async-limiter": "~1.0.0" } }, "sha512-PNIUUyLI5YpkJZj60YBzX1o0ByQ4ovvfmq9N/Kig/PAYbVlGyz4R6G0SEWrD0O9acc0sT2+IdMBVLFv8FSi0Nw=="],
- "@react-native/community-cli-plugin/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
+ "@react-native-community/cli-tools/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
- "@react-navigation/bottom-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
+ "@react-native/babel-preset/@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.86.0", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@react-native/codegen": "0.86.0" } }, "sha512-qdsABWNW7uTll90l4Vh03gjeyu3WVDi2CyiiyvYGMRDcoYbjbQi6df3BMAm9lQI2yslZ1T14LlDDAsgTwNxplA=="],
+
+ "@react-native/babel-preset/babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.36.0", "", { "dependencies": { "hermes-parser": "0.36.0" } }, "sha512-LhD0xdoedDw7ansQgXbB2DADLZIK/LRXuWNBPuVzMc5S2WK5GyT89tCM+cQzxFGO0mGyLK6D5TrVOJJzAoDy8Q=="],
+
+ "@react-native/community-cli-plugin/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
+
+ "@react-native/metro-babel-transformer/hermes-parser": ["hermes-parser@0.36.0", "", { "dependencies": { "hermes-estree": "0.36.0" } }, "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w=="],
+
+ "@react-native/metro-config/@react-native/js-polyfills": ["@react-native/js-polyfills@0.86.0", "", {}, "sha512-zYy/Cjd1VTnZ2iCNaG9bDF9C3l2ntESiPRscjIlI5FKugu6aeTwsDSv1aI8Bc4Kp3vEdoVg+UQhLAhE4svREaQ=="],
"@react-navigation/elements/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"@react-navigation/material-top-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
- "@react-navigation/native-stack/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
+ "@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.100.14", "", {}, "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew=="],
+
+ "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
+
+ "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
+
+ "@testing-library/dom/pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
- "ansi-fragments/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
-
"ansi-fragments/slice-ansi": ["slice-ansi@2.1.0", "", { "dependencies": { "ansi-styles": "^3.2.0", "astral-regex": "^1.0.0", "is-fullwidth-code-point": "^2.0.0" } }, "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ=="],
"ansi-fragments/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
- "ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+ "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
- "babel-jest/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
+ "babel-preset-expo/@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
- "babel-preset-expo/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
+ "brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
- "better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
-
- "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
+ "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
- "cli-truncate/string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="],
+ "cli-truncate/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
+ "compressible/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
+
"compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"connect/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@@ -2151,11 +2089,13 @@
"error-ex/is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
- "expo-build-properties/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
+ "expo-build-properties/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
"expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
- "expo-router/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
+ "expo-router/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
+
+ "expo-router/standard-navigation": ["standard-navigation@0.0.5", "", {}, "sha512-YAmzwAiiQVocZxO/VGPFiQHcu5pKiz09QIGC0MK6aRMoa3E0QkoTQgcqJr7ZZ3OMiNhu4DkaGElFI5htjOIDbw=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -2173,8 +2113,6 @@
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
- "glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
-
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@@ -2185,84 +2123,86 @@
"jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
+ "jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
+
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
- "lint-staged/commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
-
"log-update/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
- "log-update/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
+ "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
+
+ "log-update/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
+
+ "log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"logkitty/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
+ "metro/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
+
"metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="],
- "metro/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="],
+ "metro/hermes-parser": ["hermes-parser@0.35.0", "", { "dependencies": { "hermes-estree": "0.35.0" } }, "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA=="],
- "metro/metro-runtime": ["metro-runtime@0.83.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A=="],
+ "metro/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
- "metro/metro-source-map": ["metro-source-map@0.83.2", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.2", "nullthrows": "^1.1.1", "ob1": "0.83.2", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-5FL/6BSQvshIKjXOennt9upFngq2lFvDakZn5LfauIVq8+L4sxXewIlSTcxAtzbtjAIaXeOSVMtCJ5DdfCt9AA=="],
+ "metro-babel-transformer/hermes-parser": ["hermes-parser@0.35.0", "", { "dependencies": { "hermes-estree": "0.35.0" } }, "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA=="],
- "metro/metro-symbolicate": ["metro-symbolicate@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.2", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-KoU9BLwxxED6n33KYuQQuc5bXkIxF3fSwlc3ouxrrdLWwhu64muYZNQrukkWzhVKRNFIXW7X2iM8JXpi2heIPw=="],
+ "metro-cache/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
- "metro/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
-
- "metro-babel-transformer/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="],
-
- "metro-config/metro-runtime": ["metro-runtime@0.83.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A=="],
-
- "metro-transform-worker/metro-source-map": ["metro-source-map@0.83.2", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.2", "nullthrows": "^1.1.1", "ob1": "0.83.2", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-5FL/6BSQvshIKjXOennt9upFngq2lFvDakZn5LfauIVq8+L4sxXewIlSTcxAtzbtjAIaXeOSVMtCJ5DdfCt9AA=="],
+ "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"nativewind/@babel/types": ["@babel/types@7.19.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.18.10", "@babel/helper-validator-identifier": "^7.18.6", "to-fast-properties": "^2.0.0" } }, "sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA=="],
"nativewind/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
- "node-vibrant/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
+ "npm-package-arg/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
- "npm-package-arg/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
+ "parse-png/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="],
"patch-package/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
- "patch-package/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
+ "patch-package/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
- "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
+ "path-scurry/lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
+
+ "pixelmatch/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="],
+
+ "plist/@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="],
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
"postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
- "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
-
"pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
- "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
+ "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
- "react-native/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
+ "react-native/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
- "react-native-reanimated/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
+ "react-native-drawer-layout/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
+
+ "react-native-reanimated/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
"react-native-web/@react-native/normalize-colors": ["@react-native/normalize-colors@0.74.89", "", {}, "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg=="],
"react-native-web/memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="],
- "react-native-worklets/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
+ "react-native-worklets/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
"readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
- "requireg/resolve": ["resolve@1.7.1", "", { "dependencies": { "path-parse": "^1.0.5" } }, "sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw=="],
+ "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
- "serve-static/send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw=="],
-
"simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="],
"slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
@@ -2275,33 +2215,25 @@
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
- "sucrase/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
-
"tailwindcss/postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
- "tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
-
"terminal-link/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
- "test-exclude/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
+ "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
- "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
+ "wrap-ansi/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
- "wrap-ansi/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
+ "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
- "@babel/highlight/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
-
- "@babel/highlight/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
-
- "@babel/highlight/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="],
+ "zxing-wasm/type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="],
"@expo/cli/ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
@@ -2311,9 +2243,7 @@
"@expo/cli/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
- "@expo/metro/metro-source-map/metro-symbolicate": ["metro-symbolicate@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.2", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-KoU9BLwxxED6n33KYuQQuc5bXkIxF3fSwlc3ouxrrdLWwhu64muYZNQrukkWzhVKRNFIXW7X2iM8JXpi2heIPw=="],
-
- "@expo/metro/metro-source-map/ob1": ["ob1@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg=="],
+ "@expo/cli/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@expo/package-manager/ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
@@ -2323,21 +2253,13 @@
"@expo/package-manager/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
- "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
-
- "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
-
- "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
-
- "@istanbuljs/load-nyc-config/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
-
- "@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
-
"@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
- "@react-navigation/bottom-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+ "@react-native/babel-preset/@react-native/babel-plugin-codegen/@react-native/codegen": ["@react-native/codegen@0.86.0", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.29.0", "hermes-parser": "0.36.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "tinyglobby": "^0.2.15", "yargs": "^17.6.2" } }, "sha512-uTs9DBo3+/lUqinsGZK0FKJRBVClrwMXoZToaDxE1Q2SL2e55vs2GwyZfIKzPl5uJnbu4PfFMIp0/mLXLWUMuA=="],
- "@react-navigation/bottom-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
+ "@react-native/babel-preset/babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.36.0", "", { "dependencies": { "hermes-estree": "0.36.0" } }, "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w=="],
+
+ "@react-native/metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.36.0", "", {}, "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w=="],
"@react-navigation/elements/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -2347,9 +2269,7 @@
"@react-navigation/material-top-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
- "@react-navigation/native-stack/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
-
- "@react-navigation/native-stack/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
+ "@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
@@ -2357,26 +2277,36 @@
"ansi-fragments/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
- "ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+ "chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
- "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
+ "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
- "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
+ "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
- "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
+ "expo-router/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
- "glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
+ "expo-router/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
+
+ "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
+ "log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
+
+ "log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
+
"log-update/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+ "log-update/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
+
+ "log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
+
"logkitty/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"logkitty/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
@@ -2385,44 +2315,42 @@
"logkitty/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
- "metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="],
+ "metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="],
- "metro-transform-worker/metro-source-map/metro-symbolicate": ["metro-symbolicate@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.2", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-KoU9BLwxxED6n33KYuQQuc5bXkIxF3fSwlc3ouxrrdLWwhu64muYZNQrukkWzhVKRNFIXW7X2iM8JXpi2heIPw=="],
+ "metro-cache/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
- "metro-transform-worker/metro-source-map/ob1": ["ob1@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg=="],
+ "metro/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
- "metro/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="],
+ "metro/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="],
- "metro/metro-source-map/ob1": ["ob1@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg=="],
+ "metro/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
- "node-vibrant/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
-
- "patch-package/fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
+ "patch-package/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
+ "qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
+
+ "qrcode/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
+
+ "qrcode/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
+
+ "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
+
+ "react-native-drawer-layout/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
+ "react-native-drawer-layout/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
+
"readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
- "serve-static/send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
-
- "serve-static/send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
-
- "serve-static/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
-
"terminal-link/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
- "test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
-
- "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
+ "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
- "@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
-
- "@babel/highlight/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
-
"@expo/cli/ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
"@expo/cli/ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
@@ -2433,6 +2361,8 @@
"@expo/cli/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
+ "@expo/cli/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
"@expo/package-manager/ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
"@expo/package-manager/ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
@@ -2443,11 +2373,9 @@
"@expo/package-manager/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
- "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
+ "@react-native/babel-preset/@react-native/babel-plugin-codegen/@react-native/codegen/hermes-parser": ["hermes-parser@0.36.0", "", { "dependencies": { "hermes-estree": "0.36.0" } }, "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w=="],
- "@react-navigation/bottom-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
-
- "@react-navigation/bottom-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+ "@react-native/babel-preset/babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.36.0", "", {}, "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w=="],
"@react-navigation/elements/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
@@ -2457,27 +2385,39 @@
"@react-navigation/material-top-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
- "@react-navigation/native-stack/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
-
- "@react-navigation/native-stack/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
-
"ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
+ "chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+ "cliui/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
+ "expo-router/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+ "expo-router/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
"log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
+ "log-update/wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
+
"logkitty/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"logkitty/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"logkitty/yargs/yargs-parser/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
- "serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
+ "qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
- "@babel/highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
+ "qrcode/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
+
+ "qrcode/yargs/yargs-parser/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
+
+ "react-native-drawer-layout/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+ "react-native-drawer-layout/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"@expo/cli/ora/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
@@ -2485,18 +2425,28 @@
"@expo/cli/ora/cli-cursor/restore-cursor/onetime": ["onetime@2.0.1", "", { "dependencies": { "mimic-fn": "^1.0.0" } }, "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ=="],
+ "@expo/cli/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
"@expo/package-manager/ora/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"@expo/package-manager/ora/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
"@expo/package-manager/ora/cli-cursor/restore-cursor/onetime": ["onetime@2.0.1", "", { "dependencies": { "mimic-fn": "^1.0.0" } }, "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ=="],
- "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
+ "@react-native/babel-preset/@react-native/babel-plugin-codegen/@react-native/codegen/hermes-parser/hermes-estree": ["hermes-estree@0.36.0", "", {}, "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w=="],
"ansi-fragments/slice-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
+ "cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+ "logkitty/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+
"logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
+ "qrcode/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+
+ "qrcode/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
+
"@expo/cli/ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"@expo/cli/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="],
@@ -2505,6 +2455,16 @@
"@expo/package-manager/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="],
+ "logkitty/yargs/cliui/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
"logkitty/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
+
+ "qrcode/yargs/cliui/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
+ "qrcode/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
+
+ "logkitty/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+ "qrcode/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
}
}
diff --git a/components/AccountsSheet.tsx b/components/AccountsSheet.tsx
new file mode 100644
index 000000000..9f0894554
--- /dev/null
+++ b/components/AccountsSheet.tsx
@@ -0,0 +1,223 @@
+import { Ionicons } from "@expo/vector-icons";
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import type React from "react";
+import { useCallback, useEffect, useMemo, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { Alert, Platform, TouchableOpacity, View } from "react-native";
+import { Swipeable } from "react-native-gesture-handler";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Colors } from "@/constants/Colors";
+import {
+ deleteAccountCredential,
+ type SavedServer,
+ type SavedServerAccount,
+} from "@/utils/secureCredentials";
+import { Button } from "./Button";
+import { Text } from "./common/Text";
+
+interface AccountsSheetProps {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ server: SavedServer | null;
+ onAccountSelect: (account: SavedServerAccount) => void;
+ onAddAccount: () => void;
+ onAccountDeleted?: () => void;
+}
+
+export const AccountsSheet: React.FC = ({
+ open,
+ setOpen,
+ server,
+ onAccountSelect,
+ onAddAccount,
+ onAccountDeleted,
+}) => {
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+ const bottomSheetModalRef = useRef(null);
+
+ const isAndroid = Platform.OS === "android";
+ const snapPoints = useMemo(
+ () => (isAndroid ? ["100%"] : ["50%"]),
+ [isAndroid],
+ );
+
+ useEffect(() => {
+ if (open) {
+ bottomSheetModalRef.current?.present();
+ } else {
+ bottomSheetModalRef.current?.dismiss();
+ }
+ }, [open]);
+
+ const handleSheetChanges = useCallback(
+ (index: number) => {
+ if (index === -1) {
+ setOpen(false);
+ }
+ },
+ [setOpen],
+ );
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const handleDeleteAccount = async (account: SavedServerAccount) => {
+ if (!server) return;
+
+ Alert.alert(
+ t("server.remove_saved_login"),
+ t("server.remove_account_description", { username: account.username }),
+ [
+ { text: t("common.cancel"), style: "cancel" },
+ {
+ text: t("common.remove"),
+ style: "destructive",
+ onPress: async () => {
+ await deleteAccountCredential(server.address, account.userId);
+ onAccountDeleted?.();
+ },
+ },
+ ],
+ );
+ };
+
+ const getSecurityIcon = (
+ securityType: SavedServerAccount["securityType"],
+ ): keyof typeof Ionicons.glyphMap => {
+ switch (securityType) {
+ case "pin":
+ return "keypad";
+ case "password":
+ return "lock-closed";
+ default:
+ return "key";
+ }
+ };
+
+ const renderRightActions = (account: SavedServerAccount) => (
+ handleDeleteAccount(account)}
+ className='bg-red-600 justify-center items-center px-5'
+ >
+
+
+ );
+
+ if (!server) return null;
+
+ return (
+
+
+
+ {/* Header */}
+
+
+ {t("server.select_account")}
+
+
+ {server.name || server.address}
+
+
+
+ {/* Account List */}
+
+ {server.accounts.map((account, index) => (
+ renderRightActions(account)}
+ overshootRight={false}
+ >
+ {
+ setOpen(false);
+ onAccountSelect(account);
+ }}
+ className={`flex-row items-center p-4 bg-neutral-800 ${
+ index < server.accounts.length - 1
+ ? "border-b border-neutral-700"
+ : ""
+ }`}
+ >
+ {/* Avatar */}
+
+
+
+
+ {/* Account Info */}
+
+
+ {account.username}
+
+
+ {account.securityType === "none"
+ ? t("save_account.no_protection")
+ : account.securityType === "pin"
+ ? t("save_account.pin_code")
+ : t("save_account.password")}
+
+
+
+ {/* Security Icon */}
+
+
+
+ ))}
+
+
+ {/* Hint */}
+
+ {t("server.swipe_to_remove")}
+
+
+ {/* Add Account Button */}
+
+
+
+
+ );
+};
diff --git a/components/AddToFavorites.tsx b/components/AddToFavorites.tsx
index c69a83df8..35221f1af 100644
--- a/components/AddToFavorites.tsx
+++ b/components/AddToFavorites.tsx
@@ -16,6 +16,7 @@ export const AddToFavorites: FC = ({ item, ...props }) => {
diff --git a/components/AddToWatchlist.tsx b/components/AddToWatchlist.tsx
new file mode 100644
index 000000000..0e1d6a377
--- /dev/null
+++ b/components/AddToWatchlist.tsx
@@ -0,0 +1,43 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import type { FC } from "react";
+import { useCallback, useRef } from "react";
+import { View, type ViewProps } from "react-native";
+import { RoundButton } from "@/components/RoundButton";
+import {
+ WatchlistSheet,
+ type WatchlistSheetRef,
+} from "@/components/watchlists/WatchlistSheet";
+import {
+ useItemInWatchlists,
+ useStreamystatsEnabled,
+} from "@/hooks/useWatchlists";
+
+interface Props extends ViewProps {
+ item: BaseItemDto;
+}
+
+export const AddToWatchlist: FC = ({ item, ...props }) => {
+ const streamystatsEnabled = useStreamystatsEnabled();
+ const sheetRef = useRef(null);
+
+ const { data: watchlistsContainingItem } = useItemInWatchlists(item.Id);
+ const isInAnyWatchlist = (watchlistsContainingItem?.length ?? 0) > 0;
+
+ const handlePress = useCallback(() => {
+ sheetRef.current?.open(item);
+ }, [item]);
+
+ // Don't render if Streamystats is not enabled
+ if (!streamystatsEnabled) return null;
+
+ return (
+
+
+
+
+ );
+};
diff --git a/components/Badge.tsx b/components/Badge.tsx
index aff8cb835..4c3bcc821 100644
--- a/components/Badge.tsx
+++ b/components/Badge.tsx
@@ -1,4 +1,7 @@
-import { View, type ViewProps } from "react-native";
+import { BlurView } from "expo-blur";
+import { Platform, StyleSheet, View, type ViewProps } from "react-native";
+import { GlassEffectView } from "react-native-glass-effect-view";
+import { useScaledTVTypography } from "@/constants/TVTypography";
import { Text } from "./common/Text";
interface Props extends ViewProps {
@@ -13,16 +16,11 @@ export const Badge: React.FC = ({
variant = "purple",
...props
}) => {
- return (
-
- {iconLeft && {iconLeft}}
+ const typography = useScaledTVTypography();
+
+ const content = (
+
+ {iconLeft && {iconLeft}}
= ({
);
+
+ if (Platform.OS === "ios" && !Platform.isTV) {
+ return (
+
+
+ {content}
+
+
+ );
+ }
+
+ // On TV, use BlurView for consistent styling
+ if (Platform.isTV) {
+ return (
+
+
+ {iconLeft && {iconLeft}}
+
+ {text}
+
+
+
+ );
+ }
+
+ return (
+
+ {iconLeft && {iconLeft}}
+
+ {text}
+
+
+ );
};
+
+const styles = StyleSheet.create({
+ container: {
+ overflow: "hidden",
+ alignSelf: "flex-start",
+ flexShrink: 1,
+ flexGrow: 0,
+ },
+ content: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingHorizontal: 10,
+ paddingVertical: 4,
+ borderRadius: 50,
+ backgroundColor: "transparent",
+ },
+ iconLeft: {
+ marginRight: 4,
+ },
+});
diff --git a/components/Button.tsx b/components/Button.tsx
index bbd0082a6..1471a5174 100644
--- a/components/Button.tsx
+++ b/components/Button.tsx
@@ -2,7 +2,6 @@ import type React from "react";
import {
type PropsWithChildren,
type ReactNode,
- useMemo,
useRef,
useState,
} from "react";
@@ -16,8 +15,61 @@ import {
View,
} from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
+import { scaleSize } from "@/utils/scaleSize";
import { Loader } from "./Loader";
+const getColorClasses = (
+ color: "purple" | "red" | "black" | "transparent" | "white",
+ variant: "solid" | "border",
+ focused: boolean,
+): string => {
+ if (variant === "border") {
+ switch (color) {
+ case "purple":
+ return focused
+ ? "bg-transparent border-2 border-purple-400"
+ : "bg-transparent border-2 border-purple-600";
+ case "red":
+ return focused
+ ? "bg-transparent border-2 border-red-400"
+ : "bg-transparent border-2 border-red-600";
+ case "black":
+ return focused
+ ? "bg-transparent border-2 border-neutral-700"
+ : "bg-transparent border-2 border-neutral-900";
+ case "white":
+ return focused
+ ? "bg-transparent border-2 border-gray-100"
+ : "bg-transparent border-2 border-white";
+ case "transparent":
+ return focused
+ ? "bg-transparent border-2 border-gray-400"
+ : "bg-transparent border-2 border-gray-600";
+ default:
+ return "";
+ }
+ } else {
+ switch (color) {
+ case "purple":
+ return focused
+ ? "bg-purple-500 border-2 border-white"
+ : "bg-purple-600 border border-purple-700";
+ case "red":
+ return "bg-red-600";
+ case "black":
+ return "bg-neutral-900";
+ case "white":
+ return focused
+ ? "bg-gray-100 border-2 border-gray-300"
+ : "bg-white border border-gray-200";
+ case "transparent":
+ return "bg-transparent";
+ default:
+ return "";
+ }
+ }
+};
+
export interface ButtonProps
extends React.ComponentProps {
onPress?: () => void;
@@ -26,7 +78,8 @@ export interface ButtonProps
disabled?: boolean;
children?: string | ReactNode;
loading?: boolean;
- color?: "purple" | "red" | "black" | "transparent";
+ color?: "purple" | "red" | "black" | "transparent" | "white";
+ variant?: "solid" | "border";
iconRight?: ReactNode;
iconLeft?: ReactNode;
justify?: "center" | "between";
@@ -39,6 +92,7 @@ export const Button: React.FC> = ({
disabled = false,
loading = false,
color = "purple",
+ variant = "solid",
iconRight,
iconLeft,
children,
@@ -56,30 +110,20 @@ export const Button: React.FC> = ({
useNativeDriver: true,
}).start();
- const colorClasses = useMemo(() => {
- switch (color) {
- case "purple":
- return focused
- ? "bg-purple-500 border-2 border-white"
- : "bg-purple-600 border border-purple-700";
- case "red":
- return "bg-red-600";
- case "black":
- return "bg-neutral-900";
- case "transparent":
- return "bg-transparent";
- }
- }, [color, focused]);
+ const colorClasses = getColorClasses(color, variant, focused);
const lightHapticFeedback = useHaptic("light");
+ const textColorClass =
+ color === "white" && variant === "solid" ? "text-black" : "text-white";
+
return Platform.isTV ? (
{
setFocused(true);
- animateTo(1.08);
+ animateTo(1.03);
}}
onBlur={() => {
setFocused(false);
@@ -89,19 +133,31 @@ export const Button: React.FC> = ({
- {children}
+
+ {children}
+
@@ -135,7 +191,7 @@ export const Button: React.FC> = ({
{iconLeft ? iconLeft : }
{
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
@@ -54,7 +55,7 @@ export function Chromecast({
>
-
+
);
}
diff --git a/components/ContextMenu.tv.ts b/components/ContextMenu.tv.ts
deleted file mode 100644
index e69de29bb..000000000
diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx
index 2f2268b6a..b1f759b57 100644
--- a/components/DownloadItem.tsx
+++ b/components/DownloadItem.tsx
@@ -9,13 +9,15 @@ import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
-import { type Href, router } from "expo-router";
+import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
+import { type Href } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, Platform, Switch, View, type ViewProps } from "react-native";
import { toast } from "sonner-native";
+import useRouter from "@/hooks/useAppRouter";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -62,6 +64,7 @@ export const DownloadItems: React.FC = ({
const [user] = useAtom(userAtom);
const [queue, _setQueue] = useAtom(queueAtom);
const { settings } = useSettings();
+ const router = useRouter();
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
const { processes, startBackgroundDownload, downloadedItems } = useDownload();
@@ -71,12 +74,16 @@ export const DownloadItems: React.FC = ({
SelectedOptions | undefined
>(undefined);
+ const playSettingsOptions = useMemo(
+ () => ({ applyLanguagePreferences: true }),
+ [],
+ );
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
- } = useDefaultPlaySettings(items[0], settings);
+ } = useDefaultPlaySettings(items[0], settings, playSettingsOptions);
const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading,
@@ -109,7 +116,7 @@ export const DownloadItems: React.FC = ({
useEffect(() => {
setSelectedOptions(() => ({
bitrate: defaultBitrate,
- mediaSource: defaultMediaSource,
+ mediaSource: defaultMediaSource ?? undefined,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
@@ -170,9 +177,11 @@ export const DownloadItems: React.FC = ({
firstItem.Type !== "Episode"
? "/downloads"
: ({
- pathname: `/downloads/${firstItem.SeriesId}`,
+ pathname: "/series/[id]",
params: {
- episodeSeasonIndex: firstItem.ParentIndexNumber,
+ id: firstItem.SeriesId!,
+ seasonIndex: firstItem.ParentIndexNumber?.toString(),
+ offline: "true",
},
} as Href),
);
@@ -191,9 +200,30 @@ export const DownloadItems: React.FC = ({
);
}
const downloadDetailsPromises = items.map(async (item) => {
+ // Ensure the snapshot we store offline carries the Chapters array.
+ // Page-level fetches sometimes use a fields filter that omits it; the
+ // offline player would then render no chapter ticks / list.
+ let itemForDownload = item;
+ if (!itemForDownload.Chapters && itemForDownload.Id) {
+ try {
+ const enriched = await getUserLibraryApi(api).getItem({
+ itemId: itemForDownload.Id,
+ userId: user.Id!,
+ });
+ if (enriched.data) {
+ itemForDownload = enriched.data;
+ }
+ } catch (e) {
+ console.warn(
+ "[DownloadItem] failed to refresh item for Chapters, falling back to original",
+ e,
+ );
+ }
+ }
+
const { mediaSource, audioIndex, subtitleIndex } =
itemsNotDownloaded.length > 1
- ? getDefaultPlaySettings(item, settings!)
+ ? getDefaultPlaySettings(itemForDownload, settings!)
: {
mediaSource: selectedOptions?.mediaSource,
audioIndex: selectedOptions?.audioIndex,
@@ -202,18 +232,19 @@ export const DownloadItems: React.FC = ({
const downloadDetails = await getDownloadUrl({
api,
- item,
+ item: itemForDownload,
userId: user.Id!,
mediaSource: mediaSource!,
audioStreamIndex: audioIndex ?? -1,
subtitleStreamIndex: subtitleIndex ?? -1,
maxBitrate: selectedOptions?.bitrate || defaultBitrate,
deviceId: api.deviceInfo.id,
+ audioMode: settings?.audioTranscodeMode,
});
return {
url: downloadDetails?.url,
- item,
+ item: itemForDownload,
mediaSource: downloadDetails?.mediaSource,
};
});
@@ -236,11 +267,23 @@ export const DownloadItems: React.FC = ({
);
continue;
}
+ // Get the audio/subtitle indices that were used for this download
+ const downloadAudioIndex =
+ itemsNotDownloaded.length > 1
+ ? getDefaultPlaySettings(item, settings!).audioIndex
+ : selectedOptions?.audioIndex;
+ const downloadSubtitleIndex =
+ itemsNotDownloaded.length > 1
+ ? getDefaultPlaySettings(item, settings!).subtitleIndex
+ : selectedOptions?.subtitleIndex;
+
await startBackgroundDownload(
url,
item,
mediaSource,
selectedOptions?.bitrate || defaultBitrate,
+ downloadAudioIndex,
+ downloadSubtitleIndex,
);
}
},
diff --git a/components/ExampleGlobalModalUsage.tsx b/components/ExampleGlobalModalUsage.tsx
deleted file mode 100644
index ccebb823d..000000000
--- a/components/ExampleGlobalModalUsage.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-/**
- * Example Usage of Global Modal
- *
- * This file demonstrates how to use the global modal system from anywhere in your app.
- * You can delete this file after understanding how it works.
- */
-
-import { Ionicons } from "@expo/vector-icons";
-import { TouchableOpacity, View } from "react-native";
-import { Text } from "@/components/common/Text";
-import { useGlobalModal } from "@/providers/GlobalModalProvider";
-
-/**
- * Example 1: Simple Content Modal
- */
-export const SimpleModalExample = () => {
- const { showModal } = useGlobalModal();
-
- const handleOpenModal = () => {
- showModal(
-
- Simple Modal
-
- This is a simple modal with just some text content.
-
-
- Swipe down or tap outside to close.
-
- ,
- );
- };
-
- return (
-
- Open Simple Modal
-
- );
-};
-
-/**
- * Example 2: Modal with Custom Snap Points
- */
-export const CustomSnapPointsExample = () => {
- const { showModal } = useGlobalModal();
-
- const handleOpenModal = () => {
- showModal(
-
-
- Custom Snap Points
-
-
- This modal has custom snap points (25%, 50%, 90%).
-
-
-
- Try dragging the modal to different heights!
-
-
- ,
- {
- snapPoints: ["25%", "50%", "90%"],
- enableDynamicSizing: false,
- },
- );
- };
-
- return (
-
- Custom Snap Points
-
- );
-};
-
-/**
- * Example 3: Complex Component in Modal
- */
-const SettingsModalContent = () => {
- const { hideModal } = useGlobalModal();
-
- const settings = [
- {
- id: 1,
- title: "Notifications",
- icon: "notifications-outline" as const,
- enabled: true,
- },
- { id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
- {
- id: 3,
- title: "Auto-play",
- icon: "play-outline" as const,
- enabled: false,
- },
- ];
-
- return (
-
- Settings
-
- {settings.map((setting, index) => (
-
-
-
- {setting.title}
-
-
-
-
-
- ))}
-
-
- Close
-
-
- );
-};
-
-export const ComplexModalExample = () => {
- const { showModal } = useGlobalModal();
-
- const handleOpenModal = () => {
- showModal();
- };
-
- return (
-
- Complex Component
-
- );
-};
-
-/**
- * Example 4: Modal Triggered from Function (e.g., API response)
- */
-export const useShowSuccessModal = () => {
- const { showModal } = useGlobalModal();
-
- return (message: string) => {
- showModal(
-
-
-
-
- Success!
- {message}
- ,
- );
- };
-};
-
-/**
- * Main Demo Component
- */
-export const GlobalModalDemo = () => {
- const showSuccess = useShowSuccessModal();
-
- return (
-
-
- Global Modal Examples
-
-
-
-
-
-
- showSuccess("Operation completed successfully!")}
- className='bg-orange-600 px-4 py-2 rounded-lg'
- >
- Show Success Modal
-
-
- );
-};
diff --git a/components/GenreTags.tsx b/components/GenreTags.tsx
index 6708269d7..29f1cb303 100644
--- a/components/GenreTags.tsx
+++ b/components/GenreTags.tsx
@@ -1,11 +1,16 @@
// GenreTags.tsx
+import { BlurView } from "expo-blur";
import type React from "react";
import {
+ Platform,
type StyleProp,
+ StyleSheet,
type TextStyle,
View,
type ViewProps,
} from "react-native";
+import { GlassEffectView } from "react-native-glass-effect-view";
+import { useScaledTVTypography } from "@/constants/TVTypography";
import { Text } from "./common/Text";
interface TagProps {
@@ -20,6 +25,52 @@ export const Tag: React.FC<
textStyle?: StyleProp;
} & ViewProps
> = ({ text, textClass, textStyle, ...props }) => {
+ // Hook must be called at the top level, before any conditional returns
+ const typography = useScaledTVTypography();
+
+ if (Platform.OS === "ios" && !Platform.isTV) {
+ return (
+
+
+
+ {text}
+
+
+
+ );
+ }
+
+ // TV-specific styling with blur background
+ if (Platform.isTV) {
+ return (
+
+
+
+ {text}
+
+
+
+ );
+ }
+
return (
@@ -29,6 +80,16 @@ export const Tag: React.FC<
);
};
+const styles = StyleSheet.create({
+ container: {
+ overflow: "hidden",
+ borderRadius: 50,
+ },
+ glass: {
+ borderRadius: 50,
+ },
+});
+
export const Tags: React.FC<
TagProps & { tagProps?: ViewProps } & ViewProps
> = ({ tags, textClass = "text-xs", tagProps, ...props }) => {
@@ -36,7 +97,8 @@ export const Tags: React.FC<
return (
{tags.map((tag, idx) => (
diff --git a/components/GlobalModal.tsx b/components/GlobalModal.tsx
index 361321d35..107f78e40 100644
--- a/components/GlobalModal.tsx
+++ b/components/GlobalModal.tsx
@@ -3,7 +3,7 @@ import {
type BottomSheetBackdropProps,
BottomSheetModal,
} from "@gorhom/bottom-sheet";
-import { useCallback } from "react";
+import { useCallback, useEffect } from "react";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
/**
@@ -16,7 +16,13 @@ import { useGlobalModal } from "@/providers/GlobalModalProvider";
* after BottomSheetModalProvider.
*/
export const GlobalModal = () => {
- const { hideModal, modalState, modalRef } = useGlobalModal();
+ const { hideModal, modalState, modalRef, isVisible } = useGlobalModal();
+
+ useEffect(() => {
+ if (isVisible && modalState.content) {
+ modalRef.current?.present();
+ }
+ }, [isVisible, modalState.content, modalRef]);
const handleSheetChanges = useCallback(
(index: number) => {
diff --git a/components/IntroSheet.tsx b/components/IntroSheet.tsx
new file mode 100644
index 000000000..2b04b114f
--- /dev/null
+++ b/components/IntroSheet.tsx
@@ -0,0 +1,203 @@
+import { Feather, Ionicons } from "@expo/vector-icons";
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetScrollView,
+} from "@gorhom/bottom-sheet";
+import { Image } from "expo-image";
+import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { Linking, Platform, TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Button } from "@/components/Button";
+import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import { storage } from "@/utils/mmkv";
+
+export interface IntroSheetRef {
+ present: () => void;
+ dismiss: () => void;
+}
+
+export const IntroSheet = forwardRef((_, ref) => {
+ const bottomSheetRef = useRef(null);
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+ const router = useRouter();
+
+ useImperativeHandle(ref, () => ({
+ present: () => {
+ storage.set("hasShownIntro", true);
+ bottomSheetRef.current?.present();
+ },
+ dismiss: () => {
+ bottomSheetRef.current?.dismiss();
+ },
+ }));
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const handleDismiss = useCallback(() => {
+ bottomSheetRef.current?.dismiss();
+ }, []);
+
+ const handleGoToSettings = useCallback(() => {
+ bottomSheetRef.current?.dismiss();
+ router.push("/settings");
+ }, []);
+
+ return (
+
+
+
+
+
+ {t("home.intro.welcome_to_streamyfin")}
+
+
+ {t("home.intro.a_free_and_open_source_client_for_jellyfin")}
+
+
+
+
+
+ {t("home.intro.features_title")}
+
+
+ {t("home.intro.features_description")}
+
+
+
+
+ Seerr
+
+ {t("home.intro.jellyseerr_feature_description")}
+
+
+
+ {!Platform.isTV && (
+ <>
+
+
+
+
+
+
+ {t("home.intro.downloads_feature_title")}
+
+
+ {t("home.intro.downloads_feature_description")}
+
+
+
+
+
+
+
+
+ Chromecast
+
+ {t("home.intro.chromecast_feature_description")}
+
+
+
+ >
+ )}
+
+
+
+
+
+
+ {t("home.intro.centralised_settings_plugin_title")}
+
+
+
+ {t(
+ "home.intro.centralised_settings_plugin_description",
+ )}{" "}
+
+ {
+ Linking.openURL(
+ "https://github.com/streamyfin/jellyfin-plugin-streamyfin",
+ );
+ }}
+ >
+
+ {t("home.intro.read_more")}
+
+
+
+
+
+
+
+
+
+
+
+ {t("home.intro.go_to_settings_button")}
+
+
+
+
+
+
+
+
+ );
+});
+
+IntroSheet.displayName = "IntroSheet";
diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx
index 4c537e961..4869a75e5 100644
--- a/components/ItemContent.tsx
+++ b/components/ItemContent.tsx
@@ -6,36 +6,38 @@ import { Image } from "expo-image";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useState } from "react";
-import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { type Bitrate } from "@/components/BitrateSelector";
import { ItemImage } from "@/components/common/ItemImage";
import { DownloadSingleItem } from "@/components/DownloadItem";
+import { ItemPeopleSections } from "@/components/item/ItemPeopleSections";
import { MediaSourceButton } from "@/components/MediaSourceButton";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
-// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
import { PlayButton } from "@/components/PlayButton";
import { PlayedStatus } from "@/components/PlayedStatus";
import { SimilarItems } from "@/components/SimilarItems";
-import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
-import { useItemQuery } from "@/hooks/useItemQuery";
import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { AddToFavorites } from "./AddToFavorites";
+import { AddToWatchlist } from "./AddToWatchlist";
import { ItemHeader } from "./ItemHeader";
-import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
+import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
+const ItemContentTV = Platform.isTV
+ ? require("./ItemContent.tv").ItemContentTV
+ : null;
export type SelectedOptions = {
bitrate: Bitrate;
@@ -45,223 +47,251 @@ export type SelectedOptions = {
};
interface ItemContentProps {
- item: BaseItemDto;
- isOffline: boolean;
+ item?: BaseItemDto | null;
+ itemWithSources?: BaseItemDto | null;
+ isLoading?: boolean;
}
-export const ItemContent: React.FC = React.memo(
- ({ item, isOffline }) => {
- const [api] = useAtom(apiAtom);
- const { settings } = useSettings();
- const { orientation } = useOrientation();
- const navigation = useNavigation();
- const insets = useSafeAreaInsets();
- const [user] = useAtom(userAtom);
- const { t } = useTranslation();
+// Mobile-specific implementation
+const ItemContentMobile: React.FC = ({
+ item,
+ itemWithSources,
+}) => {
+ const [api] = useAtom(apiAtom);
+ const isOffline = useOfflineMode();
+ const { settings } = useSettings();
+ const { orientation } = useOrientation();
+ const navigation = useNavigation();
+ const insets = useSafeAreaInsets();
+ const [user] = useAtom(userAtom);
- const itemColors = useImageColorsReturn({ item });
+ const itemColors = useImageColorsReturn({ item });
- const [loadingLogo, setLoadingLogo] = useState(true);
- const [headerHeight, setHeaderHeight] = useState(350);
+ const [loadingLogo, setLoadingLogo] = useState(true);
+ const [headerHeight, setHeaderHeight] = useState(350);
- const [selectedOptions, setSelectedOptions] = useState<
- SelectedOptions | undefined
- >(undefined);
+ const [selectedOptions, setSelectedOptions] = useState<
+ SelectedOptions | undefined
+ >(undefined);
- // preload media sources
- useItemQuery(item.Id, false, undefined, []);
+ // Use itemWithSources for play settings since it has MediaSources data
+ const playSettingsOptions = useMemo(
+ () => ({ applyLanguagePreferences: true }),
+ [],
+ );
+ const {
+ defaultAudioIndex,
+ defaultBitrate,
+ defaultMediaSource,
+ defaultSubtitleIndex,
+ } = useDefaultPlaySettings(
+ itemWithSources ?? item,
+ settings,
+ playSettingsOptions,
+ );
- const {
- defaultAudioIndex,
- defaultBitrate,
- defaultMediaSource,
- defaultSubtitleIndex,
- } = useDefaultPlaySettings(item!, settings);
+ const logoUrl = useMemo(
+ () => (item ? getLogoImageUrlById({ api, item }) : null),
+ [api, item],
+ );
- const logoUrl = useMemo(
- () => (item ? getLogoImageUrlById({ api, item }) : null),
- [api, item],
- );
+ const onLogoLoad = React.useCallback(() => {
+ setLoadingLogo(false);
+ }, []);
- const loading = useMemo(() => {
- return Boolean(logoUrl && loadingLogo);
- }, [loadingLogo, logoUrl]);
+ const loading = useMemo(() => {
+ return Boolean(logoUrl && loadingLogo);
+ }, [loadingLogo, logoUrl]);
- // Needs to automatically change the selected to the default values for default indexes.
- useEffect(() => {
- setSelectedOptions(() => ({
- bitrate: defaultBitrate,
- mediaSource: defaultMediaSource,
- subtitleIndex: defaultSubtitleIndex ?? -1,
- audioIndex: defaultAudioIndex,
- }));
- }, [
- defaultAudioIndex,
- defaultBitrate,
- defaultSubtitleIndex,
- defaultMediaSource,
- ]);
+ // Needs to automatically change the selected to the default values for default indexes.
+ useEffect(() => {
+ setSelectedOptions(() => ({
+ bitrate: defaultBitrate,
+ mediaSource: defaultMediaSource ?? undefined,
+ subtitleIndex: defaultSubtitleIndex ?? -1,
+ audioIndex: defaultAudioIndex,
+ }));
+ }, [
+ defaultAudioIndex,
+ defaultBitrate,
+ defaultSubtitleIndex,
+ defaultMediaSource,
+ ]);
- useEffect(() => {
- if (!Platform.isTV) {
- navigation.setOptions({
- headerRight: () =>
- item &&
- (Platform.OS === "ios" ? (
-
-
- {item.Type !== "Program" && (
-
- {!Platform.isTV && (
-
- )}
- {user?.Policy?.IsAdministrator && (
+ useEffect(() => {
+ if (!Platform.isTV && itemWithSources) {
+ navigation.setOptions({
+ headerRight: () =>
+ item &&
+ (Platform.OS === "ios" ? (
+
+
+ {item.Type !== "Program" && (
+
+ {!Platform.isTV && (
+
+ )}
+ {user?.Policy?.IsAdministrator &&
+ !settings.hideRemoteSessionButton && (
)}
-
-
-
- )}
-
- ) : (
-
-
- {item.Type !== "Program" && (
-
- {!Platform.isTV && (
-
+
+
+ {settings.streamyStatsServerUrl &&
+ !settings.hideWatchlistsTab && (
+
)}
- {user?.Policy?.IsAdministrator && (
-
- )}
-
-
-
-
- )}
-
- )),
- });
- }
- }, [item, navigation, user]);
-
- useEffect(() => {
- if (item) {
- if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
- setHeaderHeight(230);
- else if (item.Type === "Movie") setHeaderHeight(500);
- else setHeaderHeight(350);
- }
- }, [item, orientation]);
-
- if (!item || !selectedOptions) return null;
-
- return (
-
-
-
+
+ )}
- }
- logo={
- logoUrl ? (
- setLoadingLogo(false)}
- onError={() => setLoadingLogo(false)}
- />
- ) : (
-
- )
- }
- >
-
-
-
+ ) : (
+
+
+ {item.Type !== "Program" && (
+
+ {!Platform.isTV && (
+
+ )}
+ {user?.Policy?.IsAdministrator &&
+ !settings.hideRemoteSessionButton && (
+
+ )}
-
-
+
+ {settings.streamyStatsServerUrl &&
+ !settings.hideWatchlistsTab && (
+
+ )}
+
+ )}
+
+ )),
+ });
+ }
+ }, [
+ item,
+ navigation,
+ user,
+ itemWithSources,
+ settings.hideRemoteSessionButton,
+ settings.streamyStatsServerUrl,
+ settings.hideWatchlistsTab,
+ ]);
+
+ useEffect(() => {
+ if (item) {
+ if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
+ setHeaderHeight(230);
+ else if (item.Type === "Movie") setHeaderHeight(500);
+ else setHeaderHeight(350);
+ }
+ }, [item, orientation]);
+
+ if (!item || !selectedOptions) return null;
+
+ return (
+
+
+
+
+ }
+ logo={
+ logoUrl ? (
+
+ ) : (
+
+ )
+ }
+ >
+
+
+
+
+
+
+
+ {!isOffline && (
+
-
- {!isOffline && (
-
- )}
-
+ )}
- {item.Type === "Episode" && (
-
- )}
-
-
-
- {item.Type !== "Program" && (
- <>
- {item.Type === "Episode" && !isOffline && (
-
- )}
-
- {!isOffline && (
-
- )}
-
- {item.People && item.People.length > 0 && !isOffline && (
-
- {item.People.slice(0, 3).map((person, idx) => (
-
- ))}
-
- )}
-
- {!isOffline && }
- >
- )}
-
-
- );
- },
-);
+ {item.Type === "Episode" && (
+
+ )}
+
+ {!isOffline &&
+ selectedOptions.mediaSource?.MediaStreams &&
+ selectedOptions.mediaSource.MediaStreams.length > 0 && (
+
+ )}
+
+
+
+ {item.Type !== "Program" && (
+ <>
+ {item.Type === "Episode" && !isOffline && (
+
+ )}
+
+
+
+ {!isOffline && }
+ >
+ )}
+
+
+
+ );
+};
+
+// Memoize the mobile component
+const MemoizedItemContentMobile = React.memo(ItemContentMobile);
+
+// Exported component that renders TV or mobile version based on platform
+export const ItemContent: React.FC = (props) => {
+ if (Platform.isTV && ItemContentTV) {
+ return ;
+ }
+ return ;
+};
diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx
new file mode 100644
index 000000000..05d0d1ce2
--- /dev/null
+++ b/components/ItemContent.tv.tsx
@@ -0,0 +1,967 @@
+import { Ionicons } from "@expo/vector-icons";
+import type {
+ BaseItemDto,
+ MediaSourceInfo,
+ MediaStream,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { BlurView } from "expo-blur";
+import { File } from "expo-file-system";
+import { Image } from "expo-image";
+import { useAtom } from "jotai";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { useTranslation } from "react-i18next";
+import { Alert, Dimensions, ScrollView, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
+import { ItemImage } from "@/components/common/ItemImage";
+import { Text } from "@/components/common/Text";
+import { getItemNavigation } from "@/components/common/TouchableItemRouter";
+import { GenreTags } from "@/components/GenreTags";
+import { TVEpisodeList } from "@/components/series/TVEpisodeList";
+import {
+ TVBackdrop,
+ TVButton,
+ TVCastCrewText,
+ TVCastSection,
+ TVFavoriteButton,
+ TVMetadataBadges,
+ TVOptionButton,
+ TVPlayedButton,
+ TVProgressBar,
+ TVRefreshButton,
+ TVSeriesNavigation,
+ TVTechnicalDetails,
+} from "@/components/tv";
+import type { Track } from "@/components/video-player/controls/types";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
+import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
+import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
+import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
+import { useTVOptionModal } from "@/hooks/useTVOptionModal";
+import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
+import { useTVThemeMusic } from "@/hooks/useTVThemeMusic";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useOfflineMode } from "@/providers/OfflineModeProvider";
+import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
+import { useSettings } from "@/utils/atoms/settings";
+import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
+import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
+import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
+import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
+
+const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
+
+export type SelectedOptions = {
+ bitrate: Bitrate;
+ mediaSource: MediaSourceInfo | undefined;
+ audioIndex: number | undefined;
+ subtitleIndex: number;
+};
+
+interface ItemContentTVProps {
+ item?: BaseItemDto | null;
+ itemWithSources?: BaseItemDto | null;
+ isLoading?: boolean;
+}
+
+// Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports)
+export const ItemContentTV: React.FC = React.memo(
+ ({ item, itemWithSources }) => {
+ const typography = useScaledTVTypography();
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const isOffline = useOfflineMode();
+ const { settings } = useSettings();
+ const insets = useSafeAreaInsets();
+ const router = useRouter();
+ const { showItemActions } = useTVItemActionModal();
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+
+ const _itemColors = useImageColorsReturn({ item });
+
+ // Auto-play theme music (handles fade in/out and cleanup)
+ useTVThemeMusic(item?.Id);
+
+ // State for first episode card ref (used for focus guide)
+ const [_firstEpisodeRef, setFirstEpisodeRef] = useState(null);
+
+ // Fetch season episodes for episodes
+ const { data: seasonEpisodes = [] } = useQuery({
+ queryKey: ["episodes", item?.SeasonId],
+ queryFn: async () => {
+ if (!api || !user?.Id || !item?.SeriesId || !item?.SeasonId) return [];
+ const res = await getTvShowsApi(api).getEpisodes({
+ seriesId: item.SeriesId,
+ userId: user.Id,
+ seasonId: item.SeasonId,
+ enableUserData: true,
+ fields: ["MediaSources", "Overview"],
+ });
+ return res.data.Items || [];
+ },
+ enabled:
+ !!api &&
+ !!user?.Id &&
+ !!item?.SeriesId &&
+ !!item?.SeasonId &&
+ item?.Type === "Episode",
+ });
+
+ const [selectedOptions, setSelectedOptions] = useState<
+ SelectedOptions | undefined
+ >(undefined);
+
+ // Enable language preference application for TV
+ const playSettingsOptions = useMemo(
+ () => ({ applyLanguagePreferences: true }),
+ [],
+ );
+
+ const {
+ defaultAudioIndex,
+ defaultBitrate,
+ defaultMediaSource,
+ defaultSubtitleIndex,
+ } = useDefaultPlaySettings(
+ itemWithSources ?? item,
+ settings,
+ playSettingsOptions,
+ );
+
+ const logoUrl = useMemo(
+ () => (item ? getLogoImageUrlById({ api, item }) : null),
+ [api, item],
+ );
+
+ // Set default play options
+ useEffect(() => {
+ setSelectedOptions(() => ({
+ bitrate: defaultBitrate,
+ mediaSource: defaultMediaSource ?? undefined,
+ subtitleIndex: defaultSubtitleIndex ?? -1,
+ audioIndex: defaultAudioIndex,
+ }));
+ }, [
+ defaultAudioIndex,
+ defaultBitrate,
+ defaultSubtitleIndex,
+ defaultMediaSource,
+ ]);
+
+ const navigateToPlayer = useCallback(
+ (playbackPosition: string) => {
+ if (!item || !selectedOptions) return;
+
+ const queryParams = new URLSearchParams({
+ itemId: item.Id!,
+ audioIndex: selectedOptions.audioIndex?.toString() ?? "",
+ subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
+ mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
+ bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
+ playbackPosition,
+ offline: isOffline ? "true" : "false",
+ });
+
+ router.push(`/player/direct-player?${queryParams.toString()}`);
+ },
+ [item, selectedOptions, isOffline, router],
+ );
+
+ const handlePlay = () => {
+ if (!item || !selectedOptions) return;
+
+ const hasPlaybackProgress =
+ (item.UserData?.PlaybackPositionTicks ?? 0) > 0;
+
+ if (hasPlaybackProgress) {
+ Alert.alert(
+ t("item_card.resume_playback"),
+ t("item_card.resume_playback_description"),
+ [
+ {
+ text: t("common.cancel"),
+ style: "cancel",
+ },
+ {
+ text: t("item_card.play_from_start"),
+ onPress: () => navigateToPlayer("0"),
+ },
+ {
+ text: t("item_card.continue_from", {
+ time: formatDuration(item.UserData?.PlaybackPositionTicks),
+ }),
+ onPress: () =>
+ navigateToPlayer(
+ item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
+ ),
+ isPreferred: true,
+ },
+ ],
+ );
+ } else {
+ navigateToPlayer("0");
+ }
+ };
+
+ // TV Option Modal hook for quality, audio, media source selectors
+ const { showOptions } = useTVOptionModal();
+
+ // TV Subtitle Modal hook
+ const { showSubtitleModal } = useTVSubtitleModal();
+
+ // State for first actor card ref (used for focus guide)
+ const [_firstActorCardRef, setFirstActorCardRef] = useState(
+ null,
+ );
+
+ // Get available audio tracks
+ const audioTracks = useMemo(() => {
+ const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
+ (s) => s.Type === "Audio",
+ );
+ return streams ?? [];
+ }, [selectedOptions?.mediaSource]);
+
+ // Get available subtitle tracks (raw MediaStream[] for label lookup)
+ const subtitleStreams = useMemo(() => {
+ const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
+ (s) => s.Type === "Subtitle",
+ );
+ return streams ?? [];
+ }, [selectedOptions?.mediaSource]);
+
+ // Store handleSubtitleChange in a ref for stable callback reference
+ const handleSubtitleChangeRef = useRef<((index: number) => void) | null>(
+ null,
+ );
+
+ // State to trigger refresh of local subtitles list
+ const [localSubtitlesRefreshKey, setLocalSubtitlesRefreshKey] = useState(0);
+
+ // Starting index for local (client-downloaded) subtitles
+ const LOCAL_SUBTITLE_INDEX_START = -100;
+
+ // Convert MediaStream[] to Track[] for the modal (with setTrack callbacks)
+ // Also includes locally downloaded subtitles from OpenSubtitles
+ const subtitleTracksForModal = useMemo((): Track[] => {
+ const tracks: Track[] = subtitleStreams.map((stream) => ({
+ name:
+ stream.DisplayTitle ||
+ `${stream.Language || "Unknown"} (${stream.Codec})`,
+ index: stream.Index ?? -1,
+ setTrack: () => {
+ handleSubtitleChangeRef.current?.(stream.Index ?? -1);
+ },
+ }));
+
+ // Add locally downloaded subtitles (from OpenSubtitles)
+ if (item?.Id) {
+ const localSubs = getSubtitlesForItem(item.Id);
+ let localIdx = 0;
+ for (const localSub of localSubs) {
+ // Verify file still exists (cache may have been cleared)
+ const subtitleFile = new File(localSub.filePath);
+ if (!subtitleFile.exists) {
+ continue;
+ }
+
+ const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx;
+ tracks.push({
+ name: localSub.name,
+ index: localIndex,
+ isLocal: true,
+ localPath: localSub.filePath,
+ setTrack: () => {
+ // For ItemContent (outside player), just update the selected index
+ // The actual subtitle will be loaded when playback starts
+ handleSubtitleChangeRef.current?.(localIndex);
+ },
+ });
+ localIdx++;
+ }
+ }
+
+ return tracks;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [subtitleStreams, item?.Id, localSubtitlesRefreshKey]);
+
+ // Get available media sources
+ const mediaSources = useMemo(() => {
+ return (itemWithSources ?? item)?.MediaSources ?? [];
+ }, [item, itemWithSources]);
+
+ // Audio options for selector
+ const audioOptions: TVOptionItem[] = useMemo(() => {
+ return audioTracks.map((track) => ({
+ label:
+ track.DisplayTitle ||
+ `${track.Language || "Unknown"} (${track.Codec})`,
+ value: track.Index!,
+ selected: track.Index === selectedOptions?.audioIndex,
+ }));
+ }, [audioTracks, selectedOptions?.audioIndex]);
+
+ // Media source options for selector
+ const mediaSourceOptions: TVOptionItem[] = useMemo(() => {
+ return mediaSources.map((source) => {
+ const videoStream = source.MediaStreams?.find(
+ (s) => s.Type === "Video",
+ );
+ const displayName =
+ videoStream?.DisplayTitle || source.Name || `Source ${source.Id}`;
+ return {
+ label: displayName,
+ value: source,
+ selected: source.Id === selectedOptions?.mediaSource?.Id,
+ };
+ });
+ }, [mediaSources, selectedOptions?.mediaSource?.Id]);
+
+ // Quality/bitrate options for selector
+ const qualityOptions: TVOptionItem[] = useMemo(() => {
+ return BITRATES.map((bitrate) => ({
+ label: bitrate.key,
+ value: bitrate,
+ selected: bitrate.value === selectedOptions?.bitrate?.value,
+ }));
+ }, [selectedOptions?.bitrate?.value]);
+
+ // Handlers for option changes
+ const handleAudioChange = useCallback((audioIndex: number) => {
+ setSelectedOptions((prev) =>
+ prev ? { ...prev, audioIndex } : undefined,
+ );
+ }, []);
+
+ const handleSubtitleChange = useCallback((subtitleIndex: number) => {
+ setSelectedOptions((prev) =>
+ prev ? { ...prev, subtitleIndex } : undefined,
+ );
+ }, []);
+
+ // Keep the ref updated with the latest callback
+ handleSubtitleChangeRef.current = handleSubtitleChange;
+
+ const handleMediaSourceChange = useCallback(
+ (mediaSource: MediaSourceInfo) => {
+ const defaultAudio = mediaSource.MediaStreams?.find(
+ (s) => s.Type === "Audio" && s.IsDefault,
+ );
+ const defaultSubtitle = mediaSource.MediaStreams?.find(
+ (s) => s.Type === "Subtitle" && s.IsDefault,
+ );
+ setSelectedOptions((prev) =>
+ prev
+ ? {
+ ...prev,
+ mediaSource,
+ audioIndex: defaultAudio?.Index ?? prev.audioIndex,
+ subtitleIndex: defaultSubtitle?.Index ?? -1,
+ }
+ : undefined,
+ );
+ },
+ [],
+ );
+
+ const handleQualityChange = useCallback((bitrate: Bitrate) => {
+ setSelectedOptions((prev) => (prev ? { ...prev, bitrate } : undefined));
+ }, []);
+
+ // Handle server-side subtitle download - invalidate queries to refresh tracks
+ const handleServerSubtitleDownloaded = useCallback(() => {
+ if (item?.Id) {
+ queryClient.invalidateQueries({ queryKey: ["item", item.Id] });
+ }
+ }, [item?.Id, queryClient]);
+
+ // Handle local subtitle download - trigger refresh of subtitle tracks
+ const handleLocalSubtitleDownloaded = useCallback((_path: string) => {
+ // Increment the refresh key to trigger re-computation of subtitleTracksForModal
+ setLocalSubtitlesRefreshKey((prev) => prev + 1);
+ }, []);
+
+ // Refresh subtitle tracks by fetching fresh item data from Jellyfin
+ const refreshSubtitleTracks = useCallback(async (): Promise
diff --git a/components/PlayButton.tv.tsx b/components/PlayButton.tv.tsx
index 8e3b9811a..c8b6b76e3 100644
--- a/components/PlayButton.tv.tsx
+++ b/components/PlayButton.tv.tsx
@@ -1,6 +1,5 @@
-import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
+import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
-import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect } from "react";
import { TouchableOpacity, View } from "react-native";
@@ -14,10 +13,10 @@ import Animated, {
useSharedValue,
withTiming,
} from "react-native-reanimated";
+import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
-import { useSettings } from "@/utils/atoms/settings";
import { runtimeTicksToMinutes } from "@/utils/time";
import type { Button } from "./Button";
import type { SelectedOptions } from "./ItemContent";
@@ -50,7 +49,6 @@ export const PlayButton: React.FC = ({
const startColor = useSharedValue(effectiveColors);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
- const { settings } = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(
@@ -61,7 +59,6 @@ export const PlayButton: React.FC = ({
);
const onPress = () => {
- console.log("onpress");
if (!item) return;
lightHapticFeedback();
@@ -72,6 +69,7 @@ export const PlayButton: React.FC = ({
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
+ playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
const queryString = queryParams.toString();
@@ -80,7 +78,7 @@ export const PlayButton: React.FC = ({
};
const derivedTargetWidth = useDerivedValue(() => {
- if (!item || !item.RunTimeTicks) return 0;
+ if (!item?.RunTimeTicks) return 0;
const userData = item.UserData;
if (userData?.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0
@@ -207,15 +205,6 @@ export const PlayButton: React.FC = ({
- {settings?.openInVLC && (
-
-
-
- )}
diff --git a/components/PlaybackSpeedSelector.tsx b/components/PlaybackSpeedSelector.tsx
new file mode 100644
index 000000000..70b69ba4a
--- /dev/null
+++ b/components/PlaybackSpeedSelector.tsx
@@ -0,0 +1,180 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Platform, View } from "react-native";
+import { useSettings } from "@/utils/atoms/settings";
+import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
+import { PlaybackSpeedScope } from "./video-player/controls/utils/playback-speed-settings";
+
+export const PLAYBACK_SPEEDS = [
+ { label: "0.25x", value: 0.25 },
+ { label: "0.5x", value: 0.5 },
+ { label: "0.75x", value: 0.75 },
+ { label: "1x", value: 1.0 },
+ { label: "1.25x", value: 1.25 },
+ { label: "1.5x", value: 1.5 },
+ { label: "1.75x", value: 1.75 },
+ { label: "2x", value: 2.0 },
+ { label: "2.25x", value: 2.25 },
+ { label: "2.5x", value: 2.5 },
+ { label: "2.75x", value: 2.75 },
+ { label: "3x", value: 3.0 },
+];
+
+interface Props extends React.ComponentProps {
+ onChange: (value: number, scope: PlaybackSpeedScope) => void;
+ selected: number;
+ item?: BaseItemDto;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}
+
+export const PlaybackSpeedSelector: React.FC = ({
+ onChange,
+ selected,
+ item,
+ open: controlledOpen,
+ onOpenChange,
+ ...props
+}) => {
+ const isTv = Platform.isTV;
+ const { t } = useTranslation();
+ const { settings } = useSettings();
+ const [internalOpen, setInternalOpen] = useState(false);
+
+ // Determine initial scope based on existing settings
+ const initialScope = useMemo(() => {
+ if (!item || !settings) return PlaybackSpeedScope.All;
+
+ const itemId = item?.Id;
+ if (!itemId) return PlaybackSpeedScope.All;
+
+ // Check for media-specific speed preference
+ if (settings?.playbackSpeedPerMedia?.[itemId] !== undefined) {
+ return PlaybackSpeedScope.Media;
+ }
+
+ // Check for show-specific speed preference (only for episodes)
+ const seriesId = item?.SeriesId;
+ const perShowSettings = settings?.playbackSpeedPerShow;
+ if (
+ seriesId &&
+ perShowSettings &&
+ perShowSettings[seriesId] !== undefined
+ ) {
+ return PlaybackSpeedScope.Show;
+ }
+
+ // If no custom setting exists, check default playback speed
+ // Show "All" if speed is not 1x, otherwise show "Media"
+ return (settings?.defaultPlaybackSpeed ?? 1.0) !== 1.0
+ ? PlaybackSpeedScope.All
+ : PlaybackSpeedScope.Media;
+ }, [item?.Id, item?.SeriesId, settings]);
+
+ const [selectedScope, setSelectedScope] =
+ useState(initialScope);
+
+ // Update selectedScope when initialScope changes
+ useEffect(() => {
+ setSelectedScope(initialScope);
+ }, [initialScope]);
+
+ const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
+ const setOpen = onOpenChange || setInternalOpen;
+
+ const scopeLabels = useMemo>(() => {
+ const labels: Record = {
+ [PlaybackSpeedScope.Media]: t("playback_speed.scope.media"),
+ };
+
+ if (item?.SeriesId) {
+ labels[PlaybackSpeedScope.Show] = t("playback_speed.scope.show");
+ }
+
+ labels[PlaybackSpeedScope.All] = t("playback_speed.scope.all");
+
+ return labels as Record;
+ }, [item?.SeriesId, t]);
+
+ const availableScopes = useMemo(() => {
+ const scopes = [PlaybackSpeedScope.Media];
+ if (item?.SeriesId) {
+ scopes.push(PlaybackSpeedScope.Show);
+ }
+ scopes.push(PlaybackSpeedScope.All);
+ return scopes;
+ }, [item?.SeriesId]);
+
+ const handleSpeedSelect = useCallback(
+ (speed: number) => {
+ onChange(speed, selectedScope);
+ setOpen(false);
+ },
+ [onChange, selectedScope, setOpen],
+ );
+
+ const optionGroups = useMemo(() => {
+ const groups: OptionGroup[] = [];
+
+ // Scope selection group
+ groups.push({
+ title: t("playback_speed.apply_to"),
+ options: availableScopes.map((scope) => ({
+ type: "radio" as const,
+ label: scopeLabels[scope],
+ value: scope,
+ selected: selectedScope === scope,
+ onPress: () => setSelectedScope(scope),
+ })),
+ });
+
+ // Speed selection group
+ groups.push({
+ title: t("playback_speed.speed"),
+ options: PLAYBACK_SPEEDS.map((speed) => ({
+ type: "radio" as const,
+ label: speed.label,
+ value: speed.value,
+ selected: selected === speed.value,
+ onPress: () => handleSpeedSelect(speed.value),
+ })),
+ });
+
+ return groups;
+ }, [
+ t,
+ availableScopes,
+ scopeLabels,
+ selectedScope,
+ selected,
+ handleSpeedSelect,
+ ]);
+
+ const trigger = useMemo(
+ () => (
+
+
+
+ ),
+ [],
+ );
+
+ if (isTv) return null;
+
+ return (
+
+
+
+ );
+};
diff --git a/components/PlayedStatus.tsx b/components/PlayedStatus.tsx
index 0023b0141..dd2198cde 100644
--- a/components/PlayedStatus.tsx
+++ b/components/PlayedStatus.tsx
@@ -1,12 +1,12 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
+import { useCallback } from "react";
import { View, type ViewProps } from "react-native";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { RoundButton } from "./RoundButton";
interface Props extends ViewProps {
items: BaseItemDto[];
- isOffline?: boolean;
size?: "default" | "large";
}
@@ -14,14 +14,16 @@ export const PlayedStatus: React.FC = ({ items, ...props }) => {
const allPlayed = items.every((item) => item.UserData?.Played);
const toggle = useMarkAsPlayed(items);
+ const handlePress = useCallback(() => {
+ void toggle(!allPlayed);
+ }, [allPlayed, toggle]);
+
return (
{
- await toggle(!allPlayed);
- }}
+ onPress={handlePress}
size={props.size}
/>
diff --git a/components/PreviousServersList.tsx b/components/PreviousServersList.tsx
index ffa310d31..251a6ca3d 100644
--- a/components/PreviousServersList.tsx
+++ b/components/PreviousServersList.tsx
@@ -1,42 +1,282 @@
+import { Ionicons } from "@expo/vector-icons";
import type React from "react";
-import { useMemo } from "react";
+import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
-import { View } from "react-native";
+import { ActivityIndicator, Alert, TouchableOpacity, View } from "react-native";
+import { Swipeable } from "react-native-gesture-handler";
import { useMMKVString } from "react-native-mmkv";
+import { Colors } from "@/constants/Colors";
+import {
+ deleteAccountCredential,
+ getPreviousServers,
+ removeServerFromList,
+ type SavedServer,
+ type SavedServerAccount,
+} from "@/utils/secureCredentials";
+import { AccountsSheet } from "./AccountsSheet";
+import { Text } from "./common/Text";
import { ListGroup } from "./list/ListGroup";
import { ListItem } from "./list/ListItem";
-
-interface Server {
- address: string;
-}
+import { PasswordEntryModal } from "./PasswordEntryModal";
+import { PINEntryModal } from "./PINEntryModal";
interface PreviousServersListProps {
- onServerSelect: (server: Server) => void;
+ onServerSelect: (server: SavedServer) => void;
+ onQuickLogin?: (serverUrl: string, userId: string) => Promise;
+ onPasswordLogin?: (
+ serverUrl: string,
+ username: string,
+ password: string,
+ ) => Promise;
+ onAddAccount?: (server: SavedServer) => void;
}
export const PreviousServersList: React.FC = ({
onServerSelect,
+ onQuickLogin,
+ onPasswordLogin,
+ onAddAccount,
}) => {
const [_previousServers, setPreviousServers] =
useMMKVString("previousServers");
+ const [loadingServer, setLoadingServer] = useState(null);
+
+ // Modal states
+ const [accountsSheetOpen, setAccountsSheetOpen] = useState(false);
+ const [selectedServer, setSelectedServer] = useState(
+ null,
+ );
+ const [pinModalVisible, setPinModalVisible] = useState(false);
+ const [passwordModalVisible, setPasswordModalVisible] = useState(false);
+ const [selectedAccount, setSelectedAccount] =
+ useState(null);
const previousServers = useMemo(() => {
- return JSON.parse(_previousServers || "[]") as Server[];
+ return JSON.parse(_previousServers || "[]") as SavedServer[];
}, [_previousServers]);
const { t } = useTranslation();
+ const refreshServers = () => {
+ const servers = getPreviousServers();
+ setPreviousServers(JSON.stringify(servers));
+ };
+
+ const handleAccountLogin = async (
+ server: SavedServer,
+ account: SavedServerAccount,
+ ) => {
+ switch (account.securityType) {
+ case "none":
+ // Quick login without protection
+ if (onQuickLogin) {
+ setLoadingServer(server.address);
+ try {
+ await onQuickLogin(server.address, account.userId);
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error
+ ? error.message
+ : t("server.session_expired");
+ const isSessionExpired = errorMessage.includes(
+ t("server.session_expired"),
+ );
+ Alert.alert(
+ isSessionExpired
+ ? t("server.session_expired")
+ : t("login.connection_failed"),
+ isSessionExpired ? t("server.please_login_again") : errorMessage,
+ [{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
+ );
+ } finally {
+ setLoadingServer(null);
+ }
+ }
+ break;
+
+ case "pin":
+ // Show PIN entry modal
+ setSelectedServer(server);
+ setSelectedAccount(account);
+ setPinModalVisible(true);
+ break;
+
+ case "password":
+ // Show password entry modal
+ setSelectedServer(server);
+ setSelectedAccount(account);
+ setPasswordModalVisible(true);
+ break;
+ }
+ };
+
+ const handleServerPress = async (server: SavedServer) => {
+ if (loadingServer) return; // Prevent double-tap
+
+ const accountCount = server.accounts?.length || 0;
+
+ if (accountCount === 0) {
+ // No saved accounts, go to manual login
+ onServerSelect(server);
+ } else {
+ // Has accounts, show account sheet (allows adding new account too)
+ setSelectedServer(server);
+ setAccountsSheetOpen(true);
+ }
+ };
+
+ const handlePinSuccess = async () => {
+ setPinModalVisible(false);
+ if (selectedServer && selectedAccount && onQuickLogin) {
+ setLoadingServer(selectedServer.address);
+ try {
+ await onQuickLogin(selectedServer.address, selectedAccount.userId);
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : t("server.session_expired");
+ const isSessionExpired = errorMessage.includes(
+ t("server.session_expired"),
+ );
+ Alert.alert(
+ isSessionExpired
+ ? t("server.session_expired")
+ : t("login.connection_failed"),
+ isSessionExpired ? t("server.please_login_again") : errorMessage,
+ [
+ {
+ text: t("common.ok"),
+ onPress: () => onServerSelect(selectedServer),
+ },
+ ],
+ );
+ } finally {
+ setLoadingServer(null);
+ setSelectedAccount(null);
+ setSelectedServer(null);
+ }
+ }
+ };
+
+ const handlePasswordSubmit = async (password: string) => {
+ if (selectedServer && selectedAccount && onPasswordLogin) {
+ await onPasswordLogin(
+ selectedServer.address,
+ selectedAccount.username,
+ password,
+ );
+ setPasswordModalVisible(false);
+ setSelectedAccount(null);
+ setSelectedServer(null);
+ }
+ };
+
+ const handleForgotPIN = async () => {
+ if (selectedServer && selectedAccount) {
+ await deleteAccountCredential(
+ selectedServer.address,
+ selectedAccount.userId,
+ );
+ refreshServers();
+ // Go to manual login
+ onServerSelect(selectedServer);
+ setSelectedAccount(null);
+ setSelectedServer(null);
+ }
+ };
+
+ const handleRemoveFirstCredential = async (serverUrl: string) => {
+ const server = previousServers.find((s) => s.address === serverUrl);
+ if (!server || server.accounts.length === 0) return;
+
+ Alert.alert(
+ t("server.remove_saved_login"),
+ t("server.remove_saved_login_description"),
+ [
+ { text: t("common.cancel"), style: "cancel" },
+ {
+ text: t("common.remove"),
+ style: "destructive",
+ onPress: async () => {
+ // Remove first account
+ await deleteAccountCredential(serverUrl, server.accounts[0].userId);
+ refreshServers();
+ },
+ },
+ ],
+ );
+ };
+
+ const handleRemoveServer = useCallback(
+ async (serverUrl: string) => {
+ await removeServerFromList(serverUrl);
+ refreshServers();
+ },
+ [setPreviousServers],
+ );
+
+ const renderRightActions = useCallback(
+ (serverUrl: string, swipeableRef: React.RefObject) => (
+ {
+ swipeableRef.current?.close();
+ handleRemoveServer(serverUrl);
+ }}
+ className='bg-red-600 justify-center items-center px-5'
+ >
+
+
+ ),
+ [handleRemoveServer],
+ );
+
+ const getServerSubtitle = (server: SavedServer): string | undefined => {
+ const accountCount = server.accounts?.length || 0;
+
+ if (accountCount > 1) {
+ return t("server.accounts_count", { count: accountCount });
+ }
+ if (accountCount === 1) {
+ return `${server.accounts[0].username} • ${t("server.saved")}`;
+ }
+ return server.name ? server.address : undefined;
+ };
+
+ const getSecurityIcon = (
+ server: SavedServer,
+ ): keyof typeof Ionicons.glyphMap | null => {
+ const accountCount = server.accounts?.length || 0;
+ if (accountCount === 0) return null;
+
+ if (accountCount > 1) {
+ return "people";
+ }
+
+ const account = server.accounts[0];
+ switch (account.securityType) {
+ case "pin":
+ return "keypad";
+ case "password":
+ return "lock-closed";
+ default:
+ return "key";
+ }
+ };
+
if (!previousServers.length) return null;
return (
{previousServers.map((s) => (
- onServerSelect(s)}
- title={s.address}
- showArrow
+ server={s}
+ loadingServer={loadingServer}
+ onPress={() => handleServerPress(s)}
+ onRemoveCredential={() => handleRemoveFirstCredential(s.address)}
+ renderRightActions={renderRightActions}
+ subtitle={getServerSubtitle(s)}
+ securityIcon={getSecurityIcon(s)}
/>
))}
= ({
textColor='red'
/>
+
+ {t("server.swipe_to_remove")}
+
+
+ {/* Account Selection Sheet */}
+ {
+ if (selectedServer) {
+ handleAccountLogin(selectedServer, account);
+ }
+ }}
+ onAddAccount={() => {
+ if (selectedServer && onAddAccount) {
+ onAddAccount(selectedServer);
+ }
+ }}
+ onAccountDeleted={refreshServers}
+ />
+
+ {/* PIN Entry Modal */}
+ {
+ setPinModalVisible(false);
+ setSelectedAccount(null);
+ setSelectedServer(null);
+ }}
+ onSuccess={handlePinSuccess}
+ onForgotPIN={handleForgotPIN}
+ serverUrl={selectedServer?.address || ""}
+ userId={selectedAccount?.userId || ""}
+ username={selectedAccount?.username || ""}
+ />
+
+ {/* Password Entry Modal */}
+ {
+ setPasswordModalVisible(false);
+ setSelectedAccount(null);
+ setSelectedServer(null);
+ }}
+ onSubmit={handlePasswordSubmit}
+ username={selectedAccount?.username || ""}
+ />
);
};
+
+interface ServerItemProps {
+ server: SavedServer;
+ loadingServer: string | null;
+ onPress: () => void;
+ onRemoveCredential: () => void;
+ renderRightActions: (
+ serverUrl: string,
+ swipeableRef: React.RefObject,
+ ) => React.ReactNode;
+ subtitle?: string;
+ securityIcon: keyof typeof Ionicons.glyphMap | null;
+}
+
+const ServerItem: React.FC = ({
+ server,
+ loadingServer,
+ onPress,
+ onRemoveCredential,
+ renderRightActions,
+ subtitle,
+ securityIcon,
+}) => {
+ const swipeableRef = useRef(null);
+ const hasAccounts = server.accounts?.length > 0;
+
+ return (
+
+ renderRightActions(server.address, swipeableRef)
+ }
+ overshootRight={false}
+ >
+
+ {loadingServer === server.address ? (
+
+ ) : hasAccounts && securityIcon ? (
+ {
+ e.stopPropagation();
+ onRemoveCredential();
+ }}
+ className='p-1'
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
+ >
+
+
+ ) : null}
+
+
+ );
+};
diff --git a/components/Ratings.tsx b/components/Ratings.tsx
index 5741233fb..2e06403fb 100644
--- a/components/Ratings.tsx
+++ b/components/Ratings.tsx
@@ -40,8 +40,8 @@ export const Ratings: React.FC = ({ item, ...props }) => {
> = ({
if (Platform.OS === "ios") {
return (
- > = ({
/>
) : null}
{children ? children : null}
-
+
);
}
if (fillColor)
return (
- > = ({
/>
) : null}
{children ? children : null}
-
+
);
if (background === false)
return (
- > = ({
/>
) : null}
{children ? children : null}
-
+
);
if (Platform.OS === "android")
return (
- > = ({
) : null}
{children ? children : null}
-
+
);
return (
-
+
> = ({
) : null}
{children ? children : null}
-
+
);
};
diff --git a/components/SaveAccountModal.tsx b/components/SaveAccountModal.tsx
new file mode 100644
index 000000000..81508adef
--- /dev/null
+++ b/components/SaveAccountModal.tsx
@@ -0,0 +1,258 @@
+import { Ionicons } from "@expo/vector-icons";
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import type React from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Platform, TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import type { AccountSecurityType } from "@/utils/secureCredentials";
+import { Button } from "./Button";
+import { Text } from "./common/Text";
+import { PinInput } from "./inputs/PinInput";
+
+interface SaveAccountModalProps {
+ visible: boolean;
+ onClose: () => void;
+ onSave: (securityType: AccountSecurityType, pinCode?: string) => void;
+ username: string;
+}
+
+interface SecurityOption {
+ type: AccountSecurityType;
+ titleKey: string;
+ descriptionKey: string;
+ icon: keyof typeof Ionicons.glyphMap;
+}
+
+const SECURITY_OPTIONS: SecurityOption[] = [
+ {
+ type: "none",
+ titleKey: "save_account.no_protection",
+ descriptionKey: "save_account.no_protection_desc",
+ icon: "flash-outline",
+ },
+ {
+ type: "pin",
+ titleKey: "save_account.pin_code",
+ descriptionKey: "save_account.pin_code_desc",
+ icon: "keypad-outline",
+ },
+ {
+ type: "password",
+ titleKey: "save_account.password",
+ descriptionKey: "save_account.password_desc",
+ icon: "lock-closed-outline",
+ },
+];
+
+export const SaveAccountModal: React.FC = ({
+ visible,
+ onClose,
+ onSave,
+ username,
+}) => {
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+ const bottomSheetModalRef = useRef(null);
+ const [selectedType, setSelectedType] = useState("none");
+ const [pinCode, setPinCode] = useState("");
+ const [pinError, setPinError] = useState(null);
+
+ const isAndroid = Platform.OS === "android";
+ const snapPoints = useMemo(
+ () => (isAndroid ? ["100%"] : ["70%"]),
+ [isAndroid],
+ );
+
+ const isPresentedRef = useRef(false);
+
+ useEffect(() => {
+ if (visible) {
+ bottomSheetModalRef.current?.present();
+ } else if (isPresentedRef.current) {
+ bottomSheetModalRef.current?.dismiss();
+ isPresentedRef.current = false;
+ }
+ }, [visible]);
+
+ const handleSheetChanges = useCallback(
+ (index: number) => {
+ if (index >= 0) {
+ isPresentedRef.current = true;
+ } else if (index === -1 && isPresentedRef.current) {
+ isPresentedRef.current = false;
+ resetState();
+ onClose();
+ }
+ },
+ [onClose],
+ );
+
+ const resetState = () => {
+ setSelectedType("none");
+ setPinCode("");
+ setPinError(null);
+ };
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const handleOptionSelect = (type: AccountSecurityType) => {
+ setSelectedType(type);
+ setPinCode("");
+ setPinError(null);
+ };
+
+ const handleSave = () => {
+ if (selectedType === "pin") {
+ if (pinCode.length !== 4) {
+ setPinError(t("pin.enter_4_digits") || "Enter 4 digits");
+ return;
+ }
+ onSave("pin", pinCode);
+ } else {
+ onSave(selectedType);
+ }
+ resetState();
+ };
+
+ const handleCancel = () => {
+ resetState();
+ onClose();
+ };
+
+ const canSave = () => {
+ if (selectedType === "pin") {
+ return pinCode.length === 4;
+ }
+ return true;
+ };
+
+ return (
+
+
+
+ {/* Header */}
+
+
+ {t("save_account.title")}
+
+ {username}
+
+
+ {/* PIN Entry Step */}
+ {selectedType === "pin" ? (
+
+
+
+ {t("pin.setup_pin")}
+
+
+ {pinError && (
+
+ {pinError}
+
+ )}
+
+
+ ) : (
+ /* Security Options */
+
+
+ {t("save_account.security_option")}
+
+
+ {SECURITY_OPTIONS.map((option, index) => (
+ handleOptionSelect(option.type)}
+ className={`flex-row items-center p-4 ${
+ index < SECURITY_OPTIONS.length - 1
+ ? "border-b border-neutral-700"
+ : ""
+ }`}
+ >
+
+
+
+
+
+ {t(option.titleKey)}
+
+
+ {t(option.descriptionKey)}
+
+
+
+ {selectedType === option.type && (
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ {/* Buttons */}
+
+
+
+
+
+
+
+ );
+};
diff --git a/components/SimilarItems.tsx b/components/SimilarItems.tsx
index b73e55183..ce09cbcf9 100644
--- a/components/SimilarItems.tsx
+++ b/components/SimilarItems.tsx
@@ -6,6 +6,7 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import MoviePoster from "@/components/posters/MoviePoster";
+import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { HorizontalScroll } from "./common/HorizontalScroll";
import { Text } from "./common/Text";
@@ -53,7 +54,7 @@ export const SimilarItems: React.FC = ({
(
= ({
const trigger = (
- {t("item_card.subtitles")}
+ {t("item_card.subtitles.label")}
= ({
{
- source?: MediaSourceInfo;
- onChange: (value: number) => void;
- selected?: number | undefined;
- streamType?: string;
- title: string;
-}
-
-export const TrackSheet: React.FC = ({
- source,
- onChange,
- selected,
- streamType,
- title,
- ...props
-}) => {
- const isTv = Platform.isTV;
- const { t } = useTranslation();
-
- const streams = useMemo(
- () => source?.MediaStreams?.filter((x) => x.Type === streamType),
- [source],
- );
-
- const selectedSteam = useMemo(
- () => streams?.find((x) => x.Index === selected),
- [streams, selected],
- );
-
- const noneOption = useMemo(
- () => ({ Index: -1, DisplayTitle: t("common.none") }),
- [t],
- );
-
- // Creates a modified data array that includes a "None" option for subtitles
- // We might want to possibly do this for other places, like audio?
- const addNoneToSubtitles = useMemo(() => {
- if (streamType === "Subtitle") {
- const result = streams ? [noneOption, ...streams] : [noneOption];
- return result;
- }
- return streams;
- }, [streams, streamType, noneOption]);
- const [open, setOpen] = useState(false);
-
- if (isTv || (streams && streams.length === 0)) return null;
-
- return (
-
-
- {title}
- setOpen(true)}
- >
-
- {selected === -1 && streamType === "Subtitle"
- ? t("common.none")
- : selectedSteam?.DisplayTitle || t("common.select", "Select")}
-
-
-
- {
- const label = (item as any).DisplayTitle || "";
- return label.toLowerCase().includes(query.toLowerCase());
- }}
- renderItemLabel={(item) => (
- {(item as any).DisplayTitle || ""}
- )}
- set={(vals) => {
- const chosen = vals[0] as any;
- if (chosen && chosen.Index !== null && chosen.Index !== undefined) {
- onChange(chosen.Index);
- }
- }}
- />
-
- );
-};
diff --git a/components/WatchedIndicator.tsx b/components/WatchedIndicator.tsx
index c815eaf98..145ee7e0e 100644
--- a/components/WatchedIndicator.tsx
+++ b/components/WatchedIndicator.tsx
@@ -1,14 +1,119 @@
+import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import type React from "react";
-import { View } from "react-native";
+import React from "react";
+import { Platform, View, type ViewStyle } from "react-native";
+import { Text } from "@/components/common/Text";
+import { scaleSize } from "@/utils/scaleSize";
+
+const isAggregateType = (item: BaseItemDto) =>
+ item.Type === "Series" || item.Type === "BoxSet";
+
+// TV sizes are scaled relative to a 1920×1080 reference (see scaleSize).
+const tvBadgeBase: ViewStyle = {
+ position: "absolute",
+ top: scaleSize(8),
+ right: scaleSize(8),
+ height: scaleSize(28),
+ borderRadius: scaleSize(14),
+ backgroundColor: "rgba(255,255,255,0.92)",
+ alignItems: "center",
+ justifyContent: "center",
+};
+
+// Mobile uses raw dp — no scaling.
+const mobileBadgeBase: ViewStyle = {
+ position: "absolute",
+ top: 4,
+ right: 4,
+ height: 20,
+ borderRadius: 10,
+ backgroundColor: "#9333ea",
+ alignItems: "center",
+ justifyContent: "center",
+};
+
+/**
+ * Renders the unplayed-episode count badge for Series/BoxSet items that still
+ * have episodes left to watch. Returns null for non-aggregate types, fully
+ * watched items, or items with no unplayed count, so it is safe to mount
+ * unconditionally as an overlay (e.g. on top of the tvOS glass poster, where
+ * the watched checkmark is drawn natively and only the count needs RN).
+ */
+export const UnplayedCountBadge: React.FC<{ item: BaseItemDto }> = React.memo(
+ ({ item }) => {
+ if (!isAggregateType(item)) return null;
+ if (item.UserData?.Played) return null;
+ const unplayed = item.UserData?.UnplayedItemCount ?? 0;
+ if (unplayed <= 0) return null;
+ // Cap at 1k+ to keep the badge compact (jellyfin-web caps at 99+).
+ const label = unplayed >= 1000 ? "1k+" : String(unplayed);
+
+ if (Platform.isTV) {
+ return (
+
+
+ {label}
+
+
+ );
+ }
+
+ return (
+
+
+ {label}
+
+
+ );
+ },
+);
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
+ const isMovieOrEpisode = item.Type === "Movie" || item.Type === "Episode";
+ const isAggregate = isAggregateType(item);
+ const isPlayed = item.UserData?.Played === true;
+
+ if (Platform.isTV) {
+ // Fully watched → white checkmark badge (top-right)
+ if (isPlayed && (isMovieOrEpisode || isAggregate)) {
+ return (
+
+
+
+ );
+ }
+ // Series/BoxSet with remaining episodes → count badge
+ return ;
+ }
+
+ // Mobile: purple corner ribbon for unwatched Movie/Episode (existing behavior)
return (
<>
- {item.UserData?.Played === false &&
- (item.Type === "Movie" || item.Type === "Episode") && (
-
- )}
+ {/* Strict === false: items without UserData (unknown state) get no ribbon */}
+ {isMovieOrEpisode && item.UserData?.Played === false && (
+
+ )}
+
+ {/* Fully watched Series/BoxSet → small purple checkmark */}
+ {isAggregate && isPlayed && (
+
+
+
+ )}
+
+ {/* Series/BoxSet with remaining episodes → count badge */}
+
>
);
};
diff --git a/components/apple-tv-carousel/AppleTVCarousel.tsx b/components/apple-tv-carousel/AppleTVCarousel.tsx
deleted file mode 100644
index c30711e8a..000000000
--- a/components/apple-tv-carousel/AppleTVCarousel.tsx
+++ /dev/null
@@ -1,909 +0,0 @@
-import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import {
- getItemsApi,
- getTvShowsApi,
- getUserLibraryApi,
-} from "@jellyfin/sdk/lib/utils/api";
-import { useQuery } from "@tanstack/react-query";
-import { Image } from "expo-image";
-import { LinearGradient } from "expo-linear-gradient";
-import { useRouter } from "expo-router";
-import { useAtomValue } from "jotai";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import {
- Pressable,
- TouchableOpacity,
- useWindowDimensions,
- View,
-} from "react-native";
-import { Gesture, GestureDetector } from "react-native-gesture-handler";
-import Animated, {
- Easing,
- interpolate,
- runOnJS,
- type SharedValue,
- useAnimatedStyle,
- useSharedValue,
- withTiming,
-} from "react-native-reanimated";
-import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
-import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
-import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
-import { useNetworkStatus } from "@/hooks/useNetworkStatus";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { useSettings } from "@/utils/atoms/settings";
-import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
-import { ItemImage } from "../common/ItemImage";
-import { getItemNavigation } from "../common/TouchableItemRouter";
-import type { SelectedOptions } from "../ItemContent";
-import { PlayButton } from "../PlayButton";
-import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton";
-
-interface AppleTVCarouselProps {
- initialIndex?: number;
- onItemChange?: (index: number) => void;
- scrollOffset?: SharedValue;
-}
-
-// Layout Constants
-const GRADIENT_HEIGHT_TOP = 150;
-const GRADIENT_HEIGHT_BOTTOM = 150;
-const LOGO_HEIGHT = 80;
-
-// Position Constants
-const LOGO_BOTTOM_POSITION = 260;
-const GENRES_BOTTOM_POSITION = 220;
-const OVERVIEW_BOTTOM_POSITION = 165;
-const CONTROLS_BOTTOM_POSITION = 80;
-const DOTS_BOTTOM_POSITION = 40;
-
-// Size Constants
-const DOT_HEIGHT = 6;
-const DOT_ACTIVE_WIDTH = 20;
-const DOT_INACTIVE_WIDTH = 12;
-const PLAY_BUTTON_SKELETON_HEIGHT = 50;
-const PLAYED_STATUS_SKELETON_SIZE = 40;
-const TEXT_SKELETON_HEIGHT = 20;
-const TEXT_SKELETON_WIDTH = 250;
-const OVERVIEW_SKELETON_HEIGHT = 16;
-const OVERVIEW_SKELETON_WIDTH = 400;
-const _EMPTY_STATE_ICON_SIZE = 64;
-
-// Spacing Constants
-const HORIZONTAL_PADDING = 40;
-const DOT_PADDING = 2;
-const DOT_GAP = 4;
-const CONTROLS_GAP = 10;
-const _TEXT_MARGIN_TOP = 16;
-
-// Border Radius Constants
-const DOT_BORDER_RADIUS = 3;
-const LOGO_SKELETON_BORDER_RADIUS = 8;
-const TEXT_SKELETON_BORDER_RADIUS = 4;
-const PLAY_BUTTON_BORDER_RADIUS = 25;
-const PLAYED_STATUS_BORDER_RADIUS = 20;
-
-// Animation Constants
-const DOT_ANIMATION_DURATION = 300;
-const CAROUSEL_TRANSITION_DURATION = 250;
-const PAN_ACTIVE_OFFSET = 10;
-const TRANSLATION_THRESHOLD = 0.2;
-const VELOCITY_THRESHOLD = 400;
-
-// Text Constants
-const GENRES_FONT_SIZE = 16;
-const OVERVIEW_FONT_SIZE = 14;
-const _EMPTY_STATE_FONT_SIZE = 18;
-const TEXT_SHADOW_RADIUS = 2;
-const MAX_GENRES_COUNT = 2;
-const MAX_BUTTON_WIDTH = 300;
-const OVERVIEW_MAX_LINES = 2;
-const OVERVIEW_MAX_WIDTH = "80%";
-
-// Opacity Constants
-const OVERLAY_OPACITY = 0.3;
-const DOT_INACTIVE_OPACITY = 0.6;
-const TEXT_OPACITY = 0.9;
-
-// Color Constants
-const SKELETON_BACKGROUND_COLOR = "#1a1a1a";
-const SKELETON_ELEMENT_COLOR = "#333";
-const SKELETON_ACTIVE_DOT_COLOR = "#666";
-const _EMPTY_STATE_COLOR = "#666";
-const TEXT_SHADOW_COLOR = "rgba(0, 0, 0, 0.8)";
-const LOGO_WIDTH_PERCENTAGE = "80%";
-
-const DotIndicator = ({
- index,
- currentIndex,
- onPress,
-}: {
- index: number;
- currentIndex: number;
- onPress: (index: number) => void;
-}) => {
- const isActive = index === currentIndex;
-
- const animatedStyle = useAnimatedStyle(() => ({
- width: withTiming(isActive ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH, {
- duration: DOT_ANIMATION_DURATION,
- easing: Easing.out(Easing.quad),
- }),
- opacity: withTiming(isActive ? 1 : DOT_INACTIVE_OPACITY, {
- duration: DOT_ANIMATION_DURATION,
- easing: Easing.out(Easing.quad),
- }),
- }));
-
- return (
- onPress(index)}
- style={{
- padding: DOT_PADDING, // Increase touch area
- }}
- >
-
-
- );
-};
-
-export const AppleTVCarousel: React.FC = ({
- initialIndex = 0,
- onItemChange,
- scrollOffset,
-}) => {
- const { settings } = useSettings();
- const api = useAtomValue(apiAtom);
- const user = useAtomValue(userAtom);
- const { isConnected, serverConnected } = useNetworkStatus();
- const router = useRouter();
- const { width: screenWidth, height: screenHeight } = useWindowDimensions();
- const isLandscape = screenWidth >= screenHeight;
- const carouselHeight = useMemo(
- () => (isLandscape ? screenHeight * 0.9 : screenHeight / 1.45),
- [isLandscape, screenHeight],
- );
- const [currentIndex, setCurrentIndex] = useState(initialIndex);
- const translateX = useSharedValue(-initialIndex * screenWidth);
-
- const isQueryEnabled =
- !!api && !!user?.Id && isConnected && serverConnected === true;
-
- const { data: continueWatchingData, isLoading: continueWatchingLoading } =
- useQuery({
- queryKey: ["appleTVCarousel", "continueWatching", user?.Id],
- queryFn: async () => {
- if (!api || !user?.Id) return [];
- const response = await getItemsApi(api).getResumeItems({
- userId: user.Id,
- enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
- includeItemTypes: ["Movie", "Series", "Episode"],
- fields: ["Genres", "Overview"],
- limit: 2,
- });
- return response.data.Items || [];
- },
- enabled: isQueryEnabled,
- staleTime: 60 * 1000,
- });
-
- const { data: nextUpData, isLoading: nextUpLoading } = useQuery({
- queryKey: ["appleTVCarousel", "nextUp", user?.Id],
- queryFn: async () => {
- if (!api || !user?.Id) return [];
- const response = await getTvShowsApi(api).getNextUp({
- userId: user.Id,
- fields: ["MediaSourceCount", "Genres", "Overview"],
- limit: 2,
- enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
- enableResumable: false,
- });
- return response.data.Items || [];
- },
- enabled: isQueryEnabled,
- staleTime: 60 * 1000,
- });
-
- const { data: recentlyAddedData, isLoading: recentlyAddedLoading } = useQuery(
- {
- queryKey: ["appleTVCarousel", "recentlyAdded", user?.Id],
- queryFn: async () => {
- if (!api || !user?.Id) return [];
- const response = await getUserLibraryApi(api).getLatestMedia({
- userId: user.Id,
- limit: 2,
- fields: ["PrimaryImageAspectRatio", "Path", "Genres", "Overview"],
- imageTypeLimit: 1,
- enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
- });
- return response.data || [];
- },
- enabled: isQueryEnabled,
- staleTime: 60 * 1000,
- },
- );
-
- const items = useMemo(() => {
- const continueItems = continueWatchingData ?? [];
- const nextItems = nextUpData ?? [];
- const recentItems = recentlyAddedData ?? [];
-
- const allItems = [
- ...continueItems.slice(0, 2),
- ...nextItems.slice(0, 2),
- ...recentItems.slice(0, 2),
- ];
-
- // Deduplicate by item ID to prevent duplicate keys
- const seen = new Set();
- return allItems.filter((item) => {
- if (item.Id && !seen.has(item.Id)) {
- seen.add(item.Id);
- return true;
- }
- return false;
- });
- }, [continueWatchingData, nextUpData, recentlyAddedData]);
-
- const isLoading =
- continueWatchingLoading || nextUpLoading || recentlyAddedLoading;
- const hasItems = items.length > 0;
-
- // Only get play settings if we have valid items
- const currentItem = hasItems ? items[currentIndex] : null;
-
- // Extract colors for the current item only (for performance)
- const currentItemColors = useImageColorsReturn({ item: currentItem });
-
- // Create a fallback empty item for useDefaultPlaySettings when no item is available
- const itemForPlaySettings = currentItem || { MediaSources: [] };
- const {
- defaultAudioIndex,
- defaultBitrate,
- defaultMediaSource,
- defaultSubtitleIndex,
- } = useDefaultPlaySettings(itemForPlaySettings as BaseItemDto, settings);
-
- const [selectedOptions, setSelectedOptions] = useState<
- SelectedOptions | undefined
- >(undefined);
-
- useEffect(() => {
- // Only set options if we have valid current item
- if (currentItem) {
- setSelectedOptions({
- bitrate: defaultBitrate,
- mediaSource: defaultMediaSource,
- subtitleIndex: defaultSubtitleIndex ?? -1,
- audioIndex: defaultAudioIndex,
- });
- } else {
- setSelectedOptions(undefined);
- }
- }, [
- defaultAudioIndex,
- defaultBitrate,
- defaultSubtitleIndex,
- defaultMediaSource,
- currentIndex,
- currentItem,
- ]);
-
- useEffect(() => {
- if (!hasItems) {
- setCurrentIndex(initialIndex);
- translateX.value = -initialIndex * screenWidth;
- return;
- }
-
- setCurrentIndex((prev) => {
- const newIndex = Math.min(prev, items.length - 1);
- translateX.value = -newIndex * screenWidth;
- return newIndex;
- });
- }, [hasItems, items, initialIndex, screenWidth, translateX]);
-
- useEffect(() => {
- translateX.value = -currentIndex * screenWidth;
- }, [currentIndex, screenWidth, translateX]);
-
- useEffect(() => {
- if (hasItems) {
- onItemChange?.(currentIndex);
- }
- }, [hasItems, currentIndex, onItemChange]);
-
- const goToIndex = useCallback(
- (index: number) => {
- if (!hasItems || index < 0 || index >= items.length) return;
-
- translateX.value = withTiming(-index * screenWidth, {
- duration: CAROUSEL_TRANSITION_DURATION, // Slightly longer for smoother feel
- easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), // iOS-like smooth deceleration curve
- });
-
- setCurrentIndex(index);
- onItemChange?.(index);
- },
- [hasItems, items, onItemChange, screenWidth, translateX],
- );
-
- const navigateToItem = useCallback(
- (item: BaseItemDto) => {
- const navigation = getItemNavigation(item, "(home)");
- router.push(navigation as any);
- },
- [router],
- );
-
- const panGesture = Gesture.Pan()
- .activeOffsetX([-PAN_ACTIVE_OFFSET, PAN_ACTIVE_OFFSET])
- .onUpdate((event) => {
- translateX.value = -currentIndex * screenWidth + event.translationX;
- })
- .onEnd((event) => {
- const velocity = event.velocityX;
- const translation = event.translationX;
-
- let newIndex = currentIndex;
-
- // Improved thresholds for more responsive navigation
- if (
- Math.abs(translation) > screenWidth * TRANSLATION_THRESHOLD ||
- Math.abs(velocity) > VELOCITY_THRESHOLD
- ) {
- if (translation > 0 && currentIndex > 0) {
- newIndex = currentIndex - 1;
- } else if (
- translation < 0 &&
- items &&
- currentIndex < items.length - 1
- ) {
- newIndex = currentIndex + 1;
- }
- }
-
- runOnJS(goToIndex)(newIndex);
- });
-
- const containerAnimatedStyle = useAnimatedStyle(() => {
- return {
- transform: [{ translateX: translateX.value }],
- };
- });
-
- const togglePlayedStatus = useMarkAsPlayed(items);
-
- const headerAnimatedStyle = useAnimatedStyle(() => {
- if (!scrollOffset) return {};
- return {
- transform: [
- {
- translateY: interpolate(
- scrollOffset.value,
- [-carouselHeight, 0, carouselHeight],
- [-carouselHeight / 2, 0, carouselHeight * 0.75],
- ),
- },
- {
- scale: interpolate(
- scrollOffset.value,
- [-carouselHeight, 0, carouselHeight],
- [2, 1, 1],
- ),
- },
- ],
- };
- });
-
- const renderDots = () => {
- if (!hasItems || items.length <= 1) return null;
-
- return (
-
- {items.map((_, index) => (
-
- ))}
-
- );
- };
-
- const renderSkeletonLoader = () => {
- return (
-
- {/* Background Skeleton */}
-
-
- {/* Dark Overlay Skeleton */}
-
-
- {/* Gradient Fade to Black Top Skeleton */}
-
-
- {/* Gradient Fade to Black Bottom Skeleton */}
-
-
- {/* Logo Skeleton */}
-
-
-
-
- {/* Type and Genres Skeleton */}
-
-
-
-
- {/* Overview Skeleton */}
-
-
-
-
-
- {/* Controls Skeleton */}
-
- {/* Play Button Skeleton */}
-
-
- {/* Played Status Skeleton */}
-
-
-
- {/* Dots Skeleton */}
-
- {[1, 2, 3].map((_, index) => (
-
- ))}
-
-
- );
- };
-
- const renderItem = (item: BaseItemDto, _index: number) => {
- const itemLogoUrl = api ? getLogoImageUrlById({ api, item }) : null;
-
- return (
-
- {/* Background Backdrop */}
-
-
-
-
- {/* Dark Overlay */}
-
-
- {/* Gradient Fade to Black at Top */}
-
-
- {/* Gradient Fade to Black at Bottom */}
-
-
- {/* Logo Section */}
- {itemLogoUrl && (
- navigateToItem(item)}
- style={{
- position: "absolute",
- bottom: LOGO_BOTTOM_POSITION,
- left: 0,
- right: 0,
- paddingHorizontal: HORIZONTAL_PADDING,
- alignItems: "center",
- }}
- >
-
-
- )}
-
- {/* Type and Genres Section */}
-
- navigateToItem(item)}>
-
- {(() => {
- let typeLabel = "";
-
- if (item.Type === "Episode") {
- // For episodes, show season and episode number
- const season = item.ParentIndexNumber;
- const episode = item.IndexNumber;
- if (season && episode) {
- typeLabel = `S${season} • E${episode}`;
- } else {
- typeLabel = "Episode";
- }
- } else {
- typeLabel =
- item.Type === "Series"
- ? "TV Show"
- : item.Type === "Movie"
- ? "Movie"
- : item.Type || "";
- }
-
- const genres =
- item.Genres && item.Genres.length > 0
- ? item.Genres.slice(0, MAX_GENRES_COUNT).join(" • ")
- : "";
-
- if (typeLabel && genres) {
- return `${typeLabel} • ${genres}`;
- } else if (typeLabel) {
- return typeLabel;
- } else if (genres) {
- return genres;
- } else {
- return "";
- }
- })()}
-
-
-
-
- {/* Overview Section - for Episodes and Movies */}
- {(item.Type === "Episode" || item.Type === "Movie") &&
- item.Overview && (
-
- navigateToItem(item)}>
-
- {item.Overview}
-
-
-
- )}
-
- {/* Controls Section */}
-
-
- {/* Play Button */}
-
- {selectedOptions && (
-
- )}
-
-
- {/* Mark as Played */}
-
-
-
-
- );
- };
-
- // Handle loading state
- if (isLoading) {
- return (
-
- {renderSkeletonLoader()}
-
- );
- }
-
- // Handle empty items
- if (!hasItems) {
- return null;
- }
-
- return (
-
-
-
- {items.map((item, index) => renderItem(item, index))}
-
-
-
- {/* Animated Dots Indicator */}
- {renderDots()}
-
- );
-};
diff --git a/components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx b/components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx
deleted file mode 100644
index ea9bd98df..000000000
--- a/components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { Button, Host } from "@expo/ui/swift-ui";
-import { Ionicons } from "@expo/vector-icons";
-import { Platform, View } from "react-native";
-import { RoundButton } from "../RoundButton";
-
-interface MarkAsPlayedLargeButtonProps {
- isPlayed: boolean;
- onToggle: (isPlayed: boolean) => void;
-}
-
-export const MarkAsPlayedLargeButton: React.FC<
- MarkAsPlayedLargeButtonProps
-> = ({ isPlayed, onToggle }) => {
- if (Platform.OS === "ios")
- return (
-
-
-
- );
-
- return (
-
- onToggle(isPlayed)}
- />
-
- );
-};
diff --git a/components/chapters/ChapterList.tsx b/components/chapters/ChapterList.tsx
new file mode 100644
index 000000000..e44332095
--- /dev/null
+++ b/components/chapters/ChapterList.tsx
@@ -0,0 +1,209 @@
+/**
+ * A modal listing an item's chapters. Each row shows the chapter name and its
+ * timestamp; the current chapter is highlighted. Tapping a row seeks to that
+ * chapter and closes the modal. Player-agnostic — the seek is injected.
+ */
+
+import { Ionicons } from "@expo/vector-icons";
+import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
+import { memo, useEffect, useMemo, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { Colors } from "@/constants/Colors";
+import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
+import {
+ type ChapterEntry,
+ chapterStartsMs,
+ formatChapterTime,
+ sortedChapters,
+} from "@/utils/chapters";
+
+interface ChapterListProps {
+ visible: boolean;
+ chapters: ChapterInfo[] | null | undefined;
+ /** Current playback position in milliseconds (to highlight the row). */
+ currentPositionMs: number;
+ /** Seek the player to this millisecond position. */
+ onSeek: (positionMs: number) => void;
+ onClose: () => void;
+}
+
+const ROW_HEIGHT = 48;
+
+function ChapterListComponent({
+ visible,
+ chapters,
+ currentPositionMs,
+ onSeek,
+ onClose,
+}: ChapterListProps) {
+ const { t } = useTranslation();
+ const safeArea = useControlsSafeAreaInsets();
+ const listRef = useRef>(null);
+
+ const entries = useMemo(() => sortedChapters(chapters), [chapters]);
+ // Memoize starts so currentChapterIndex computation doesn't re-sort/filter
+ // every tick — chapters is the only input that drives the underlying array.
+ const starts = useMemo(() => chapterStartsMs(chapters), [chapters]);
+ const activeIndex = useMemo(() => {
+ let idx = -1;
+ for (let i = 0; i < starts.length; i++) {
+ if (currentPositionMs >= starts[i]) idx = i;
+ else break;
+ }
+ return idx;
+ }, [currentPositionMs, starts]);
+
+ // FlatList.initialScrollIndex only fires at first mount; keeps its
+ // children mounted across visible toggles, so subsequent opens never scroll.
+ // Trigger an imperative scroll each time the sheet becomes visible.
+ useEffect(() => {
+ if (!visible || activeIndex < 0 || entries.length === 0) return;
+ const raf = requestAnimationFrame(() => {
+ listRef.current?.scrollToIndex({
+ index: activeIndex,
+ animated: false,
+ viewPosition: 0.5,
+ });
+ });
+ return () => cancelAnimationFrame(raf);
+ }, [visible, activeIndex, entries.length]);
+
+ return (
+ to portrait-only; without this it rotates the app
+ // back to portrait when opened from the landscape player. Android ignores it.
+ supportedOrientations={["portrait", "landscape"]}
+ >
+
+ e.stopPropagation()}
+ style={[
+ styles.sheet,
+ {
+ marginLeft: safeArea.left,
+ marginRight: safeArea.right,
+ paddingBottom: safeArea.bottom,
+ },
+ ]}
+ >
+
+ {t("chapters.title")}
+
+
+
+
+ `${item.positionMs}-${index}`}
+ getItemLayout={(_, index) => ({
+ length: ROW_HEIGHT,
+ offset: ROW_HEIGHT * index,
+ index,
+ })}
+ onScrollToIndexFailed={(info) => {
+ // Required when getItemLayout is provided and the target index
+ // is outside the currently rendered window. Fallback to an
+ // offset-based scroll, then retry the precise scroll once a
+ // frame has elapsed.
+ listRef.current?.scrollToOffset({
+ offset: info.averageItemLength * info.index,
+ animated: false,
+ });
+ setTimeout(() => {
+ listRef.current?.scrollToIndex({
+ index: info.index,
+ animated: false,
+ viewPosition: 0.5,
+ });
+ }, 50);
+ }}
+ renderItem={({ item, index }) => {
+ const positionMs = item.positionMs;
+ const isActive = index === activeIndex;
+ return (
+ {
+ onSeek(positionMs);
+ onClose();
+ }}
+ style={[
+ styles.row,
+ isActive && { backgroundColor: `${Colors.primary}33` },
+ ]}
+ >
+
+ {item.chapter.Name ||
+ t("chapters.chapter_number", { number: index + 1 })}
+
+
+ {formatChapterTime(positionMs)}
+
+
+ );
+ }}
+ />
+
+
+
+ );
+}
+
+export const ChapterList = memo(ChapterListComponent);
+
+const styles = StyleSheet.create({
+ backdrop: {
+ flex: 1,
+ justifyContent: "flex-end",
+ },
+ sheet: {
+ backgroundColor: Colors.background,
+ borderTopLeftRadius: 16,
+ borderTopRightRadius: 16,
+ maxHeight: "70%",
+ },
+ header: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ padding: 16,
+ },
+ title: {
+ color: Colors.text,
+ fontSize: 17,
+ fontWeight: "700",
+ },
+ row: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ paddingHorizontal: 16,
+ height: ROW_HEIGHT,
+ },
+ rowText: {
+ fontSize: 15,
+ flex: 1,
+ },
+ rowTime: {
+ color: Colors.icon,
+ fontSize: 13,
+ marginLeft: 12,
+ },
+});
diff --git a/components/chapters/ChapterTicks.tsx b/components/chapters/ChapterTicks.tsx
new file mode 100644
index 000000000..850c63bf0
--- /dev/null
+++ b/components/chapters/ChapterTicks.tsx
@@ -0,0 +1,87 @@
+/**
+ * Chapter tick marks drawn as an absolute overlay over a progress slider.
+ * Renders nothing for media with one or zero chapters. `pointerEvents: "none"`
+ * so the slider underneath still receives touches.
+ */
+
+import { memo, useState } from "react";
+import { type LayoutChangeEvent, PixelRatio, View } from "react-native";
+import type { ChapterMarker } from "@/utils/chapters";
+
+interface ChapterTicksProps {
+ /** Pre-computed markers (caller memoizes — avoids double-computing here). */
+ markers: ChapterMarker[];
+ /** Tick colour. */
+ color?: string;
+ /** Tick height in px — slightly less than the slider track thickness. */
+ height?: number;
+ /** Tick width in px — integer to avoid sub-pixel anti-aliasing. */
+ width?: number;
+}
+
+function ChapterTicksComponent({
+ markers,
+ // Semi-transparent black contrasts against both the filled progress
+ // (#fff) and the unfilled track (rgba(255,255,255,0.2)) so the ticks
+ // stay visible across the whole bar as playback advances.
+ color = "rgba(0,0,0,0.55)",
+ height = 14,
+ width = 2,
+}: ChapterTicksProps) {
+ // Hooks must run unconditionally — keep them before any early return.
+ const [sliderWidth, setSliderWidth] = useState(0);
+
+ const handleLayout = (e: LayoutChangeEvent) => {
+ setSliderWidth(e.nativeEvent.layout.width);
+ };
+
+ // One chapter (typically a single marker at 0) is not worth marking.
+ if (markers.length <= 1) return null;
+
+ return (
+
+ {sliderWidth > 0 &&
+ markers
+ // Skip the leading 0ms marker — it overlaps the slider start and
+ // adds visual noise at an already-rendered boundary.
+ .filter((marker) => marker.positionMs > 0)
+ .map((marker, index) => {
+ // Align both the position AND the width onto the device's
+ // physical pixel grid. Without this, fractional dp values land
+ // at different sub-pixel fractions per tick — Android samples
+ // each one differently and some ticks render visibly thicker.
+ const centerDp = (marker.percent / 100) * sliderWidth;
+ const left = PixelRatio.roundToNearestPixel(centerDp - width / 2);
+ const snappedWidth = PixelRatio.roundToNearestPixel(width);
+ return (
+
+ );
+ })}
+
+ );
+}
+
+export const ChapterTicks = memo(ChapterTicksComponent);
diff --git a/components/common/HeaderBackButton.tsx b/components/common/HeaderBackButton.tsx
index 686cab5d6..8818a9a09 100644
--- a/components/common/HeaderBackButton.tsx
+++ b/components/common/HeaderBackButton.tsx
@@ -1,42 +1,36 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView, type BlurViewProps } from "expo-blur";
-import { useRouter } from "expo-router";
-import {
- Platform,
- TouchableOpacity,
- type TouchableOpacityProps,
-} from "react-native";
+import { Platform } from "react-native";
+import { Pressable, type PressableProps } from "react-native-gesture-handler";
+import useRouter from "@/hooks/useAppRouter";
interface Props extends BlurViewProps {
background?: "blur" | "transparent";
- touchableOpacityProps?: TouchableOpacityProps;
+ pressableProps?: Omit;
}
export const HeaderBackButton: React.FC = ({
background = "transparent",
- touchableOpacityProps,
+ pressableProps,
...props
}) => {
const router = useRouter();
if (Platform.OS === "ios") {
return (
- router.back()}
className='flex items-center justify-center w-9 h-9'
- {...touchableOpacityProps}
+ {...pressableProps}
>
-
+
);
}
if (background === "transparent" && Platform.OS !== "android")
return (
- router.back()}
- {...touchableOpacityProps}
- >
+ router.back()} {...pressableProps}>
= ({
color='white'
/>
-
+
);
return (
- router.back()}
className=' rounded-full p-2'
- {...touchableOpacityProps}
+ {...pressableProps}
>
= ({
size={24}
color='white'
/>
-
+
);
};
diff --git a/components/common/HorizontalScroll.tsx b/components/common/HorizontalScroll.tsx
index 6c6af8e72..e907f52f0 100644
--- a/components/common/HorizontalScroll.tsx
+++ b/components/common/HorizontalScroll.tsx
@@ -58,7 +58,7 @@ export const HorizontalScroll = (
if (!data || loading) {
return (
-
+
diff --git a/components/common/Input.tsx b/components/common/Input.tsx
index 8d7f602f8..7770f556c 100644
--- a/components/common/Input.tsx
+++ b/components/common/Input.tsx
@@ -1,50 +1,146 @@
-import React, { useState } from "react";
+import { Ionicons } from "@expo/vector-icons";
+import { BlurView } from "expo-blur";
+import { useRef, useState } from "react";
import {
+ Animated,
+ Easing,
Platform,
+ Pressable,
TextInput,
type TextInputProps,
- TouchableOpacity,
+ View,
} from "react-native";
+import { useScaledTVTypography } from "@/constants/TVTypography";
interface InputProps extends TextInputProps {
- extraClassName?: string; // new prop for additional classes
+ extraClassName?: string;
}
export function Input(props: InputProps) {
const { style, extraClassName = "", ...otherProps } = props;
- const inputRef = React.useRef(null);
+ const inputRef = useRef(null);
const [isFocused, setIsFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+ // TV-only: scales the input font with the tvTypographyScale setting.
+ // Not consumed by the mobile branch below.
+ const tvTypography = useScaledTVTypography();
- return Platform.isTV ? (
- inputRef?.current?.focus?.()}
- activeOpacity={1}
- >
- setIsFocused(true)}
- onBlur={() => setIsFocused(false)}
- {...otherProps}
- />
-
- ) : (
+ const animateFocus = (focused: boolean) => {
+ Animated.timing(scale, {
+ toValue: focused ? 1.02 : 1,
+ duration: 150,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start();
+ };
+
+ const handleFocus = () => {
+ setIsFocused(true);
+ animateFocus(true);
+ };
+
+ const handleBlur = () => {
+ setIsFocused(false);
+ animateFocus(false);
+ };
+
+ if (Platform.isTV) {
+ // Scale the whole input (box height, padding, icon) proportionally with the
+ // font so the component grows/shrinks with the tvTypographyScale setting.
+ // Uses the `body` token (primary reading size); it resolves to 28 at Default.
+ const fontSize = tvTypography.body;
+ const factor = fontSize / 28;
+ const height = Math.round(56 * factor);
+ const paddingLeft = Math.round(24 * factor);
+ const iconSize = Math.round(26 * factor);
+ const iconMarginRight = Math.round(14 * factor);
+
+ const containerStyle = {
+ height,
+ borderRadius: 50,
+ borderWidth: isFocused ? 1.5 : 1,
+ borderColor: isFocused
+ ? "rgba(255, 255, 255, 0.3)"
+ : "rgba(255, 255, 255, 0.1)",
+ overflow: "hidden" as const,
+ flexDirection: "row" as const,
+ alignItems: "center" as const,
+ paddingLeft,
+ };
+
+ const inputElement = (
+ <>
+
+
+ >
+ );
+
+ return (
+ inputRef.current?.focus()}
+ onFocus={handleFocus}
+ onBlur={handleBlur}
+ >
+
+ {Platform.OS === "ios" ? (
+
+ {inputElement}
+
+ ) : (
+
+ {inputElement}
+
+ )}
+
+
+ );
+ }
+
+ // Mobile version unchanged
+ return (
= ({ url }) => {
- if (!url)
- return (
-
-
-
- );
-
- return (
-
-
-
- );
-};
diff --git a/components/common/ProgressBar.tsx b/components/common/ProgressBar.tsx
index 1a47327e6..9c754cdc8 100644
--- a/components/common/ProgressBar.tsx
+++ b/components/common/ProgressBar.tsx
@@ -1,6 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useMemo } from "react";
-import { View } from "react-native";
+import { Platform, View } from "react-native";
interface ProgressBarProps {
item: BaseItemDto;
@@ -37,10 +37,12 @@ export const ProgressBar: React.FC = ({ item }) => {
}
/>
>
);
diff --git a/components/common/SectionHeader.tsx b/components/common/SectionHeader.tsx
new file mode 100644
index 000000000..aa9efc434
--- /dev/null
+++ b/components/common/SectionHeader.tsx
@@ -0,0 +1,42 @@
+import { TouchableOpacity, View } from "react-native";
+import { Colors } from "@/constants/Colors";
+import { Text } from "./Text";
+
+type Props = {
+ title: string;
+ actionLabel?: string;
+ actionDisabled?: boolean;
+ onPressAction?: () => void;
+};
+
+export const SectionHeader: React.FC = ({
+ title,
+ actionLabel,
+ actionDisabled = false,
+ onPressAction,
+}) => {
+ const shouldShowAction = Boolean(actionLabel) && Boolean(onPressAction);
+
+ return (
+
+ {title}
+ {shouldShowAction && (
+
+
+ {actionLabel}
+
+
+ )}
+
+ );
+};
diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx
index e95fd5e72..fed45dc99 100644
--- a/components/common/TouchableItemRouter.tsx
+++ b/components/common/TouchableItemRouter.tsx
@@ -1,14 +1,21 @@
import { useActionSheet } from "@expo/react-native-action-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { useRouter, useSegments } from "expo-router";
+import { useSegments } from "expo-router";
import { type PropsWithChildren, useCallback } from "react";
-import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
+import { useTranslation } from "react-i18next";
+import {
+ Platform,
+ TouchableOpacity,
+ type TouchableOpacityProps,
+} from "react-native";
+import useRouter from "@/hooks/useAppRouter";
import { useFavorite } from "@/hooks/useFavorite";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
+import { useDownload } from "@/providers/DownloadProvider";
+import { useOfflineMode } from "@/providers/OfflineModeProvider";
interface Props extends TouchableOpacityProps {
item: BaseItemDto;
- isOffline?: boolean;
}
export const itemRouter = (item: BaseItemDto, from: string) => {
@@ -16,6 +23,10 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
return `/(auth)/(tabs)/${from}/livetv`;
}
+ if ("CollectionType" in item && item.CollectionType === "music") {
+ return `/(auth)/(tabs)/(libraries)/music/${item.Id}`;
+ }
+
if (item.Type === "Series") {
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
}
@@ -50,6 +61,13 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
};
}
+ if ("CollectionType" in item && item.CollectionType === "music") {
+ return {
+ pathname: "/music/[libraryId]" as const,
+ params: { libraryId: item.Id! },
+ };
+ }
+
if (item.Type === "Series") {
return {
pathname: "/series/[id]" as const,
@@ -71,13 +89,55 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
};
}
- if (item.Type === "CollectionFolder" || item.Type === "Playlist") {
+ if (item.Type === "CollectionFolder") {
return {
pathname: "/[libraryId]" as const,
params: { libraryId: item.Id! },
};
}
+ // Music types - use shared routes for proper back navigation
+ if (item.Type === "MusicArtist") {
+ return {
+ pathname: "/music/artist/[artistId]" as const,
+ params: { artistId: item.Id! },
+ };
+ }
+
+ if (item.Type === "MusicAlbum") {
+ return {
+ pathname: "/music/album/[albumId]" as const,
+ params: { albumId: item.Id! },
+ };
+ }
+
+ if (item.Type === "Audio") {
+ // Navigate to the album if available, otherwise to the item page
+ if (item.AlbumId) {
+ return {
+ pathname: "/music/album/[albumId]" as const,
+ params: { albumId: item.AlbumId },
+ };
+ }
+ return {
+ pathname: "/items/page" as const,
+ params: { id: item.Id! },
+ };
+ }
+
+ if (item.Type === "Playlist") {
+ if (Platform.isTV) {
+ return {
+ pathname: "/[libraryId]" as const,
+ params: { libraryId: item.Id! },
+ };
+ }
+ return {
+ pathname: "/music/playlist/[playlistId]" as const,
+ params: { playlistId: item.Id! },
+ };
+ }
+
// Default case - items page
return {
pathname: "/items/page" as const,
@@ -87,18 +147,32 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
export const TouchableItemRouter: React.FC> = ({
item,
- isOffline = false,
children,
...props
}) => {
- const router = useRouter();
+ const { t } = useTranslation();
const segments = useSegments();
const { showActionSheetWithOptions } = useActionSheet();
const markAsPlayedStatus = useMarkAsPlayed([item]);
const { isFavorite, toggleFavorite } = useFavorite(item);
+ const router = useRouter();
+ const isOffline = useOfflineMode();
+ const { deleteFile } = useDownload();
const from = (segments as string[])[2] || "(home)";
+ const handlePress = useCallback(() => {
+ // Force music libraries to navigate via the explicit string route.
+ // This avoids losing the dynamic [libraryId] param when going through a nested navigator.
+ if ("CollectionType" in item && item.CollectionType === "music") {
+ router.push(itemRouter(item, from) as any);
+ return;
+ }
+
+ const navigation = getItemNavigation(item, from);
+ router.push(navigation as any);
+ }, [from, item, router]);
+
const showActionSheet = useCallback(() => {
if (
!(
@@ -108,18 +182,26 @@ export const TouchableItemRouter: React.FC> = ({
)
)
return;
- const options = [
- "Mark as Played",
- "Mark as Not Played",
- isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
- "Cancel",
+
+ const options: string[] = [
+ t("common.mark_as_played"),
+ t("common.mark_as_not_played"),
+ isFavorite
+ ? t("music.track_options.remove_from_favorites")
+ : t("music.track_options.add_to_favorites"),
+ ...(isOffline ? [t("home.downloads.delete_download")] : []),
+ t("common.cancel"),
];
- const cancelButtonIndex = 3;
+ const cancelButtonIndex = options.length - 1;
+ const destructiveButtonIndex = isOffline
+ ? cancelButtonIndex - 1
+ : undefined;
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
+ destructiveButtonIndex,
},
async (selectedIndex) => {
if (selectedIndex === 0) {
@@ -128,31 +210,33 @@ export const TouchableItemRouter: React.FC> = ({
await markAsPlayedStatus(false);
} else if (selectedIndex === 2) {
toggleFavorite();
+ } else if (isOffline && selectedIndex === 3 && item.Id) {
+ deleteFile(item.Id);
}
},
);
- }, [showActionSheetWithOptions, isFavorite, markAsPlayedStatus]);
+ }, [
+ showActionSheetWithOptions,
+ isFavorite,
+ markAsPlayedStatus,
+ toggleFavorite,
+ isOffline,
+ deleteFile,
+ item.Id,
+ t,
+ ]);
if (
from === "(home)" ||
from === "(search)" ||
from === "(libraries)" ||
- from === "(favorites)"
+ from === "(favorites)" ||
+ from === "(watchlists)"
)
return (
{
- if (isOffline) {
- // For offline mode, we still need to use query params
- const url = `${itemRouter(item, from)}&offline=true`;
- router.push(url as any);
- return;
- }
-
- const navigation = getItemNavigation(item, from);
- router.push(navigation as any);
- }}
+ onPress={handlePress}
{...props}
>
{children}
diff --git a/components/common/VerticalSkeleton.tsx b/components/common/VerticalSkeleton.tsx
deleted file mode 100644
index 02a8a2567..000000000
--- a/components/common/VerticalSkeleton.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { View, type ViewProps } from "react-native";
-
-interface Props extends ViewProps {
- index: number;
-}
-
-export const VerticalSkeleton: React.FC = ({ index, ...props }) => {
- return (
-
-
-
-
-
-
- );
-};
diff --git a/components/companion/CompanionLoginScreen.tsx b/components/companion/CompanionLoginScreen.tsx
new file mode 100644
index 000000000..95e92dffb
--- /dev/null
+++ b/components/companion/CompanionLoginScreen.tsx
@@ -0,0 +1,532 @@
+import { useAtom } from "jotai";
+import React, { useCallback, useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ KeyboardAvoidingView,
+ Linking,
+ Platform,
+ ScrollView,
+ TextInput,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { Button } from "@/components/Button";
+import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { sendCredentialsToTV } from "@/utils/pairingService";
+
+type ScreenState =
+ | "scanning"
+ | "no-permission"
+ | "confirm"
+ | "form"
+ | "sending"
+ | "success"
+ | "error";
+
+interface ParsedPairingCode {
+ code: string;
+}
+
+type ExpoCameraModule = typeof import("expo-camera");
+
+const ExpoCamera: ExpoCameraModule | null = Platform.isTV
+ ? null
+ : require("expo-camera");
+
+export const CompanionLoginScreen: React.FC = () => {
+ const { t } = useTranslation();
+ const router = useRouter();
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+
+ const [screenState, setScreenState] = useState(
+ Platform.isTV ? "form" : "scanning",
+ );
+ const [pairingCode, setPairingCode] = useState("");
+ const [serverUrl, setServerUrl] = useState("");
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [errorMessage, setErrorMessage] = useState(null);
+
+ // Pre-fill server URL and username from current session
+ useEffect(() => {
+ if (api?.basePath) {
+ setServerUrl(api.basePath);
+ }
+
+ if (user?.Name) {
+ setUsername(user.Name);
+ }
+ }, [api?.basePath, user?.Name]);
+
+ // Request camera permission
+ useEffect(() => {
+ if (!ExpoCamera) return;
+
+ ExpoCamera.Camera.getCameraPermissionsAsync().then((response) => {
+ if (!response.granted) {
+ ExpoCamera.Camera.requestCameraPermissionsAsync().then((result) => {
+ if (!result.granted) {
+ setScreenState("no-permission");
+ }
+ });
+ }
+ });
+ }, []);
+
+ const validateAndParseQR = useCallback(
+ (data: string): ParsedPairingCode | null => {
+ try {
+ const parsed = JSON.parse(data);
+
+ if (
+ parsed.action === "streamyfin-pair" &&
+ typeof parsed.code === "string" &&
+ parsed.code.length > 0
+ ) {
+ return { code: parsed.code };
+ }
+
+ return null;
+ } catch {
+ return null;
+ }
+ },
+ [],
+ );
+
+ const handleBarCodeScanned = useCallback(
+ ({ data }: { data: string }) => {
+ if (screenState !== "scanning") return;
+
+ const parsed = validateAndParseQR(data);
+
+ if (!parsed) {
+ setErrorMessage(t("companion_login.error_invalid_qr"));
+ setScreenState("error");
+ return;
+ }
+
+ setPairingCode(parsed.code);
+
+ // If user is logged in, show confirmation screen (still needs password)
+ // Otherwise, go straight to the full form
+ if (user?.Name && api?.basePath) {
+ setScreenState("confirm");
+ } else {
+ setScreenState("form");
+ }
+ },
+ [screenState, validateAndParseQR, t, user?.Name, api?.basePath],
+ );
+
+ const handleSendCredentials = useCallback(async () => {
+ if (
+ !serverUrl.trim() ||
+ !username.trim() ||
+ !password.trim() ||
+ !pairingCode
+ ) {
+ return;
+ }
+
+ setScreenState("sending");
+
+ try {
+ await sendCredentialsToTV(
+ pairingCode,
+ serverUrl.trim(),
+ username.trim(),
+ password,
+ );
+
+ setScreenState("success");
+ } catch {
+ setErrorMessage(t("companion_login.error_generic"));
+ setScreenState("error");
+ }
+ }, [pairingCode, serverUrl, username, password, t]);
+
+ const handleScanAgain = useCallback(() => {
+ setPairingCode("");
+ setErrorMessage(null);
+ setPassword("");
+ setScreenState("scanning");
+ }, []);
+
+ const handleDone = useCallback(() => {
+ router.back();
+ }, [router]);
+
+ const handleUseDifferentUser = useCallback(() => {
+ setUsername("");
+ setPassword("");
+ setScreenState("form");
+ }, []);
+
+ const handleEnterCodeManually = useCallback(() => {
+ setScreenState("form");
+ }, []);
+
+ if (screenState === "no-permission") {
+ return (
+
+
+
+ {t("companion_login.error_permission_denied")}
+
+
+ {Platform.OS === "ios" && (
+ Linking.openSettings()}
+ className='mt-4 rounded-lg bg-purple-600 px-6 py-3'
+ >
+
+ {t("companion_login.open_settings")}
+
+
+ )}
+
+
+
+
+ );
+ }
+
+ if (screenState === "success") {
+ return (
+
+
+
+ {t("companion_login.success_title")}
+
+
+
+ {t("companion_login.pairing_tv_connecting")}
+
+
+
+
+
+ );
+ }
+
+ if (screenState === "error") {
+ return (
+
+
+
+ {t("companion_login.error_title")}
+
+
+
+ {errorMessage}
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ if (screenState === "sending") {
+ return (
+
+
+
+ {t("companion_login.authorizing")}
+
+
+
+ );
+ }
+
+ if (screenState === "confirm") {
+ return (
+
+
+
+ {t("companion_login.login_as", { username })}
+
+
+
+ {t("companion_login.on_server", {
+ server: serverUrl.replace(/^https?:\/\//, ""),
+ })}
+
+
+
+
+ {t("companion_login.pairing_code_label")}
+
+
+
+ {pairingCode}
+
+
+
+
+
+ {t("login.password_placeholder")}
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("companion_login.use_different_user")}
+
+
+
+
+
+ {t("companion_login.scan_again")}
+
+
+
+
+
+ );
+ }
+
+ if (screenState === "form") {
+ return (
+
+
+
+ {t("companion_login.pairing_enter_credentials")}
+
+
+
+
+ {t("companion_login.pairing_code_label")}
+
+
+
+
+
+
+
+ {t("companion_login.server")}
+
+
+
+
+
+
+
+ {t("login.username_placeholder")}
+
+
+
+
+
+
+
+ {t("login.password_placeholder")}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ const CameraView = ExpoCamera?.CameraView;
+
+ if (!CameraView) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Camera full screen */}
+
+
+ {/* Dark overlay */}
+
+
+ {/* Center scan area */}
+
+
+
+
+ {t("companion_login.align_qr")}
+
+
+
+
+ {t("companion_login.enter_code_manually")}
+
+
+
+
+ );
+};
diff --git a/components/downloads/DownloadCard.tsx b/components/downloads/DownloadCard.tsx
index 5453f32a6..c67f60583 100644
--- a/components/downloads/DownloadCard.tsx
+++ b/components/downloads/DownloadCard.tsx
@@ -1,7 +1,5 @@
import { Ionicons } from "@expo/vector-icons";
-import { useQueryClient } from "@tanstack/react-query";
import { Image } from "expo-image";
-import { useRouter } from "expo-router";
import { t } from "i18next";
import { useMemo } from "react";
import {
@@ -12,6 +10,8 @@ import {
} from "react-native";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useDownload } from "@/providers/DownloadProvider";
import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";
import { JobStatus } from "@/providers/Downloads/types";
@@ -37,7 +37,7 @@ interface DownloadCardProps extends TouchableOpacityProps {
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { cancelDownload } = useDownload();
const router = useRouter();
- const queryClient = useQueryClient();
+ const queryClient = useNetworkAwareQueryClient();
const handleDelete = async (id: string) => {
try {
@@ -116,7 +116,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
}, [process?.progress]);
// Return null after all hooks have been called
- if (!process || !process.item || !process.item.Id) {
+ if (!process?.item?.Id) {
return null;
}
diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx
index 3c57a17a7..f907bacfc 100644
--- a/components/downloads/EpisodeCard.tsx
+++ b/components/downloads/EpisodeCard.tsx
@@ -61,7 +61,6 @@ export const EpisodeCard: React.FC = ({ item }) => {
return (
diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx
index f02fe7962..9d805ddb6 100644
--- a/components/downloads/MovieCard.tsx
+++ b/components/downloads/MovieCard.tsx
@@ -67,7 +67,7 @@ export const MovieCard: React.FC = ({ item }) => {
}, [showActionSheetWithOptions, handleDeleteFile]);
return (
-
+
{base64Image ? (
= ({ items }) => {
const { deleteItems } = useDownload();
const { showActionSheetWithOptions } = useActionSheet();
+ const router = useRouter();
+ // Keyed on SeriesId so recycled FlashList cells re-read the correct poster
+ // instead of freezing the first-rendered series' image (empty deps bug).
const base64Image = useMemo(() => {
- return storage.getString(items[0].SeriesId!);
- }, []);
+ const seriesId = items[0]?.SeriesId;
+ return seriesId ? storage.getString(seriesId) : undefined;
+ }, [items[0]?.SeriesId]);
const deleteSeries = useCallback(
async () =>
@@ -46,7 +50,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
return (
router.push(`/downloads/${items[0].SeriesId}`)}
+ onPress={() =>
+ router.push({
+ pathname: "/series/[id]",
+ params: { id: items[0].SeriesId!, offline: "true" },
+ })
+ }
onLongPress={showActionSheet}
>
{base64Image ? (
diff --git a/components/filters/ResetFiltersButton.tsx b/components/filters/ResetFiltersButton.tsx
index c0cc6d687..856ccd3ba 100644
--- a/components/filters/ResetFiltersButton.tsx
+++ b/components/filters/ResetFiltersButton.tsx
@@ -2,6 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
import { useAtom } from "jotai";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import {
+ filterByAtom,
genreFilterAtom,
tagsFilterAtom,
yearFilterAtom,
@@ -13,11 +14,13 @@ export const ResetFiltersButton: React.FC = ({ ...props }) => {
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
+ const [selectedFilters, setSelectedFilters] = useAtom(filterByAtom);
if (
selectedGenres.length === 0 &&
selectedTags.length === 0 &&
- selectedYears.length === 0
+ selectedYears.length === 0 &&
+ selectedFilters.length === 0
) {
return null;
}
@@ -28,6 +31,7 @@ export const ResetFiltersButton: React.FC = ({ ...props }) => {
setSelectedGenres([]);
setSelectedTags([]);
setSelectedYears([]);
+ setSelectedFilters([]);
}}
className='bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1'
{...props}
diff --git a/components/home/Favorites.tsx b/components/home/Favorites.tsx
index b1b32d959..84fa36b99 100644
--- a/components/home/Favorites.tsx
+++ b/components/home/Favorites.tsx
@@ -9,6 +9,7 @@ import { Text, View } from "react-native";
// PNG ASSET
import heart from "@/assets/icons/heart.fill.png";
import { Colors } from "@/constants/Colors";
+import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { InfiniteScrollingCollectionList } from "./InfiniteScrollingCollectionList";
@@ -22,8 +23,10 @@ type FavoriteTypes =
type EmptyState = Record;
export const Favorites = () => {
+ const router = useRouter();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
+ const pageSize = 20;
const [emptyState, setEmptyState] = useState({
Series: false,
Movie: false,
@@ -91,35 +94,77 @@ export const Favorites = () => {
const fetchFavoriteSeries = useCallback(
({ pageParam }: { pageParam: number }) =>
- fetchFavoritesByType("Series", pageParam),
- [fetchFavoritesByType],
+ fetchFavoritesByType("Series", pageParam, pageSize),
+ [fetchFavoritesByType, pageSize],
);
const fetchFavoriteMovies = useCallback(
({ pageParam }: { pageParam: number }) =>
- fetchFavoritesByType("Movie", pageParam),
- [fetchFavoritesByType],
+ fetchFavoritesByType("Movie", pageParam, pageSize),
+ [fetchFavoritesByType, pageSize],
);
const fetchFavoriteEpisodes = useCallback(
({ pageParam }: { pageParam: number }) =>
- fetchFavoritesByType("Episode", pageParam),
- [fetchFavoritesByType],
+ fetchFavoritesByType("Episode", pageParam, pageSize),
+ [fetchFavoritesByType, pageSize],
);
const fetchFavoriteVideos = useCallback(
({ pageParam }: { pageParam: number }) =>
- fetchFavoritesByType("Video", pageParam),
- [fetchFavoritesByType],
+ fetchFavoritesByType("Video", pageParam, pageSize),
+ [fetchFavoritesByType, pageSize],
);
const fetchFavoriteBoxsets = useCallback(
({ pageParam }: { pageParam: number }) =>
- fetchFavoritesByType("BoxSet", pageParam),
- [fetchFavoritesByType],
+ fetchFavoritesByType("BoxSet", pageParam, pageSize),
+ [fetchFavoritesByType, pageSize],
);
const fetchFavoritePlaylists = useCallback(
({ pageParam }: { pageParam: number }) =>
- fetchFavoritesByType("Playlist", pageParam),
- [fetchFavoritesByType],
+ fetchFavoritesByType("Playlist", pageParam, pageSize),
+ [fetchFavoritesByType, pageSize],
);
+ const handleSeeAllSeries = useCallback(() => {
+ router.push({
+ pathname: "/(auth)/(tabs)/(favorites)/see-all",
+ params: { type: "Series", title: t("favorites.series") },
+ } as any);
+ }, [router]);
+
+ const handleSeeAllMovies = useCallback(() => {
+ router.push({
+ pathname: "/(auth)/(tabs)/(favorites)/see-all",
+ params: { type: "Movie", title: t("favorites.movies") },
+ } as any);
+ }, [router]);
+
+ const handleSeeAllEpisodes = useCallback(() => {
+ router.push({
+ pathname: "/(auth)/(tabs)/(favorites)/see-all",
+ params: { type: "Episode", title: t("favorites.episodes") },
+ } as any);
+ }, [router]);
+
+ const handleSeeAllVideos = useCallback(() => {
+ router.push({
+ pathname: "/(auth)/(tabs)/(favorites)/see-all",
+ params: { type: "Video", title: t("favorites.videos") },
+ } as any);
+ }, [router]);
+
+ const handleSeeAllBoxsets = useCallback(() => {
+ router.push({
+ pathname: "/(auth)/(tabs)/(favorites)/see-all",
+ params: { type: "BoxSet", title: t("favorites.boxsets") },
+ } as any);
+ }, [router]);
+
+ const handleSeeAllPlaylists = useCallback(() => {
+ router.push({
+ pathname: "/(auth)/(tabs)/(favorites)/see-all",
+ params: { type: "Playlist", title: t("favorites.playlists") },
+ } as any);
+ }, [router]);
+
return (
{areAllEmpty() && (
@@ -143,6 +188,8 @@ export const Favorites = () => {
queryKey={["home", "favorites", "series"]}
title={t("favorites.series")}
hideIfEmpty
+ pageSize={pageSize}
+ onPressSeeAll={handleSeeAllSeries}
/>
{
title={t("favorites.movies")}
hideIfEmpty
orientation='vertical'
+ pageSize={pageSize}
+ onPressSeeAll={handleSeeAllMovies}
/>
);
diff --git a/components/home/Favorites.tv.tsx b/components/home/Favorites.tv.tsx
new file mode 100644
index 000000000..b76a7fe83
--- /dev/null
+++ b/components/home/Favorites.tv.tsx
@@ -0,0 +1,231 @@
+import type { Api } from "@jellyfin/sdk";
+import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
+import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
+import { Image } from "expo-image";
+import { useAtom } from "jotai";
+import { useCallback, useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { ScrollView, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import heart from "@/assets/icons/heart.fill.png";
+import { Text } from "@/components/common/Text";
+import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
+import { Colors } from "@/constants/Colors";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+
+const HORIZONTAL_PADDING = 60;
+const TOP_PADDING = 100;
+const SECTION_GAP = 10;
+
+type FavoriteTypes =
+ | "Series"
+ | "Movie"
+ | "Episode"
+ | "Video"
+ | "BoxSet"
+ | "Playlist";
+type EmptyState = Record;
+
+export const Favorites = () => {
+ const typography = useScaledTVTypography();
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const pageSize = 20;
+ const [emptyState, setEmptyState] = useState({
+ Series: false,
+ Movie: false,
+ Episode: false,
+ Video: false,
+ BoxSet: false,
+ Playlist: false,
+ });
+
+ const fetchFavoritesByType = useCallback(
+ async (
+ itemType: BaseItemKind,
+ startIndex: number = 0,
+ limit: number = 20,
+ ) => {
+ 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: false,
+ startIndex: startIndex,
+ limit: limit,
+ includeItemTypes: [itemType],
+ });
+ const items = response.data.Items || [];
+
+ if (startIndex === 0) {
+ setEmptyState((prev) => ({
+ ...prev,
+ [itemType as FavoriteTypes]: items.length === 0,
+ }));
+ }
+
+ return items;
+ },
+ [api, user],
+ );
+
+ useEffect(() => {
+ setEmptyState({
+ Series: false,
+ Movie: false,
+ Episode: false,
+ Video: false,
+ BoxSet: false,
+ Playlist: false,
+ });
+ }, [api, user]);
+
+ const areAllEmpty = () => {
+ const loadedCategories = Object.values(emptyState);
+ return (
+ loadedCategories.length > 0 &&
+ loadedCategories.every((isEmpty) => isEmpty)
+ );
+ };
+
+ const fetchFavoriteSeries = useCallback(
+ ({ pageParam }: { pageParam: number }) =>
+ fetchFavoritesByType("Series", pageParam, pageSize),
+ [fetchFavoritesByType, pageSize],
+ );
+ const fetchFavoriteMovies = useCallback(
+ ({ pageParam }: { pageParam: number }) =>
+ fetchFavoritesByType("Movie", pageParam, pageSize),
+ [fetchFavoritesByType, pageSize],
+ );
+ const fetchFavoriteEpisodes = useCallback(
+ ({ pageParam }: { pageParam: number }) =>
+ fetchFavoritesByType("Episode", pageParam, pageSize),
+ [fetchFavoritesByType, pageSize],
+ );
+ const fetchFavoriteVideos = useCallback(
+ ({ pageParam }: { pageParam: number }) =>
+ fetchFavoritesByType("Video", pageParam, pageSize),
+ [fetchFavoritesByType, pageSize],
+ );
+ const fetchFavoriteBoxsets = useCallback(
+ ({ pageParam }: { pageParam: number }) =>
+ fetchFavoritesByType("BoxSet", pageParam, pageSize),
+ [fetchFavoritesByType, pageSize],
+ );
+ const fetchFavoritePlaylists = useCallback(
+ ({ pageParam }: { pageParam: number }) =>
+ fetchFavoritesByType("Playlist", pageParam, pageSize),
+ [fetchFavoritesByType, pageSize],
+ );
+
+ if (areAllEmpty()) {
+ return (
+
+
+
+ {t("favorites.noDataTitle")}
+
+
+ {t("favorites.noData")}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/components/home/Home.tsx b/components/home/Home.tsx
index 5803083d5..060dafedb 100644
--- a/components/home/Home.tsx
+++ b/components/home/Home.tsx
@@ -12,7 +12,7 @@ import {
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import { type QueryFunction, useQuery } from "@tanstack/react-query";
-import { useNavigation, useRouter, useSegments } from "expo-router";
+import { useNavigation, useSegments } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -21,22 +21,32 @@ import {
Platform,
RefreshControl,
ScrollView,
- TouchableOpacity,
View,
} from "react-native";
+import { Pressable } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
+import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists";
+import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
+import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
+import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
+import { useIntroSheet } from "@/providers/IntroSheetProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
+import { storage } from "@/utils/mmkv";
+
+// Conditionally load TV version
+const HomeTV = Platform.isTV ? require("./Home.tv").Home : null;
type InfiniteScrollingCollectionListSection = {
type: "InfiniteScrollingCollectionList";
@@ -45,17 +55,20 @@ type InfiniteScrollingCollectionListSection = {
queryFn: QueryFunction;
orientation?: "horizontal" | "vertical";
pageSize?: number;
+ priority?: 1 | 2; // 1 = high priority (loads first), 2 = low priority
+ parentId?: string; // Library ID for "See All" navigation
};
type MediaListSectionType = {
type: "MediaListSection";
queryKey: (string | undefined)[];
queryFn: QueryFunction;
+ priority?: 1 | 2;
};
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
-export const Home = () => {
+const HomeMobile = () => {
const router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
@@ -74,7 +87,26 @@ export const Home = () => {
retryCheck,
} = useNetworkStatus();
const invalidateCache = useInvalidatePlaybackProgressCache();
- const [scrollY, setScrollY] = useState(0);
+ const [loadedSections, setLoadedSections] = useState>(new Set());
+ const { showIntro } = useIntroSheet();
+
+ // Fallback refresh for newly added content when returning to the home screen
+ // (primary path is the LibraryChanged WebSocket event).
+ useRefreshLibraryOnFocus();
+
+ // Show intro modal on first launch
+ useEffect(() => {
+ const hasShownIntro = storage.getBoolean("hasShownIntro");
+ if (!hasShownIntro) {
+ const timer = setTimeout(() => {
+ showIntro();
+ }, 1000);
+
+ return () => {
+ clearTimeout(timer);
+ };
+ }
+ }, [showIntro]);
useEffect(() => {
if (isConnected && !prevIsConnected.current) {
@@ -97,11 +129,10 @@ export const Home = () => {
}
navigation.setOptions({
headerLeft: () => (
- {
router.push("/(auth)/downloads");
}}
- className='ml-1.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
{
color={hasDownloads ? Colors.primary : "white"}
size={24}
/>
-
+
),
});
}, [navigation, router, hasDownloads]);
@@ -172,6 +203,7 @@ export const Home = () => {
const refetch = async () => {
setLoading(true);
+ setLoadedSections(new Set());
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
@@ -194,10 +226,10 @@ export const Home = () => {
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
- limit: 100, // Fetch a larger set for pagination
- fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
+ limit: 10,
+ fields: ["PrimaryImageAspectRatio"],
imageTypeLimit: 1,
- enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes,
parentId,
})
@@ -208,6 +240,7 @@ export const Home = () => {
},
type: "InfiniteScrollingCollectionList",
pageSize,
+ parentId,
}),
[api, user?.Id],
);
@@ -236,64 +269,143 @@ export const Home = () => {
);
});
+ // Helper to sort items by most recent activity
+ const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
+ return items.sort((a, b) => {
+ const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
+ const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
+ return new Date(dateB).getTime() - new Date(dateA).getTime();
+ });
+ };
+
+ // Helper to deduplicate items by ID
+ const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
+ const seen = new Set();
+ return items.filter((item) => {
+ if (!item.Id || seen.has(item.Id)) return false;
+ seen.add(item.Id);
+ return true;
+ });
+ };
+
+ // Build the first sections based on merge setting
+ const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
+ ? [
+ {
+ title: t("home.continue_and_next_up"),
+ queryKey: ["home", "continueAndNextUp"],
+ queryFn: async ({ pageParam = 0 }) => {
+ // Fetch both in parallel
+ const [resumeResponse, nextUpResponse] = await Promise.all([
+ getItemsApi(api).getResumeItems({
+ userId: user.Id,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ includeItemTypes: ["Movie", "Series", "Episode"],
+ startIndex: 0,
+ limit: 20,
+ }),
+ getTvShowsApi(api).getNextUp({
+ userId: user?.Id,
+ startIndex: 0,
+ limit: 20,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ enableResumable: false,
+ }),
+ ]);
+
+ const resumeItems = resumeResponse.data.Items || [];
+ const nextUpItems = nextUpResponse.data.Items || [];
+
+ // Combine, sort by recent activity, deduplicate
+ const combined = [...resumeItems, ...nextUpItems];
+ const sorted = sortByRecentActivity(combined);
+ const deduplicated = deduplicateById(sorted);
+
+ // Paginate client-side
+ return deduplicated.slice(pageParam, pageParam + 10);
+ },
+ type: "InfiniteScrollingCollectionList",
+ orientation: "horizontal",
+ pageSize: 10,
+ priority: 1,
+ },
+ ]
+ : [
+ {
+ title: t("home.continue_watching"),
+ queryKey: ["home", "resumeItems"],
+ queryFn: async ({ pageParam = 0 }) =>
+ (
+ await getItemsApi(api).getResumeItems({
+ userId: user.Id,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ includeItemTypes: ["Movie", "Series", "Episode"],
+ startIndex: pageParam,
+ limit: 10,
+ })
+ ).data.Items || [],
+ type: "InfiniteScrollingCollectionList",
+ orientation: "horizontal",
+ pageSize: 10,
+ priority: 1,
+ },
+ {
+ title: t("home.next_up"),
+ queryKey: ["home", "nextUp-all"],
+ queryFn: async ({ pageParam = 0 }) =>
+ (
+ await getTvShowsApi(api).getNextUp({
+ userId: user?.Id,
+ startIndex: pageParam,
+ limit: 10,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ enableResumable: false,
+ })
+ ).data.Items || [],
+ type: "InfiniteScrollingCollectionList",
+ orientation: "horizontal",
+ pageSize: 10,
+ priority: 1,
+ },
+ ];
+
const ss: Section[] = [
- {
- title: t("home.continue_watching"),
- queryKey: ["home", "resumeItems"],
- queryFn: async ({ pageParam = 0 }) =>
- (
- await getItemsApi(api).getResumeItems({
- userId: user.Id,
- enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
- includeItemTypes: ["Movie", "Series", "Episode"],
- fields: ["Genres"],
- startIndex: pageParam,
- limit: 10,
- })
- ).data.Items || [],
- type: "InfiniteScrollingCollectionList",
- orientation: "horizontal",
- pageSize: 10,
- },
- {
- title: t("home.next_up"),
- queryKey: ["home", "nextUp-all"],
- queryFn: async ({ pageParam = 0 }) =>
- (
- await getTvShowsApi(api).getNextUp({
- userId: user?.Id,
- fields: ["MediaSourceCount", "Genres"],
- startIndex: pageParam,
- limit: 10,
- enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
- enableResumable: false,
- })
- ).data.Items || [],
- type: "InfiniteScrollingCollectionList",
- orientation: "horizontal",
- pageSize: 10,
- },
- ...latestMediaViews,
- {
- title: t("home.suggested_movies"),
- queryKey: ["home", "suggestedMovies", user?.Id],
- queryFn: async ({ pageParam = 0 }) =>
- (
- await getSuggestionsApi(api).getSuggestions({
- userId: user?.Id,
- startIndex: pageParam,
- limit: 10,
- mediaType: ["Video"],
- type: ["Movie"],
- })
- ).data.Items || [],
- type: "InfiniteScrollingCollectionList",
- orientation: "vertical",
- pageSize: 10,
- },
+ ...firstSections,
+ ...latestMediaViews.map((s) => ({ ...s, priority: 2 as const })),
+ // Only show Jellyfin suggested movies if StreamyStats recommendations are disabled
+ ...(!settings?.streamyStatsMovieRecommendations
+ ? [
+ {
+ title: t("home.suggested_movies"),
+ queryKey: ["home", "suggestedMovies", user?.Id],
+ queryFn: async ({ pageParam = 0 }: { pageParam?: number }) =>
+ (
+ await getSuggestionsApi(api).getSuggestions({
+ userId: user?.Id,
+ startIndex: pageParam,
+ limit: 10,
+ mediaType: ["Video"],
+ type: ["Movie"],
+ })
+ ).data.Items || [],
+ type: "InfiniteScrollingCollectionList" as const,
+ orientation: "vertical" as const,
+ pageSize: 10,
+ priority: 2 as const,
+ },
+ ]
+ : []),
];
return ss;
- }, [api, user?.Id, collections, t, createCollectionConfig]);
+ }, [
+ api,
+ user?.Id,
+ collections,
+ t,
+ createCollectionConfig,
+ settings?.streamyStatsMovieRecommendations,
+ settings.mergeNextUpAndContinueWatching,
+ ]);
const customSections = useMemo(() => {
if (!api || !user?.Id || !settings?.home?.sections) return [];
@@ -322,10 +434,9 @@ export const Home = () => {
if (section.nextUp) {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
- fields: ["MediaSourceCount", "Genres"],
startIndex: pageParam,
limit: section.nextUp?.limit || pageSize,
- enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: section.nextUp?.enableResumable,
enableRewatching: section.nextUp?.enableRewatching,
});
@@ -338,7 +449,7 @@ export const Home = () => {
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
includeItemTypes: section.latest?.includeItemTypes,
- limit: section.latest?.limit || 100, // Fetch larger set
+ limit: section.latest?.limit || 10,
isPlayed: section.latest?.isPlayed,
groupItems: section.latest?.groupItems,
})
@@ -367,6 +478,8 @@ export const Home = () => {
type: "InfiniteScrollingCollectionList",
orientation: section?.orientation || "vertical",
pageSize,
+ // First 2 custom sections are high priority
+ priority: index < 2 ? 1 : 2,
});
});
return ss;
@@ -374,6 +487,25 @@ export const Home = () => {
const sections = settings?.home?.sections ? customSections : defaultSections;
+ // Get all high priority section keys and check if all have loaded
+ const highPrioritySectionKeys = useMemo(() => {
+ return sections
+ .filter((s) => s.priority === 1)
+ .map((s) => s.queryKey.join("-"));
+ }, [sections]);
+
+ const allHighPriorityLoaded = useMemo(() => {
+ return highPrioritySectionKeys.every((key) => loadedSections.has(key));
+ }, [highPrioritySectionKeys, loadedSections]);
+
+ const markSectionLoaded = useCallback(
+ (queryKey: (string | undefined | null)[]) => {
+ const key = queryKey.join("-");
+ setLoadedSections((prev) => new Set(prev).add(key));
+ },
+ [],
+ );
+
if (!isConnected || serverConnected !== true) {
let title = "";
let subtitle = "";
@@ -451,10 +583,6 @@ export const Home = () => {
ref={scrollRef}
nestedScrollEnabled
contentInsetAdjustmentBehavior='automatic'
- onScroll={(event) => {
- setScrollY(event.nativeEvent.contentOffset.y - 500);
- }}
- scrollEventThrottle={16}
refreshControl={
{
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
{sections.map((section, index) => {
+ // Render Streamystats sections after Recently Added sections
+ // For default sections: place after Recently Added, before Suggested Movies (if present)
+ // For custom sections: place at the very end
+ const hasSuggestedMovies =
+ !settings?.streamyStatsMovieRecommendations &&
+ !settings?.home?.sections;
+ const streamystatsIndex =
+ sections.length - 1 - (hasSuggestedMovies ? 1 : 0);
+ const hasStreamystatsContent =
+ settings.streamyStatsMovieRecommendations ||
+ settings.streamyStatsSeriesRecommendations ||
+ settings.streamyStatsPromotedWatchlists;
+ const streamystatsSections =
+ index === streamystatsIndex && hasStreamystatsContent ? (
+
+ {settings.streamyStatsMovieRecommendations && (
+
+ )}
+ {settings.streamyStatsSeriesRecommendations && (
+
+ )}
+ {settings.streamyStatsPromotedWatchlists && (
+
+ )}
+
+ ) : null;
if (section.type === "InfiniteScrollingCollectionList") {
+ const isHighPriority = section.priority === 1;
+ const handleSeeAll = section.parentId
+ ? () => {
+ router.push({
+ pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
+ params: {
+ libraryId: section.parentId!,
+ sortBy: SortByOption.DateCreated,
+ sortOrder: SortOrderOption.Descending,
+ },
+ } as any);
+ }
+ : undefined;
return (
-
+
+ markSectionLoaded(section.queryKey)
+ : undefined
+ }
+ onPressSeeAll={handleSeeAll}
+ />
+ {streamystatsSections}
+
);
}
if (section.type === "MediaListSection") {
return (
-
+
+
+ {streamystatsSections}
+
);
}
return null;
@@ -504,3 +697,11 @@ export const Home = () => {
);
};
+
+// Exported component that renders TV or mobile version based on platform
+export const Home = () => {
+ if (Platform.isTV && HomeTV) {
+ return ;
+ }
+ return ;
+};
diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx
new file mode 100644
index 000000000..8b20fe288
--- /dev/null
+++ b/components/home/Home.tv.tsx
@@ -0,0 +1,839 @@
+import { Ionicons } from "@expo/vector-icons";
+import type {
+ BaseItemDto,
+ BaseItemDtoQueryResult,
+ BaseItemKind,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import {
+ getItemsApi,
+ getSuggestionsApi,
+ getTvShowsApi,
+ getUserLibraryApi,
+ getUserViewsApi,
+} from "@jellyfin/sdk/lib/utils/api";
+import { type QueryFunction, useQuery } from "@tanstack/react-query";
+import { Image } from "expo-image";
+import { LinearGradient } from "expo-linear-gradient";
+import { useAtomValue } from "jotai";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ ActivityIndicator,
+ Animated,
+ Easing,
+ ScrollView,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Button } from "@/components/Button";
+import { Text } from "@/components/common/Text";
+import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
+import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists.tv";
+import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv";
+import { TVHeroCarousel } from "@/components/home/TVHeroCarousel";
+import { Loader } from "@/components/Loader";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
+import { useNetworkStatus } from "@/hooks/useNetworkStatus";
+import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
+import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
+import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
+import {
+ apiAtom,
+ cacheVersionAtom,
+ userAtom,
+} from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
+import { scaleSize } from "@/utils/scaleSize";
+import { updateTVDiscovery } from "@/utils/tvDiscovery/sync";
+
+const HORIZONTAL_PADDING = scaleSize(60);
+const TOP_PADDING = scaleSize(100);
+// Generous gap between sections for Apple TV+ aesthetic
+const SECTION_GAP = scaleSize(24);
+
+type InfiniteScrollingCollectionListSection = {
+ type: "InfiniteScrollingCollectionList";
+ title?: string;
+ queryKey: (string | undefined | null)[];
+ queryFn: QueryFunction;
+ orientation?: "horizontal" | "vertical";
+ pageSize?: number;
+ parentId?: string;
+};
+
+type Section = InfiniteScrollingCollectionListSection;
+
+// Debounce delay in ms - prevents rapid backdrop changes when scrolling fast
+const BACKDROP_DEBOUNCE_MS = 300;
+
+export const Home = () => {
+ const typography = useScaledTVTypography();
+ const _router = useRouter();
+ const { t } = useTranslation();
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
+ const cacheVersion = useAtomValue(cacheVersionAtom);
+ const insets = useSafeAreaInsets();
+ const { settings } = useSettings();
+ const scrollRef = useRef(null);
+ const {
+ isConnected,
+ serverConnected,
+ loading: retryLoading,
+ retryCheck,
+ } = useNetworkStatus();
+ const _invalidateCache = useInvalidatePlaybackProgressCache();
+ const { showItemActions } = useTVItemActionModal();
+
+ // Fallback refresh for newly added content when returning to the home screen
+ // (primary path is the LibraryChanged WebSocket event).
+ useRefreshLibraryOnFocus();
+
+ // Dynamic backdrop state with debounce
+ const [focusedItem, setFocusedItem] = useState(null);
+ const debounceTimerRef = useRef | null>(null);
+
+ // Handle item focus with debounce
+ const handleItemFocus = useCallback((item: BaseItemDto) => {
+ // Clear any pending debounce timer
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current);
+ }
+ // Set new timer to update focused item after debounce delay
+ debounceTimerRef.current = setTimeout(() => {
+ setFocusedItem(item);
+ }, BACKDROP_DEBOUNCE_MS);
+ }, []);
+
+ // Cleanup debounce timer on unmount
+ useEffect(() => {
+ return () => {
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current);
+ }
+ };
+ }, []);
+
+ // Get backdrop URL from focused item (only if setting is enabled)
+ const backdropUrl = useMemo(() => {
+ if (!settings.showHomeBackdrop || !focusedItem) return null;
+ return getBackdropUrl({
+ api,
+ item: focusedItem,
+ quality: 90,
+ width: 1920,
+ });
+ }, [api, focusedItem, settings.showHomeBackdrop]);
+
+ // Crossfade animation for backdrop transitions
+ const [activeLayer, setActiveLayer] = useState<0 | 1>(0);
+ const [layer0Url, setLayer0Url] = useState(null);
+ const [layer1Url, setLayer1Url] = useState(null);
+ const layer0Opacity = useRef(new Animated.Value(0)).current;
+ const layer1Opacity = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ if (!backdropUrl) return;
+
+ let isCancelled = false;
+
+ const performCrossfade = async () => {
+ // Prefetch to disk only - the full-size 1920x1080 backdrop (~8MB
+ // decoded ARGB) is too large to pin in the memory cache on every
+ // focus change. Disk cache is fast enough for a 500ms crossfade.
+ try {
+ await Image.prefetch(backdropUrl, "disk");
+ } catch {
+ // Continue even if prefetch fails
+ }
+
+ if (isCancelled) return;
+
+ // Determine which layer to fade in
+ const incomingLayer = activeLayer === 0 ? 1 : 0;
+ const incomingOpacity =
+ incomingLayer === 0 ? layer0Opacity : layer1Opacity;
+ const outgoingOpacity =
+ incomingLayer === 0 ? layer1Opacity : layer0Opacity;
+
+ // Set the new URL on the incoming layer
+ if (incomingLayer === 0) {
+ setLayer0Url(backdropUrl);
+ } else {
+ setLayer1Url(backdropUrl);
+ }
+
+ // Small delay to ensure image component has the new URL
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ if (isCancelled) return;
+
+ // Crossfade: fade in the incoming layer, fade out the outgoing
+ Animated.parallel([
+ Animated.timing(incomingOpacity, {
+ toValue: 1,
+ duration: 500,
+ easing: Easing.inOut(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(outgoingOpacity, {
+ toValue: 0,
+ duration: 500,
+ easing: Easing.inOut(Easing.quad),
+ useNativeDriver: true,
+ }),
+ ]).start(() => {
+ if (!isCancelled) {
+ setActiveLayer(incomingLayer);
+ }
+ });
+ };
+
+ performCrossfade();
+
+ return () => {
+ isCancelled = true;
+ };
+ }, [backdropUrl]);
+
+ const {
+ data,
+ isError: e1,
+ isLoading: l1,
+ } = useQuery({
+ queryKey: ["home", "userViews", user?.Id],
+ queryFn: async () => {
+ if (!api || !user?.Id) {
+ return null;
+ }
+
+ const response = await getUserViewsApi(api).getUserViews({
+ userId: user.Id,
+ });
+
+ return response.data.Items || null;
+ },
+ enabled: !!api && !!user?.Id,
+ staleTime: 60 * 1000,
+ refetchInterval: 60 * 1000,
+ });
+
+ // Fetch hero items (Continue Watching + Next Up combined)
+ const { data: heroItems } = useQuery({
+ queryKey: ["home", "heroItems", user?.Id],
+ queryFn: async () => {
+ if (!api || !user?.Id) return [];
+
+ const [resumeResponse, nextUpResponse] = await Promise.all([
+ getItemsApi(api).getResumeItems({
+ userId: user.Id,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ includeItemTypes: ["Movie", "Series", "Episode"],
+ fields: ["Overview"],
+ startIndex: 0,
+ limit: 10,
+ }),
+ getTvShowsApi(api).getNextUp({
+ userId: user.Id,
+ startIndex: 0,
+ limit: 10,
+ fields: ["Overview"],
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ enableResumable: false,
+ }),
+ ]);
+
+ const resumeItems = resumeResponse.data.Items || [];
+ const nextUpItems = nextUpResponse.data.Items || [];
+
+ // Combine, sort by recent activity, and dedupe
+ const combined = [...resumeItems, ...nextUpItems];
+ const sorted = combined.sort((a, b) => {
+ const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
+ const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
+ return new Date(dateB).getTime() - new Date(dateA).getTime();
+ });
+
+ const seen = new Set();
+ const deduped: BaseItemDto[] = [];
+ for (const item of sorted) {
+ if (!item.Id || seen.has(item.Id)) continue;
+ seen.add(item.Id);
+ deduped.push(item);
+ }
+
+ return deduped.slice(0, 15);
+ },
+ enabled: !!api && !!user?.Id,
+ staleTime: 60 * 1000,
+ refetchInterval: 60 * 1000,
+ });
+
+ useEffect(() => {
+ updateTVDiscovery({
+ api,
+ sections: [
+ {
+ title: t("home.continue_and_next_up"),
+ items: heroItems,
+ },
+ ],
+ });
+ }, [api, heroItems, t]);
+
+ const userViews = useMemo(
+ () => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
+ [data, settings?.hiddenLibraries],
+ );
+
+ const collections = useMemo(() => {
+ const allow = ["movies", "tvshows"];
+ return (
+ userViews?.filter(
+ (c) => c.CollectionType && allow.includes(c.CollectionType),
+ ) || []
+ );
+ }, [userViews]);
+
+ const createCollectionConfig = useCallback(
+ (
+ title: string,
+ queryKey: string[],
+ includeItemTypes: BaseItemKind[],
+ parentId: string | undefined,
+ pageSize = 10,
+ ): InfiniteScrollingCollectionListSection => ({
+ title,
+ queryKey,
+ queryFn: async ({ pageParam = 0 }) => {
+ if (!api) return [];
+ const allData =
+ (
+ await getUserLibraryApi(api).getLatestMedia({
+ userId: user?.Id,
+ limit: 10,
+ fields: ["PrimaryImageAspectRatio"],
+ imageTypeLimit: 1,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ includeItemTypes,
+ parentId,
+ })
+ ).data || [];
+
+ return allData.slice(pageParam, pageParam + pageSize);
+ },
+ type: "InfiniteScrollingCollectionList",
+ pageSize,
+ parentId,
+ }),
+ [api, user?.Id],
+ );
+
+ const defaultSections = useMemo(() => {
+ if (!api || !user?.Id) return [];
+
+ const latestMediaViews = collections.map((c) => {
+ const includeItemTypes: BaseItemKind[] =
+ c.CollectionType === "tvshows" || c.CollectionType === "movies"
+ ? []
+ : ["Movie"];
+ const title = t("home.recently_added_in", { libraryName: c.Name });
+ const queryKey: string[] = [
+ "home",
+ `recentlyAddedIn${c.CollectionType}`,
+ user.Id!,
+ c.Id!,
+ ];
+ return createCollectionConfig(
+ title || "",
+ queryKey,
+ includeItemTypes,
+ c.Id,
+ 10,
+ );
+ });
+
+ const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
+ return items.sort((a, b) => {
+ const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
+ const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
+ return new Date(dateB).getTime() - new Date(dateA).getTime();
+ });
+ };
+
+ const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
+ const seen = new Set();
+ return items.filter((item) => {
+ if (!item.Id || seen.has(item.Id)) return false;
+ seen.add(item.Id);
+ return true;
+ });
+ };
+
+ const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
+ ? [
+ {
+ title: t("home.continue_and_next_up"),
+ queryKey: ["home", "continueAndNextUp"],
+ queryFn: async ({ pageParam = 0 }) => {
+ const [resumeResponse, nextUpResponse] = await Promise.all([
+ getItemsApi(api).getResumeItems({
+ userId: user.Id,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ includeItemTypes: ["Movie", "Series", "Episode"],
+ startIndex: 0,
+ limit: 20,
+ }),
+ getTvShowsApi(api).getNextUp({
+ userId: user?.Id,
+ startIndex: 0,
+ limit: 20,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ enableResumable: false,
+ }),
+ ]);
+
+ const resumeItems = resumeResponse.data.Items || [];
+ const nextUpItems = nextUpResponse.data.Items || [];
+
+ const combined = [...resumeItems, ...nextUpItems];
+ const sorted = sortByRecentActivity(combined);
+ const deduplicated = deduplicateById(sorted);
+
+ return deduplicated.slice(pageParam, pageParam + 10);
+ },
+ type: "InfiniteScrollingCollectionList",
+ orientation: "horizontal",
+ pageSize: 10,
+ },
+ ]
+ : [
+ {
+ title: t("home.continue_watching"),
+ queryKey: ["home", "resumeItems"],
+ queryFn: async ({ pageParam = 0 }) =>
+ (
+ await getItemsApi(api).getResumeItems({
+ userId: user.Id,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ includeItemTypes: ["Movie", "Series", "Episode"],
+ startIndex: pageParam,
+ limit: 10,
+ })
+ ).data.Items || [],
+ type: "InfiniteScrollingCollectionList",
+ orientation: "horizontal",
+ pageSize: 10,
+ },
+ {
+ title: t("home.next_up"),
+ queryKey: ["home", "nextUp-all"],
+ queryFn: async ({ pageParam = 0 }) =>
+ (
+ await getTvShowsApi(api).getNextUp({
+ userId: user?.Id,
+ startIndex: pageParam,
+ limit: 10,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ enableResumable: false,
+ })
+ ).data.Items || [],
+ type: "InfiniteScrollingCollectionList",
+ orientation: "horizontal",
+ pageSize: 10,
+ },
+ ];
+
+ const ss: Section[] = [
+ ...firstSections,
+ ...latestMediaViews,
+ ...(!settings?.streamyStatsMovieRecommendations
+ ? [
+ {
+ title: t("home.suggested_movies"),
+ queryKey: ["home", "suggestedMovies", user?.Id],
+ queryFn: async ({ pageParam = 0 }: { pageParam?: number }) =>
+ (
+ await getSuggestionsApi(api).getSuggestions({
+ userId: user?.Id,
+ startIndex: pageParam,
+ limit: 10,
+ mediaType: ["Video"],
+ type: ["Movie"],
+ })
+ ).data.Items || [],
+ type: "InfiniteScrollingCollectionList" as const,
+ orientation: "vertical" as const,
+ pageSize: 10,
+ },
+ ]
+ : []),
+ ];
+ return ss;
+ }, [
+ api,
+ user?.Id,
+ collections,
+ t,
+ createCollectionConfig,
+ settings?.streamyStatsMovieRecommendations,
+ settings.mergeNextUpAndContinueWatching,
+ ]);
+
+ const customSections = useMemo(() => {
+ if (!api || !user?.Id || !settings?.home?.sections) return [];
+ const ss: Section[] = [];
+ settings.home.sections.forEach((section, index) => {
+ const id = section.title || `section-${index}`;
+ const pageSize = 10;
+ ss.push({
+ title: t(`${id}`),
+ queryKey: ["home", "custom", String(index), section.title ?? null],
+ queryFn: async ({ pageParam = 0 }) => {
+ if (section.items) {
+ const response = await getItemsApi(api).getItems({
+ userId: user?.Id,
+ startIndex: pageParam,
+ limit: section.items?.limit || pageSize,
+ recursive: true,
+ includeItemTypes: section.items?.includeItemTypes,
+ sortBy: section.items?.sortBy,
+ sortOrder: section.items?.sortOrder,
+ filters: section.items?.filters,
+ parentId: section.items?.parentId,
+ });
+ return response.data.Items || [];
+ }
+ if (section.nextUp) {
+ const response = await getTvShowsApi(api).getNextUp({
+ userId: user?.Id,
+ startIndex: pageParam,
+ limit: section.nextUp?.limit || pageSize,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ enableResumable: section.nextUp?.enableResumable,
+ enableRewatching: section.nextUp?.enableRewatching,
+ });
+ return response.data.Items || [];
+ }
+ if (section.latest) {
+ const allData =
+ (
+ await getUserLibraryApi(api).getLatestMedia({
+ userId: user?.Id,
+ includeItemTypes: section.latest?.includeItemTypes,
+ limit: section.latest?.limit || 10,
+ isPlayed: section.latest?.isPlayed,
+ groupItems: section.latest?.groupItems,
+ })
+ ).data || [];
+
+ return allData.slice(pageParam, pageParam + pageSize);
+ }
+ if (section.custom) {
+ const response = await api.get(
+ section.custom.endpoint,
+ {
+ params: {
+ ...(section.custom.query || {}),
+ userId: user?.Id,
+ startIndex: pageParam,
+ limit: pageSize,
+ },
+ headers: section.custom.headers || {},
+ },
+ );
+ return response.data.Items || [];
+ }
+ return [];
+ },
+ type: "InfiniteScrollingCollectionList",
+ orientation: section?.orientation || "vertical",
+ pageSize,
+ });
+ });
+ return ss;
+ }, [api, user?.Id, settings?.home?.sections, t]);
+
+ const sections = settings?.home?.sections ? customSections : defaultSections;
+
+ // Determine if hero should be shown (separate setting from backdrop)
+ // We need this early to calculate which sections will actually be rendered
+ const showHero = useMemo(() => {
+ return heroItems && heroItems.length > 0 && settings.showTVHeroCarousel;
+ }, [heroItems, settings.showTVHeroCarousel]);
+
+ // Get sections that will actually be rendered (accounting for hero slicing)
+ // When hero is shown, skip the first sections since hero already displays that content
+ // - If mergeNextUpAndContinueWatching: skip 1 section (combined Continue & Next Up)
+ // - Otherwise: skip 2 sections (separate Continue Watching + Next Up)
+ const renderedSections = useMemo(() => {
+ if (!showHero) return sections;
+ const sectionsToSkip = settings.mergeNextUpAndContinueWatching ? 1 : 2;
+ return sections.slice(sectionsToSkip);
+ }, [sections, showHero, settings.mergeNextUpAndContinueWatching]);
+
+ if (!isConnected || serverConnected !== true) {
+ let title = "";
+ let subtitle = "";
+
+ if (!isConnected) {
+ title = t("home.no_internet");
+ subtitle = t("home.no_internet_message");
+ } else if (serverConnected === null) {
+ title = t("home.checking_server_connection");
+ subtitle = t("home.checking_server_connection_message");
+ } else if (!serverConnected) {
+ title = t("home.server_unreachable");
+ subtitle = t("home.server_unreachable_message");
+ }
+ return (
+
+
+ {title}
+
+
+ {subtitle}
+
+
+
+
+ )
+ }
+ >
+ {retryLoading ? (
+
+ ) : (
+ t("home.retry")
+ )}
+
+
+
+ );
+ }
+
+ if (e1)
+ return (
+
+
+ {t("home.oops")}
+
+
+ {t("home.error_message")}
+
+
+ );
+
+ if (l1)
+ return (
+
+
+
+ );
+
+ return (
+
+ {/* Dynamic backdrop with crossfade - only shown when hero is disabled */}
+ {!showHero && settings.showHomeBackdrop && (
+
+ {/* Layer 0 */}
+
+ {layer0Url && (
+
+ )}
+
+ {/* Layer 1 */}
+
+ {layer1Url && (
+
+ )}
+
+ {/* Gradient overlays for readability */}
+
+
+ )}
+
+
+ {/* Hero Carousel - Apple TV+ style featured content */}
+ {showHero && heroItems && (
+
+ )}
+
+
+ {/* Skip first section (Continue Watching) when hero is shown since hero displays that content */}
+ {renderedSections.map((section, index) => {
+ // Render Streamystats sections after Recently Added sections
+ // For default sections: place after Recently Added, before Suggested Movies (if present)
+ // For custom sections: place at the very end
+ const hasSuggestedMovies =
+ !settings?.streamyStatsMovieRecommendations &&
+ !settings?.home?.sections;
+ const displayedSectionsLength = renderedSections.length;
+ const streamystatsIndex =
+ displayedSectionsLength - 1 - (hasSuggestedMovies ? 1 : 0);
+ const hasStreamystatsContent =
+ settings.streamyStatsMovieRecommendations ||
+ settings.streamyStatsSeriesRecommendations ||
+ settings.streamyStatsPromotedWatchlists;
+ const streamystatsSections =
+ index === streamystatsIndex && hasStreamystatsContent ? (
+
+ {settings.streamyStatsMovieRecommendations && (
+
+ )}
+ {settings.streamyStatsSeriesRecommendations && (
+
+ )}
+ {settings.streamyStatsPromotedWatchlists && (
+
+ )}
+
+ ) : null;
+
+ if (section.type === "InfiniteScrollingCollectionList") {
+ // First section only gets preferred focus if hero is not shown
+ const isFirstSection = index === 0 && !showHero;
+ return (
+
+
+ {streamystatsSections}
+
+ );
+ }
+ return null;
+ })}
+
+
+
+ );
+};
diff --git a/components/home/HomeWithCarousel.tsx b/components/home/HomeWithCarousel.tsx
deleted file mode 100644
index 43e7fefbe..000000000
--- a/components/home/HomeWithCarousel.tsx
+++ /dev/null
@@ -1,511 +0,0 @@
-import { Feather, Ionicons } from "@expo/vector-icons";
-import type {
- BaseItemDto,
- BaseItemDtoQueryResult,
- BaseItemKind,
-} from "@jellyfin/sdk/lib/generated-client/models";
-import {
- getItemsApi,
- getSuggestionsApi,
- getTvShowsApi,
- getUserLibraryApi,
- getUserViewsApi,
-} from "@jellyfin/sdk/lib/utils/api";
-import { type QueryFunction, useQuery } from "@tanstack/react-query";
-import { useNavigation, useRouter, useSegments } from "expo-router";
-import { useAtomValue } from "jotai";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { useTranslation } from "react-i18next";
-import {
- ActivityIndicator,
- Platform,
- TouchableOpacity,
- View,
-} from "react-native";
-import Animated, {
- useAnimatedRef,
- useScrollViewOffset,
-} from "react-native-reanimated";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { Button } from "@/components/Button";
-import { Text } from "@/components/common/Text";
-import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
-import { Loader } from "@/components/Loader";
-import { MediaListSection } from "@/components/medialists/MediaListSection";
-import { Colors } from "@/constants/Colors";
-import { useNetworkStatus } from "@/hooks/useNetworkStatus";
-import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
-import { useDownload } from "@/providers/DownloadProvider";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { useSettings } from "@/utils/atoms/settings";
-import { eventBus } from "@/utils/eventBus";
-import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel";
-
-type InfiniteScrollingCollectionListSection = {
- type: "InfiniteScrollingCollectionList";
- title?: string;
- queryKey: (string | undefined | null)[];
- queryFn: QueryFunction;
- orientation?: "horizontal" | "vertical";
- pageSize?: number;
-};
-
-type MediaListSectionType = {
- type: "MediaListSection";
- queryKey: (string | undefined)[];
- queryFn: QueryFunction;
-};
-
-type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
-
-export const HomeWithCarousel = () => {
- const router = useRouter();
- const { t } = useTranslation();
- const api = useAtomValue(apiAtom);
- const user = useAtomValue(userAtom);
- const insets = useSafeAreaInsets();
- const [_loading, setLoading] = useState(false);
- const { settings, refreshStreamyfinPluginSettings } = useSettings();
- const headerOverlayOffset = Platform.isTV ? 0 : 60;
- const navigation = useNavigation();
- const animatedScrollRef = useAnimatedRef();
- const scrollOffset = useScrollViewOffset(animatedScrollRef);
- const { downloadedItems, cleanCacheDirectory } = useDownload();
- const prevIsConnected = useRef(false);
- const {
- isConnected,
- serverConnected,
- loading: retryLoading,
- retryCheck,
- } = useNetworkStatus();
- const invalidateCache = useInvalidatePlaybackProgressCache();
- const [scrollY, setScrollY] = useState(0);
-
- useEffect(() => {
- if (isConnected && !prevIsConnected.current) {
- invalidateCache();
- }
- prevIsConnected.current = isConnected;
- }, [isConnected, invalidateCache]);
-
- const hasDownloads = useMemo(() => {
- if (Platform.isTV) return false;
- return downloadedItems.length > 0;
- }, [downloadedItems]);
-
- useEffect(() => {
- if (Platform.isTV) {
- navigation.setOptions({
- headerLeft: () => null,
- });
- return;
- }
- navigation.setOptions({
- headerLeft: () => (
- {
- router.push("/(auth)/downloads");
- }}
- className='ml-1.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
- });
- }, [navigation, router, hasDownloads]);
-
- useEffect(() => {
- cleanCacheDirectory().catch((_e) =>
- console.error("Something went wrong cleaning cache directory"),
- );
- }, []);
-
- const segments = useSegments();
- useEffect(() => {
- const unsubscribe = eventBus.on("scrollToTop", () => {
- if ((segments as string[])[2] === "(home)")
- animatedScrollRef.current?.scrollTo({
- y: Platform.isTV ? -152 : -100,
- animated: true,
- });
- });
-
- return () => {
- unsubscribe();
- };
- }, [segments]);
-
- const {
- data,
- isError: e1,
- isLoading: l1,
- } = useQuery({
- queryKey: ["home", "userViews", user?.Id],
- queryFn: async () => {
- if (!api || !user?.Id) {
- return null;
- }
-
- const response = await getUserViewsApi(api).getUserViews({
- userId: user.Id,
- });
-
- return response.data.Items || null;
- },
- enabled: !!api && !!user?.Id,
- staleTime: 60 * 1000,
- });
-
- const userViews = useMemo(
- () => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
- [data, settings?.hiddenLibraries],
- );
-
- const collections = useMemo(() => {
- const allow = ["movies", "tvshows"];
- return (
- userViews?.filter(
- (c) => c.CollectionType && allow.includes(c.CollectionType),
- ) || []
- );
- }, [userViews]);
-
- const _refetch = async () => {
- setLoading(true);
- await refreshStreamyfinPluginSettings();
- await invalidateCache();
- setLoading(false);
- };
-
- const createCollectionConfig = useCallback(
- (
- title: string,
- queryKey: string[],
- includeItemTypes: BaseItemKind[],
- parentId: string | undefined,
- pageSize: number = 10,
- ): InfiniteScrollingCollectionListSection => ({
- title,
- queryKey,
- queryFn: async ({ pageParam = 0 }) => {
- if (!api) return [];
- // getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
- const allData =
- (
- await getUserLibraryApi(api).getLatestMedia({
- userId: user?.Id,
- limit: 100, // Fetch a larger set for pagination
- fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
- imageTypeLimit: 1,
- enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
- includeItemTypes,
- parentId,
- })
- ).data || [];
-
- // Simulate pagination by slicing
- return allData.slice(pageParam, pageParam + pageSize);
- },
- type: "InfiniteScrollingCollectionList",
- pageSize,
- }),
- [api, user?.Id],
- );
-
- const defaultSections = useMemo(() => {
- if (!api || !user?.Id) return [];
-
- const latestMediaViews = collections.map((c) => {
- const includeItemTypes: BaseItemKind[] =
- c.CollectionType === "tvshows" || c.CollectionType === "movies"
- ? []
- : ["Movie"];
- const title = t("home.recently_added_in", { libraryName: c.Name });
- const queryKey: string[] = [
- "home",
- `recentlyAddedIn${c.CollectionType}`,
- user.Id!,
- c.Id!,
- ];
- return createCollectionConfig(
- title || "",
- queryKey,
- includeItemTypes,
- c.Id,
- 10,
- );
- });
-
- const ss: Section[] = [
- {
- title: t("home.continue_watching"),
- queryKey: ["home", "resumeItems"],
- queryFn: async ({ pageParam = 0 }) =>
- (
- await getItemsApi(api).getResumeItems({
- userId: user.Id,
- enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
- includeItemTypes: ["Movie", "Series", "Episode"],
- fields: ["Genres"],
- startIndex: pageParam,
- limit: 10,
- })
- ).data.Items || [],
- type: "InfiniteScrollingCollectionList",
- orientation: "horizontal",
- pageSize: 10,
- },
- {
- title: t("home.next_up"),
- queryKey: ["home", "nextUp-all"],
- queryFn: async ({ pageParam = 0 }) =>
- (
- await getTvShowsApi(api).getNextUp({
- userId: user?.Id,
- fields: ["MediaSourceCount", "Genres"],
- startIndex: pageParam,
- limit: 10,
- enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
- enableResumable: false,
- })
- ).data.Items || [],
- type: "InfiniteScrollingCollectionList",
- orientation: "horizontal",
- pageSize: 10,
- },
- ...latestMediaViews,
- {
- title: t("home.suggested_movies"),
- queryKey: ["home", "suggestedMovies", user?.Id],
- queryFn: async ({ pageParam = 0 }) =>
- (
- await getSuggestionsApi(api).getSuggestions({
- userId: user?.Id,
- startIndex: pageParam,
- limit: 10,
- mediaType: ["Video"],
- type: ["Movie"],
- })
- ).data.Items || [],
- type: "InfiniteScrollingCollectionList",
- orientation: "vertical",
- pageSize: 10,
- },
- ];
- return ss;
- }, [api, user?.Id, collections, t, createCollectionConfig]);
-
- const customSections = useMemo(() => {
- if (!api || !user?.Id || !settings?.home?.sections) return [];
- const ss: Section[] = [];
- settings.home.sections.forEach((section, index) => {
- const id = section.title || `section-${index}`;
- const pageSize = 10;
- ss.push({
- title: t(`${id}`),
- queryKey: ["home", "custom", String(index), section.title ?? null],
- queryFn: async ({ pageParam = 0 }) => {
- if (section.items) {
- const response = await getItemsApi(api).getItems({
- userId: user?.Id,
- startIndex: pageParam,
- limit: section.items?.limit || pageSize,
- recursive: true,
- includeItemTypes: section.items?.includeItemTypes,
- sortBy: section.items?.sortBy,
- sortOrder: section.items?.sortOrder,
- filters: section.items?.filters,
- parentId: section.items?.parentId,
- });
- return response.data.Items || [];
- }
- if (section.nextUp) {
- const response = await getTvShowsApi(api).getNextUp({
- userId: user?.Id,
- fields: ["MediaSourceCount", "Genres"],
- startIndex: pageParam,
- limit: section.nextUp?.limit || pageSize,
- enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
- enableResumable: section.nextUp?.enableResumable,
- enableRewatching: section.nextUp?.enableRewatching,
- });
- return response.data.Items || [];
- }
- if (section.latest) {
- // getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
- const allData =
- (
- await getUserLibraryApi(api).getLatestMedia({
- userId: user?.Id,
- includeItemTypes: section.latest?.includeItemTypes,
- limit: section.latest?.limit || 100, // Fetch larger set
- isPlayed: section.latest?.isPlayed,
- groupItems: section.latest?.groupItems,
- })
- ).data || [];
-
- // Simulate pagination by slicing
- return allData.slice(pageParam, pageParam + pageSize);
- }
- if (section.custom) {
- const response = await api.get(
- section.custom.endpoint,
- {
- params: {
- ...(section.custom.query || {}),
- userId: user?.Id,
- startIndex: pageParam,
- limit: pageSize,
- },
- headers: section.custom.headers || {},
- },
- );
- return response.data.Items || [];
- }
- return [];
- },
- type: "InfiniteScrollingCollectionList",
- orientation: section?.orientation || "vertical",
- pageSize,
- });
- });
- return ss;
- }, [api, user?.Id, settings?.home?.sections, t]);
-
- const sections = settings?.home?.sections ? customSections : defaultSections;
-
- if (!isConnected || serverConnected !== true) {
- let title = "";
- let subtitle = "";
-
- if (!isConnected) {
- title = t("home.no_internet");
- subtitle = t("home.no_internet_message");
- } else if (serverConnected === null) {
- title = t("home.checking_server_connection");
- subtitle = t("home.checking_server_connection_message");
- } else if (!serverConnected) {
- title = t("home.server_unreachable");
- subtitle = t("home.server_unreachable_message");
- }
- return (
-
- {title}
- {subtitle}
-
-
- {!Platform.isTV && (
-
- )}
-
-
- )
- }
- >
- {retryLoading ? (
-
- ) : (
- t("home.retry")
- )}
-
-
-
- );
- }
-
- if (e1)
- return (
-
- {t("home.oops")}
-
- {t("home.error_message")}
-
-
- );
-
- if (l1)
- return (
-
-
-
- );
-
- return (
- {
- setScrollY(event.nativeEvent.contentOffset.y);
- }}
- >
-
-
-
- {sections.map((section, index) => {
- if (section.type === "InfiniteScrollingCollectionList") {
- return (
-
- );
- }
- if (section.type === "MediaListSection") {
- return (
-
- );
- }
- return null;
- })}
-
-
-
-
- );
-};
diff --git a/components/home/InfiniteScrollingCollectionList.tsx b/components/home/InfiniteScrollingCollectionList.tsx
index 30464b639..de4c64621 100644
--- a/components/home/InfiniteScrollingCollectionList.tsx
+++ b/components/home/InfiniteScrollingCollectionList.tsx
@@ -4,6 +4,7 @@ import {
type QueryKey,
useInfiniteQuery,
} from "@tanstack/react-query";
+import { useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
@@ -11,6 +12,7 @@ import {
View,
type ViewProps,
} from "react-native";
+import { SectionHeader } from "@/components/common/SectionHeader";
import { Text } from "@/components/common/Text";
import MoviePoster from "@/components/posters/MoviePoster";
import { Colors } from "../../constants/Colors";
@@ -27,6 +29,9 @@ interface Props extends ViewProps {
queryFn: QueryFunction;
hideIfEmpty?: boolean;
pageSize?: number;
+ onPressSeeAll?: () => void;
+ enabled?: boolean;
+ onLoaded?: () => void;
}
export const InfiniteScrollingCollectionList: React.FC = ({
@@ -37,32 +42,71 @@ export const InfiniteScrollingCollectionList: React.FC = ({
queryKey,
hideIfEmpty = false,
pageSize = 10,
+ onPressSeeAll,
+ enabled = true,
+ onLoaded,
...props
}) => {
- const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
- useInfiniteQuery({
- queryKey: queryKey,
- queryFn: ({ pageParam = 0, ...context }) =>
- queryFn({ ...context, queryKey, pageParam }),
- getNextPageParam: (lastPage, allPages) => {
- // If the last page has fewer items than pageSize, we've reached the end
- if (lastPage.length < pageSize) {
- return undefined;
- }
- // Otherwise, return the next start index
- return allPages.length * pageSize;
- },
- initialPageParam: 0,
- staleTime: 60 * 1000, // 1 minute
- refetchOnMount: false,
- refetchOnWindowFocus: false,
- refetchOnReconnect: true,
- });
+ const effectivePageSize = Math.max(1, pageSize);
+ const hasCalledOnLoaded = useRef(false);
+ const {
+ data,
+ isLoading,
+ isFetchingNextPage,
+ hasNextPage,
+ fetchNextPage,
+ isSuccess,
+ } = useInfiniteQuery({
+ queryKey: queryKey,
+ queryFn: ({ pageParam = 0, ...context }) =>
+ queryFn({ ...context, queryKey, pageParam }),
+ getNextPageParam: (lastPage, allPages) => {
+ // If the last page has fewer items than pageSize, we've reached the end
+ if (lastPage.length < effectivePageSize) {
+ return undefined;
+ }
+ // Otherwise, return the next start index based on how many items we already loaded.
+ // This avoids overlaps if the server/page size differs from our configured page size.
+ return allPages.reduce((acc, page) => acc + page.length, 0);
+ },
+ initialPageParam: 0,
+ staleTime: 60 * 1000, // 1 minute
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: true,
+ enabled,
+ });
+
+ // Notify parent when data has loaded
+ useEffect(() => {
+ if (isSuccess && !hasCalledOnLoaded.current && onLoaded) {
+ hasCalledOnLoaded.current = true;
+ onLoaded();
+ }
+ }, [isSuccess, onLoaded]);
const { t } = useTranslation();
- // Flatten all pages into a single array
- const allItems = data?.pages.flat() || [];
+ // Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
+ const allItems = useMemo(() => {
+ const items = data?.pages.flat() ?? [];
+ const seen = new Set();
+ const deduped: BaseItemDto[] = [];
+
+ for (const item of items) {
+ const id = item.Id;
+ if (!id) continue;
+ if (seen.has(id)) continue;
+ seen.add(id);
+ deduped.push(item);
+ }
+
+ return deduped;
+ }, [data]);
+
+ const snapOffsets = useMemo(() => {
+ const itemWidth = orientation === "horizontal" ? 184 : 120; // w-44 (176px) + mr-2 (8px) or w-28 (112px) + mr-2 (8px)
+ return allItems.map((_, index) => index * itemWidth);
+ }, [allItems, orientation]);
if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
if (disabled || !title) return null;
@@ -84,9 +128,12 @@ export const InfiniteScrollingCollectionList: React.FC = ({
return (
-
- {title}
-
+
{isLoading === false && allItems.length === 0 && (
{t("home.no_items")}
@@ -126,13 +173,15 @@ export const InfiniteScrollingCollectionList: React.FC = ({
showsHorizontalScrollIndicator={false}
onScroll={handleScroll}
scrollEventThrottle={16}
+ snapToOffsets={snapOffsets}
+ decelerationRate='fast'
>
- {allItems.map((item) => (
+ {allItems.map((item, index) => (
diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx
new file mode 100644
index 000000000..1db314f2b
--- /dev/null
+++ b/components/home/InfiniteScrollingCollectionList.tv.tsx
@@ -0,0 +1,397 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import {
+ type QueryFunction,
+ type QueryKey,
+ useInfiniteQuery,
+} from "@tanstack/react-query";
+import { useSegments } from "expo-router";
+import { useCallback, useMemo, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ ActivityIndicator,
+ FlatList,
+ View,
+ type ViewProps,
+} from "react-native";
+import { Text } from "@/components/common/Text";
+import { getItemNavigation } from "@/components/common/TouchableItemRouter";
+import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
+import { TVPosterCard } from "@/components/tv/TVPosterCard";
+import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
+import { useScaledTVSizes } from "@/constants/TVSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
+import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
+import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
+import { scaleSize } from "@/utils/scaleSize";
+
+// Extra padding to accommodate scale animation (1.05x) and glow shadow
+const _SCALE_PADDING = scaleSize(20);
+
+interface Props extends ViewProps {
+ title?: string | null;
+ orientation?: "horizontal" | "vertical";
+ disabled?: boolean;
+ queryKey: QueryKey;
+ queryFn: QueryFunction;
+ hideIfEmpty?: boolean;
+ pageSize?: number;
+ onPressSeeAll?: () => void;
+ enabled?: boolean;
+ isFirstSection?: boolean;
+ onItemFocus?: (item: BaseItemDto) => void;
+ parentId?: string;
+}
+
+type Typography = ReturnType;
+type PosterSizes = ReturnType;
+
+// TV-specific "See All" card for end of lists
+const TVSeeAllCard: React.FC<{
+ onPress: () => void;
+ orientation: "horizontal" | "vertical";
+ disabled?: boolean;
+ onFocus?: () => void;
+ onBlur?: () => void;
+ typography: Typography;
+ posterSizes: PosterSizes;
+}> = ({
+ onPress,
+ orientation,
+ disabled,
+ onFocus,
+ onBlur,
+ typography,
+ posterSizes,
+}) => {
+ const { t } = useTranslation();
+ const width =
+ orientation === "horizontal" ? posterSizes.episode : posterSizes.poster;
+ const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15;
+
+ return (
+
+
+
+
+
+ {t("common.seeAll", { defaultValue: "See all" })}
+
+
+
+
+ );
+};
+
+export const InfiniteScrollingCollectionList: React.FC = ({
+ title,
+ orientation = "vertical",
+ disabled = false,
+ queryFn,
+ queryKey,
+ hideIfEmpty = false,
+ pageSize = 10,
+ enabled = true,
+ isFirstSection = false,
+ onItemFocus,
+ parentId,
+ ...props
+}) => {
+ const typography = useScaledTVTypography();
+ const posterSizes = useScaledTVPosterSizes();
+ const sizes = useScaledTVSizes();
+ const ITEM_GAP = sizes.gaps.item;
+ const effectivePageSize = Math.max(1, pageSize);
+ const router = useRouter();
+ const { showItemActions } = useTVItemActionModal();
+ const segments = useSegments();
+ const from = (segments as string[])[2] || "(home)";
+
+ const flatListRef = useRef>(null);
+
+ // Pass through focus callbacks without tracking internal state
+ const handleItemFocus = useCallback(
+ (item: BaseItemDto) => {
+ onItemFocus?.(item);
+ },
+ [onItemFocus],
+ );
+
+ const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
+ useInfiniteQuery({
+ queryKey: queryKey,
+ queryFn: ({ pageParam = 0, ...context }) =>
+ queryFn({ ...context, queryKey, pageParam }),
+ getNextPageParam: (lastPage, allPages) => {
+ if (lastPage.length < effectivePageSize) {
+ return undefined;
+ }
+ return allPages.reduce((acc, page) => acc + page.length, 0);
+ },
+ initialPageParam: 0,
+ staleTime: 60 * 1000,
+ refetchInterval: 60 * 1000,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: true,
+ enabled,
+ });
+
+ const { t } = useTranslation();
+
+ const allItems = useMemo(() => {
+ const items = data?.pages.flat() ?? [];
+ const seen = new Set();
+ const deduped: BaseItemDto[] = [];
+
+ for (const item of items) {
+ const id = item.Id;
+ if (!id) continue;
+ if (seen.has(id)) continue;
+ seen.add(id);
+ deduped.push(item);
+ }
+
+ return deduped;
+ }, [data]);
+
+ const itemWidth =
+ orientation === "horizontal" ? posterSizes.episode : posterSizes.poster;
+
+ const handleItemPress = useCallback(
+ (item: BaseItemDto) => {
+ const navigation = getItemNavigation(item, from);
+ router.push(navigation as any);
+ },
+ [from, router],
+ );
+
+ const handleEndReached = useCallback(() => {
+ if (hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
+
+ const handleSeeAllPress = useCallback(() => {
+ if (!parentId) return;
+ // Navigate into the library detail (lives in the libraries tab) sorted by most
+ // recently added. The `fromSeeAll` flag tells the detail page to (a) collapse
+ // the libraries stack so the native tab can't auto-pop it back to the list, and
+ // (b) intercept Back to route to the library list so the user can switch
+ // libraries. See app/(auth)/(tabs)/(libraries)/[libraryId].tsx.
+ router.push({
+ pathname: "/[libraryId]",
+ params: {
+ libraryId: parentId,
+ sortBy: SortByOption.DateCreated,
+ sortOrder: SortOrderOption.Descending,
+ fromSeeAll: "true",
+ },
+ } as any);
+ }, [router, parentId]);
+
+ const renderItem = useCallback(
+ ({ item, index }: { item: BaseItemDto; index: number }) => {
+ const isFirstItem = isFirstSection && index === 0;
+
+ return (
+
+ handleItemPress(item)}
+ onLongPress={() => showItemActions(item)}
+ hasTVPreferredFocus={isFirstItem}
+ onFocus={() => handleItemFocus(item)}
+ width={itemWidth}
+ />
+
+ );
+ },
+ [
+ orientation,
+ isFirstSection,
+ itemWidth,
+ handleItemPress,
+ showItemActions,
+ handleItemFocus,
+ ITEM_GAP,
+ ],
+ );
+
+ if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
+ if (disabled || !title) return null;
+
+ return (
+
+ {/* Section Header */}
+
+ {title}
+
+
+ {isLoading === false && allItems.length === 0 && (
+
+ {t("home.no_items")}
+
+ )}
+
+ {isLoading ? (
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+
+
+
+ Placeholder text here
+
+
+
+ ))}
+
+ ) : (
+ item.Id!}
+ renderItem={renderItem}
+ showsHorizontalScrollIndicator={false}
+ onEndReached={handleEndReached}
+ onEndReachedThreshold={0.5}
+ initialNumToRender={4}
+ maxToRenderPerBatch={2}
+ windowSize={3}
+ removeClippedSubviews={false}
+ maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
+ style={{ overflow: "visible" }}
+ contentContainerStyle={{
+ paddingVertical: sizes.gaps.small,
+ paddingLeft: sizes.padding.horizontal,
+ paddingRight: sizes.padding.horizontal,
+ }}
+ // Below is a work around with the contentInset, same in TVHeroCarousel, if okay on apple remove
+ // ListHeaderComponent={
+ //
+ // }
+ // contentInset={{
+ // left: sizes.padding.horizontal,
+ // right: sizes.padding.horizontal,
+ // }}
+ // contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
+ // contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
+ ListFooterComponent={
+ // No fixed width: the footer must size to the "See All" card so the
+ // FlatList's scrollable content extends to fully reveal it. A fixed
+ // (narrow) width clipped the card at the right edge. Trailing space is
+ // provided by contentContainerStyle.paddingRight.
+
+ {isFetchingNextPage && (
+
+
+
+ )}
+ {parentId && allItems.length > 0 && (
+
+ )}
+
+ }
+ />
+ )}
+
+ );
+};
diff --git a/components/home/LargeMovieCarousel.tsx b/components/home/LargeMovieCarousel.tsx
index 913e024b1..cf79af73b 100644
--- a/components/home/LargeMovieCarousel.tsx
+++ b/components/home/LargeMovieCarousel.tsx
@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
-import { useRouter, useSegments } from "expo-router";
+import { useSegments } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useMemo } from "react";
import { Dimensions, View, type ViewProps } from "react-native";
@@ -16,6 +16,7 @@ import Carousel, {
type ICarouselInstance,
Pagination,
} from "react-native-reanimated-carousel";
+import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx
index 54542c443..185ad8c2f 100644
--- a/components/home/ScrollingCollectionList.tsx
+++ b/components/home/ScrollingCollectionList.tsx
@@ -21,7 +21,6 @@ interface Props extends ViewProps {
queryKey: QueryKey;
queryFn: QueryFunction;
hideIfEmpty?: boolean;
- isOffline?: boolean;
scrollY?: number; // For lazy loading
enableLazyLoading?: boolean; // Enable/disable lazy loading
}
@@ -33,7 +32,6 @@ export const ScrollingCollectionList: React.FC = ({
queryFn,
queryKey,
hideIfEmpty = false,
- isOffline = false,
scrollY = 0,
enableLazyLoading = false,
...props
@@ -46,7 +44,6 @@ export const ScrollingCollectionList: React.FC = ({
queryKey: queryKey,
queryFn,
staleTime: 60 * 1000, // 1 minute
- refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled: enableLazyLoading ? isInView : true,
@@ -106,7 +103,6 @@ export const ScrollingCollectionList: React.FC = ({
= ({
+ watchlist,
+ jellyfinServerId,
+ ...props
+}) => {
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
+ const { settings } = useSettings();
+ const router = useRouter();
+ const { t } = useTranslation();
+
+ const { data: items, isLoading } = useQuery({
+ queryKey: [
+ "streamystats",
+ "watchlist",
+ watchlist.id,
+ jellyfinServerId,
+ settings?.streamyStatsServerUrl,
+ ],
+ queryFn: async (): Promise => {
+ if (!settings?.streamyStatsServerUrl || !api?.accessToken || !user?.Id) {
+ return [];
+ }
+
+ const streamystatsApi = createStreamystatsApi({
+ serverUrl: settings.streamyStatsServerUrl,
+ jellyfinToken: api.accessToken,
+ });
+
+ const watchlistDetail = await streamystatsApi.getWatchlistItemIds({
+ watchlistId: watchlist.id,
+ jellyfinServerId,
+ });
+
+ const itemIds = watchlistDetail.data?.items;
+ if (!itemIds?.length) {
+ return [];
+ }
+
+ const response = await getItemsApi(api).getItems({
+ userId: user.Id,
+ ids: itemIds,
+ fields: ["PrimaryImageAspectRatio", "Genres"],
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ });
+
+ return response.data.Items || [];
+ },
+ enabled:
+ Boolean(settings?.streamyStatsServerUrl) &&
+ Boolean(api?.accessToken) &&
+ Boolean(user?.Id),
+ staleTime: 5 * 60 * 1000,
+ refetchOnWindowFocus: false,
+ });
+
+ const snapOffsets = useMemo(() => {
+ return items?.map((_, index) => index * ITEM_WIDTH) ?? [];
+ }, [items]);
+
+ const handleSeeAll = () => {
+ router.push({
+ pathname: "/(auth)/(tabs)/(watchlists)/[watchlistId]",
+ params: { watchlistId: watchlist.id.toString() },
+ } as any);
+ };
+
+ if (!isLoading && (!items || items.length === 0)) return null;
+
+ return (
+
+
+ {isLoading ? (
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+ Loading...
+
+
+
+ ))}
+
+ ) : (
+
+
+ {items?.map((item) => (
+
+ {item.Type === "Movie" && }
+ {item.Type === "Series" && }
+
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+interface StreamystatsPromotedWatchlistsProps extends ViewProps {
+ enabled?: boolean;
+}
+
+export const StreamystatsPromotedWatchlists: React.FC<
+ StreamystatsPromotedWatchlistsProps
+> = ({ enabled = true, ...props }) => {
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
+ const { settings } = useSettings();
+
+ const streamyStatsEnabled = useMemo(() => {
+ return Boolean(settings?.streamyStatsServerUrl);
+ }, [settings?.streamyStatsServerUrl]);
+
+ // Fetch server info to get the Jellyfin server ID
+ const { data: serverInfo } = useQuery({
+ queryKey: ["jellyfin", "serverInfo"],
+ queryFn: async (): Promise => {
+ if (!api) return null;
+ const response = await getSystemApi(api).getPublicSystemInfo();
+ return response.data;
+ },
+ enabled: enabled && Boolean(api) && streamyStatsEnabled,
+ staleTime: 60 * 60 * 1000,
+ });
+
+ const jellyfinServerId = serverInfo?.Id;
+
+ const {
+ data: watchlists,
+ isLoading,
+ isError,
+ } = useQuery({
+ queryKey: [
+ "streamystats",
+ "promotedWatchlists",
+ jellyfinServerId,
+ settings?.streamyStatsServerUrl,
+ ],
+ queryFn: async (): Promise => {
+ if (
+ !settings?.streamyStatsServerUrl ||
+ !api?.accessToken ||
+ !jellyfinServerId
+ ) {
+ return [];
+ }
+
+ const streamystatsApi = createStreamystatsApi({
+ serverUrl: settings.streamyStatsServerUrl,
+ jellyfinToken: api.accessToken,
+ });
+
+ const response = await streamystatsApi.getPromotedWatchlists({
+ jellyfinServerId,
+ includePreview: false,
+ });
+
+ return response.data || [];
+ },
+ enabled:
+ enabled &&
+ streamyStatsEnabled &&
+ Boolean(api?.accessToken) &&
+ Boolean(jellyfinServerId) &&
+ Boolean(user?.Id),
+ staleTime: 5 * 60 * 1000,
+ refetchOnWindowFocus: false,
+ });
+
+ if (!streamyStatsEnabled) return null;
+ if (isError) return null;
+ if (!isLoading && (!watchlists || watchlists.length === 0)) return null;
+
+ if (isLoading) {
+ return (
+
+
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+ Loading...
+
+
+
+ ))}
+
+
+ );
+ }
+
+ return (
+ <>
+ {watchlists?.map((watchlist) => (
+
+ ))}
+ >
+ );
+};
diff --git a/components/home/StreamystatsPromotedWatchlists.tv.tsx b/components/home/StreamystatsPromotedWatchlists.tv.tsx
new file mode 100644
index 000000000..6b3c32aea
--- /dev/null
+++ b/components/home/StreamystatsPromotedWatchlists.tv.tsx
@@ -0,0 +1,382 @@
+import type {
+ BaseItemDto,
+ PublicSystemInfo,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import { useSegments } from "expo-router";
+import { useAtomValue } from "jotai";
+import { useCallback, useMemo } from "react";
+import { FlatList, View, type ViewProps } from "react-native";
+
+import { Text } from "@/components/common/Text";
+import { getItemNavigation } from "@/components/common/TouchableItemRouter";
+import { TVPosterCard } from "@/components/tv/TVPosterCard";
+import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
+import { useScaledTVSizes } from "@/constants/TVSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
+import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import { scaleSize } from "@/utils/scaleSize";
+import { createStreamystatsApi } from "@/utils/streamystats/api";
+import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
+
+const SCALE_PADDING = scaleSize(20);
+
+interface WatchlistSectionProps extends ViewProps {
+ watchlist: StreamystatsWatchlist;
+ jellyfinServerId: string;
+ onItemFocus?: (item: BaseItemDto) => void;
+}
+
+const WatchlistSection: React.FC = ({
+ watchlist,
+ jellyfinServerId,
+ onItemFocus,
+ ...props
+}) => {
+ const typography = useScaledTVTypography();
+ const posterSizes = useScaledTVPosterSizes();
+ const sizes = useScaledTVSizes();
+ const ITEM_GAP = sizes.gaps.item;
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
+ const { settings } = useSettings();
+ const router = useRouter();
+ const { showItemActions } = useTVItemActionModal();
+ const segments = useSegments();
+ const from = (segments as string[])[2] || "(home)";
+
+ const { data: items, isLoading } = useQuery({
+ queryKey: [
+ "streamystats",
+ "watchlist",
+ watchlist.id,
+ jellyfinServerId,
+ settings?.streamyStatsServerUrl,
+ ],
+ queryFn: async (): Promise => {
+ if (!settings?.streamyStatsServerUrl || !api?.accessToken || !user?.Id) {
+ return [];
+ }
+
+ const streamystatsApi = createStreamystatsApi({
+ serverUrl: settings.streamyStatsServerUrl,
+ jellyfinToken: api.accessToken,
+ });
+
+ const watchlistDetail = await streamystatsApi.getWatchlistItemIds({
+ watchlistId: watchlist.id,
+ jellyfinServerId,
+ });
+
+ const itemIds = watchlistDetail.data?.items;
+ if (!itemIds?.length) {
+ return [];
+ }
+
+ const response = await getItemsApi(api).getItems({
+ userId: user.Id,
+ ids: itemIds,
+ fields: ["PrimaryImageAspectRatio", "Genres"],
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ });
+
+ return response.data.Items || [];
+ },
+ enabled:
+ Boolean(settings?.streamyStatsServerUrl) &&
+ Boolean(api?.accessToken) &&
+ Boolean(user?.Id),
+ staleTime: 60 * 1000,
+ refetchInterval: 60 * 1000,
+ refetchOnWindowFocus: false,
+ });
+
+ const handleItemPress = useCallback(
+ (item: BaseItemDto) => {
+ const navigation = getItemNavigation(item, from);
+ router.push(navigation as any);
+ },
+ [from, router],
+ );
+
+ const getItemLayout = useCallback(
+ (_data: ArrayLike | null | undefined, index: number) => ({
+ length: posterSizes.poster + ITEM_GAP,
+ offset: (posterSizes.poster + ITEM_GAP) * index,
+ index,
+ }),
+ [posterSizes.poster, ITEM_GAP],
+ );
+
+ const renderItem = useCallback(
+ ({ item }: { item: BaseItemDto }) => {
+ return (
+
+ handleItemPress(item)}
+ onLongPress={() => showItemActions(item)}
+ onFocus={() => onItemFocus?.(item)}
+ width={posterSizes.poster}
+ />
+
+ );
+ },
+ [
+ ITEM_GAP,
+ posterSizes.poster,
+ handleItemPress,
+ showItemActions,
+ onItemFocus,
+ ],
+ );
+
+ if (!isLoading && (!items || items.length === 0)) return null;
+
+ return (
+
+
+ {watchlist.name}
+
+
+ {isLoading ? (
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+
+
+
+ Placeholder text here
+
+
+
+ ))}
+
+ ) : (
+ item.Id!}
+ renderItem={renderItem}
+ showsHorizontalScrollIndicator={false}
+ initialNumToRender={5}
+ maxToRenderPerBatch={3}
+ windowSize={5}
+ removeClippedSubviews={false}
+ getItemLayout={getItemLayout}
+ style={{ overflow: "visible" }}
+ contentInset={{
+ left: sizes.padding.horizontal,
+ right: sizes.padding.horizontal,
+ }}
+ contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
+ contentContainerStyle={{
+ paddingVertical: SCALE_PADDING,
+ }}
+ />
+ )}
+
+ );
+};
+
+interface StreamystatsPromotedWatchlistsProps extends ViewProps {
+ enabled?: boolean;
+ onItemFocus?: (item: BaseItemDto) => void;
+}
+
+export const StreamystatsPromotedWatchlists: React.FC<
+ StreamystatsPromotedWatchlistsProps
+> = ({ enabled = true, onItemFocus, ...props }) => {
+ const posterSizes = useScaledTVPosterSizes();
+ const sizes = useScaledTVSizes();
+ const ITEM_GAP = sizes.gaps.item;
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
+ const { settings } = useSettings();
+ const typography = useScaledTVTypography();
+
+ const streamyStatsEnabled = useMemo(() => {
+ return Boolean(settings?.streamyStatsServerUrl);
+ }, [settings?.streamyStatsServerUrl]);
+
+ const { data: serverInfo } = useQuery({
+ queryKey: ["jellyfin", "serverInfo"],
+ queryFn: async (): Promise => {
+ if (!api) return null;
+ const response = await getSystemApi(api).getPublicSystemInfo();
+ return response.data;
+ },
+ enabled: enabled && Boolean(api) && streamyStatsEnabled,
+ staleTime: 60 * 60 * 1000,
+ });
+
+ const jellyfinServerId = serverInfo?.Id;
+
+ const {
+ data: watchlists,
+ isLoading,
+ isError,
+ } = useQuery({
+ queryKey: [
+ "streamystats",
+ "promotedWatchlists",
+ jellyfinServerId,
+ settings?.streamyStatsServerUrl,
+ ],
+ queryFn: async (): Promise => {
+ if (
+ !settings?.streamyStatsServerUrl ||
+ !api?.accessToken ||
+ !jellyfinServerId
+ ) {
+ return [];
+ }
+
+ const streamystatsApi = createStreamystatsApi({
+ serverUrl: settings.streamyStatsServerUrl,
+ jellyfinToken: api.accessToken,
+ });
+
+ const response = await streamystatsApi.getPromotedWatchlists({
+ jellyfinServerId,
+ includePreview: false,
+ });
+
+ return response.data || [];
+ },
+ enabled:
+ enabled &&
+ streamyStatsEnabled &&
+ Boolean(api?.accessToken) &&
+ Boolean(jellyfinServerId) &&
+ Boolean(user?.Id),
+ staleTime: 60 * 1000,
+ refetchInterval: 60 * 1000,
+ refetchOnWindowFocus: false,
+ });
+
+ if (!streamyStatsEnabled) return null;
+ if (isError) return null;
+ if (!isLoading && (!watchlists || watchlists.length === 0)) return null;
+
+ if (isLoading) {
+ return (
+
+
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+
+
+
+ Placeholder text here
+
+
+
+ ))}
+
+
+ );
+ }
+
+ return (
+ <>
+ {watchlists?.map((watchlist) => (
+
+ ))}
+ >
+ );
+};
diff --git a/components/home/StreamystatsRecommendations.tsx b/components/home/StreamystatsRecommendations.tsx
new file mode 100644
index 000000000..73b7231d9
--- /dev/null
+++ b/components/home/StreamystatsRecommendations.tsx
@@ -0,0 +1,195 @@
+import type {
+ BaseItemDto,
+ PublicSystemInfo,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import { useAtomValue } from "jotai";
+import { useMemo } from "react";
+import { ScrollView, View, type ViewProps } from "react-native";
+
+import { SectionHeader } from "@/components/common/SectionHeader";
+import { Text } from "@/components/common/Text";
+import MoviePoster from "@/components/posters/MoviePoster";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import { createStreamystatsApi } from "@/utils/streamystats/api";
+import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types";
+import { TouchableItemRouter } from "../common/TouchableItemRouter";
+import { ItemCardText } from "../ItemCardText";
+import SeriesPoster from "../posters/SeriesPoster";
+
+const ITEM_WIDTH = 120; // w-28 (112px) + mr-2 (8px)
+
+interface Props extends ViewProps {
+ title: string;
+ type: "Movie" | "Series";
+ limit?: number;
+ enabled?: boolean;
+}
+
+export const StreamystatsRecommendations: React.FC = ({
+ title,
+ type,
+ limit = 20,
+ enabled = true,
+ ...props
+}) => {
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
+ const { settings } = useSettings();
+
+ const streamyStatsEnabled = useMemo(() => {
+ return Boolean(settings?.streamyStatsServerUrl);
+ }, [settings?.streamyStatsServerUrl]);
+
+ // Fetch server info to get the Jellyfin server ID
+ const { data: serverInfo } = useQuery({
+ queryKey: ["jellyfin", "serverInfo"],
+ queryFn: async (): Promise => {
+ if (!api) return null;
+ const response = await getSystemApi(api).getPublicSystemInfo();
+ return response.data;
+ },
+ enabled: enabled && Boolean(api) && streamyStatsEnabled,
+ staleTime: 60 * 60 * 1000, // 1 hour - server info rarely changes
+ });
+
+ const jellyfinServerId = serverInfo?.Id;
+
+ const {
+ data: recommendationIds,
+ isLoading: isLoadingRecommendations,
+ isError: isRecommendationsError,
+ } = useQuery({
+ queryKey: [
+ "streamystats",
+ "recommendations",
+ type,
+ jellyfinServerId,
+ settings?.streamyStatsServerUrl,
+ ],
+ queryFn: async (): Promise => {
+ if (
+ !settings?.streamyStatsServerUrl ||
+ !api?.accessToken ||
+ !jellyfinServerId
+ ) {
+ return [];
+ }
+
+ const streamyStatsApi = createStreamystatsApi({
+ serverUrl: settings.streamyStatsServerUrl,
+ jellyfinToken: api.accessToken,
+ });
+
+ const response = await streamyStatsApi.getRecommendationIds(
+ jellyfinServerId,
+ type,
+ limit,
+ );
+
+ const data = response as StreamystatsRecommendationsIdsResponse;
+
+ if (type === "Movie") {
+ return data.data.movies || [];
+ }
+ return data.data.series || [];
+ },
+ enabled:
+ enabled &&
+ streamyStatsEnabled &&
+ Boolean(api?.accessToken) &&
+ Boolean(jellyfinServerId) &&
+ Boolean(user?.Id),
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ refetchOnWindowFocus: false,
+ });
+
+ const {
+ data: items,
+ isLoading: isLoadingItems,
+ isError: isItemsError,
+ } = useQuery({
+ queryKey: [
+ "streamystats",
+ "recommendations",
+ "items",
+ type,
+ recommendationIds,
+ ],
+ queryFn: async (): Promise => {
+ if (!api || !user?.Id || !recommendationIds?.length) {
+ return [];
+ }
+
+ const response = await getItemsApi(api).getItems({
+ userId: user.Id,
+ ids: recommendationIds,
+ fields: ["PrimaryImageAspectRatio", "Genres"],
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ });
+
+ return response.data.Items || [];
+ },
+ enabled:
+ Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
+ staleTime: 5 * 60 * 1000,
+ refetchOnWindowFocus: false,
+ });
+
+ const isLoading = isLoadingRecommendations || isLoadingItems;
+ const isError = isRecommendationsError || isItemsError;
+
+ const snapOffsets = useMemo(() => {
+ return items?.map((_, index) => index * ITEM_WIDTH) ?? [];
+ }, [items]);
+
+ if (!streamyStatsEnabled) return null;
+ if (isError) return null;
+ if (!isLoading && (!items || items.length === 0)) return null;
+
+ return (
+
+
+ {isLoading ? (
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+ Loading title...
+
+
+
+ ))}
+
+ ) : (
+
+
+ {items?.map((item) => (
+
+ {item.Type === "Movie" && }
+ {item.Type === "Series" && }
+
+
+ ))}
+
+
+ )}
+
+ );
+};
diff --git a/components/home/StreamystatsRecommendations.tv.tsx b/components/home/StreamystatsRecommendations.tv.tsx
new file mode 100644
index 000000000..ad9e388fd
--- /dev/null
+++ b/components/home/StreamystatsRecommendations.tv.tsx
@@ -0,0 +1,278 @@
+import type {
+ BaseItemDto,
+ PublicSystemInfo,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import { useSegments } from "expo-router";
+import { useAtomValue } from "jotai";
+import { useCallback, useMemo } from "react";
+import { FlatList, View, type ViewProps } from "react-native";
+
+import { Text } from "@/components/common/Text";
+import { getItemNavigation } from "@/components/common/TouchableItemRouter";
+import { TVPosterCard } from "@/components/tv/TVPosterCard";
+import { useScaledTVSizes } from "@/constants/TVSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
+import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import { scaleSize } from "@/utils/scaleSize";
+import { createStreamystatsApi } from "@/utils/streamystats/api";
+import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types";
+
+interface Props extends ViewProps {
+ title: string;
+ type: "Movie" | "Series";
+ limit?: number;
+ enabled?: boolean;
+ onItemFocus?: (item: BaseItemDto) => void;
+}
+
+export const StreamystatsRecommendations: React.FC = ({
+ title,
+ type,
+ limit = 20,
+ enabled = true,
+ onItemFocus,
+ ...props
+}) => {
+ const typography = useScaledTVTypography();
+ const sizes = useScaledTVSizes();
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
+ const { settings } = useSettings();
+ const router = useRouter();
+ const { showItemActions } = useTVItemActionModal();
+ const segments = useSegments();
+ const from = (segments as string[])[2] || "(home)";
+
+ const streamyStatsEnabled = useMemo(() => {
+ return Boolean(settings?.streamyStatsServerUrl);
+ }, [settings?.streamyStatsServerUrl]);
+
+ const { data: serverInfo } = useQuery({
+ queryKey: ["jellyfin", "serverInfo"],
+ queryFn: async (): Promise => {
+ if (!api) return null;
+ const response = await getSystemApi(api).getPublicSystemInfo();
+ return response.data;
+ },
+ enabled: enabled && Boolean(api) && streamyStatsEnabled,
+ staleTime: 60 * 60 * 1000,
+ });
+
+ const jellyfinServerId = serverInfo?.Id;
+
+ const {
+ data: recommendationIds,
+ isLoading: isLoadingRecommendations,
+ isError: isRecommendationsError,
+ } = useQuery({
+ queryKey: [
+ "streamystats",
+ "recommendations",
+ type,
+ jellyfinServerId,
+ settings?.streamyStatsServerUrl,
+ ],
+ queryFn: async (): Promise => {
+ if (
+ !settings?.streamyStatsServerUrl ||
+ !api?.accessToken ||
+ !jellyfinServerId
+ ) {
+ return [];
+ }
+
+ const streamyStatsApi = createStreamystatsApi({
+ serverUrl: settings.streamyStatsServerUrl,
+ jellyfinToken: api.accessToken,
+ });
+
+ const response = await streamyStatsApi.getRecommendationIds(
+ jellyfinServerId,
+ type,
+ limit,
+ );
+
+ const data = response as StreamystatsRecommendationsIdsResponse;
+
+ if (type === "Movie") {
+ return data.data.movies || [];
+ }
+ return data.data.series || [];
+ },
+ enabled:
+ enabled &&
+ streamyStatsEnabled &&
+ Boolean(api?.accessToken) &&
+ Boolean(jellyfinServerId) &&
+ Boolean(user?.Id),
+ staleTime: 60 * 1000,
+ refetchInterval: 60 * 1000,
+ refetchOnWindowFocus: false,
+ });
+
+ const {
+ data: items,
+ isLoading: isLoadingItems,
+ isError: isItemsError,
+ } = useQuery({
+ queryKey: [
+ "streamystats",
+ "recommendations",
+ "items",
+ type,
+ recommendationIds,
+ ],
+ queryFn: async (): Promise => {
+ if (!api || !user?.Id || !recommendationIds?.length) {
+ return [];
+ }
+
+ const response = await getItemsApi(api).getItems({
+ userId: user.Id,
+ ids: recommendationIds,
+ fields: ["PrimaryImageAspectRatio", "Genres"],
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ });
+
+ return response.data.Items || [];
+ },
+ enabled:
+ Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
+ staleTime: 60 * 1000,
+ refetchInterval: 60 * 1000,
+ refetchOnWindowFocus: false,
+ });
+
+ const isLoading = isLoadingRecommendations || isLoadingItems;
+ const isError = isRecommendationsError || isItemsError;
+
+ const handleItemPress = useCallback(
+ (item: BaseItemDto) => {
+ const navigation = getItemNavigation(item, from);
+ router.push(navigation as any);
+ },
+ [from, router],
+ );
+
+ const getItemLayout = useCallback(
+ (_data: ArrayLike | null | undefined, index: number) => ({
+ length: sizes.posters.poster + sizes.gaps.item,
+ offset: (sizes.posters.poster + sizes.gaps.item) * index,
+ index,
+ }),
+ [sizes],
+ );
+
+ const renderItem = useCallback(
+ ({ item }: { item: BaseItemDto }) => {
+ return (
+
+ handleItemPress(item)}
+ onLongPress={() => showItemActions(item)}
+ onFocus={() => onItemFocus?.(item)}
+ width={sizes.posters.poster}
+ />
+
+ );
+ },
+ [sizes, handleItemPress, showItemActions, onItemFocus],
+ );
+
+ if (!streamyStatsEnabled) return null;
+ if (isError) return null;
+ if (!isLoading && (!items || items.length === 0)) return null;
+
+ return (
+
+
+ {title}
+
+
+ {isLoading ? (
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+
+
+
+ Placeholder text here
+
+
+
+ ))}
+
+ ) : (
+ item.Id!}
+ renderItem={renderItem}
+ showsHorizontalScrollIndicator={false}
+ initialNumToRender={5}
+ maxToRenderPerBatch={3}
+ windowSize={5}
+ removeClippedSubviews={false}
+ getItemLayout={getItemLayout}
+ style={{ overflow: "visible" }}
+ contentInset={{
+ left: sizes.padding.horizontal,
+ right: sizes.padding.horizontal,
+ }}
+ contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
+ contentContainerStyle={{
+ paddingVertical: sizes.padding.scale,
+ }}
+ />
+ )}
+
+ );
+};
diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx
new file mode 100644
index 000000000..596f2d369
--- /dev/null
+++ b/components/home/TVHeroCarousel.tsx
@@ -0,0 +1,675 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { Image } from "expo-image";
+import { LinearGradient } from "expo-linear-gradient";
+import { useAtomValue } from "jotai";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import {
+ Animated,
+ Dimensions,
+ Easing,
+ FlatList,
+ Platform,
+ Pressable,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { ProgressBar } from "@/components/common/ProgressBar";
+import { Text } from "@/components/common/Text";
+import { getItemNavigation } from "@/components/common/TouchableItemRouter";
+import { type ScaledTVSizes, useScaledTVSizes } from "@/constants/TVSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
+import {
+ GlassPosterView,
+ isGlassEffectAvailable,
+} from "@/modules/glass-poster";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
+import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
+import { scaleSize } from "@/utils/scaleSize";
+import { runtimeTicksToMinutes } from "@/utils/time";
+
+const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
+
+interface TVHeroCarouselProps {
+ items: BaseItemDto[];
+ onItemFocus?: (item: BaseItemDto) => void;
+ onItemLongPress?: (item: BaseItemDto) => void;
+}
+
+interface HeroCardProps {
+ item: BaseItemDto;
+ isFirst: boolean;
+ sizes: ScaledTVSizes;
+ onFocus: (item: BaseItemDto) => void;
+ onPress: (item: BaseItemDto) => void;
+ onLongPress?: (item: BaseItemDto) => void;
+}
+
+const HeroCard: React.FC = React.memo(
+ ({ item, isFirst, sizes, onFocus, onPress, onLongPress }) => {
+ const api = useAtomValue(apiAtom);
+ const [focused, setFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+
+ // Check if glass effect is available (tvOS 26+)
+ const useGlass = Platform.OS === "ios" && isGlassEffectAvailable();
+
+ const posterUrl = useMemo(() => {
+ if (!api) return null;
+
+ // For episodes, always use series thumb
+ if (item.Type === "Episode") {
+ if (item.ParentThumbImageTag) {
+ return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
+ }
+ if (item.SeriesId) {
+ return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
+ }
+ }
+
+ // For non-episodes, use item's own thumb/primary
+ if (item.ImageTags?.Thumb) {
+ return `${api.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ImageTags.Thumb}`;
+ }
+ if (item.ImageTags?.Primary) {
+ return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=400&quality=80&tag=${item.ImageTags.Primary}`;
+ }
+ return null;
+ }, [api, item]);
+
+ const animateTo = useCallback(
+ (value: number) =>
+ Animated.timing(scale, {
+ toValue: value,
+ duration: 150,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start(),
+ [scale],
+ );
+
+ const handleFocus = useCallback(() => {
+ setFocused(true);
+ animateTo(sizes.animation.focusScale);
+ onFocus(item);
+ }, [animateTo, onFocus, item, sizes.animation.focusScale]);
+
+ const handleBlur = useCallback(() => {
+ setFocused(false);
+ animateTo(1);
+ }, [animateTo]);
+
+ const handlePress = useCallback(() => {
+ onPress(item);
+ }, [onPress, item]);
+
+ const handleLongPress = useCallback(() => {
+ onLongPress?.(item);
+ }, [onLongPress, item]);
+
+ // Use glass poster for tvOS 26+
+ if (useGlass && posterUrl) {
+ const progress = item.UserData?.PlayedPercentage || 0;
+ return (
+
+
+
+ );
+ }
+
+ // Fallback for non-tvOS or older tvOS
+ return (
+
+
+ {posterUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ );
+ },
+);
+
+// Debounce delay to prevent rapid backdrop changes when navigating fast
+const BACKDROP_DEBOUNCE_MS = 300;
+
+export const TVHeroCarousel: React.FC = ({
+ items,
+ onItemFocus,
+ onItemLongPress,
+}) => {
+ const typography = useScaledTVTypography();
+ const sizes = useScaledTVSizes();
+ const api = useAtomValue(apiAtom);
+ const insets = useSafeAreaInsets();
+ const router = useRouter();
+
+ // Active item for featured display (debounced)
+ const [activeItem, setActiveItem] = useState(
+ items[0] || null,
+ );
+ const debounceTimerRef = useRef | null>(null);
+
+ // Cleanup debounce timer on unmount
+ useEffect(() => {
+ return () => {
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current);
+ }
+ };
+ }, []);
+
+ // Crossfade animation state
+ const [activeLayer, setActiveLayer] = useState<0 | 1>(0);
+ const [layer0Url, setLayer0Url] = useState(null);
+ const [layer1Url, setLayer1Url] = useState(null);
+ const layer0Opacity = useRef(new Animated.Value(0)).current;
+ const layer1Opacity = useRef(new Animated.Value(0)).current;
+
+ // Get backdrop URL for active item
+ const backdropUrl = useMemo(() => {
+ if (!activeItem) return null;
+ return getBackdropUrl({
+ api,
+ item: activeItem,
+ quality: 90,
+ width: 1920,
+ });
+ }, [api, activeItem]);
+
+ // Get logo URL for active item
+ const logoUrl = useMemo(() => {
+ if (!activeItem) return null;
+ return getLogoImageUrlById({ api, item: activeItem });
+ }, [api, activeItem]);
+
+ // Crossfade effect for backdrop
+ useEffect(() => {
+ if (!backdropUrl) return;
+
+ let isCancelled = false;
+
+ const performCrossfade = async () => {
+ // Disk-only prefetch: backdrops are ~8MB decoded ARGB; keeping them
+ // out of the memory cache avoids bloat when the user cycles through
+ // hero items quickly.
+ try {
+ await Image.prefetch(backdropUrl, "disk");
+ } catch {
+ // Continue even if prefetch fails
+ }
+
+ if (isCancelled) return;
+
+ const incomingLayer = activeLayer === 0 ? 1 : 0;
+ const incomingOpacity =
+ incomingLayer === 0 ? layer0Opacity : layer1Opacity;
+ const outgoingOpacity =
+ incomingLayer === 0 ? layer1Opacity : layer0Opacity;
+
+ if (incomingLayer === 0) {
+ setLayer0Url(backdropUrl);
+ } else {
+ setLayer1Url(backdropUrl);
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ if (isCancelled) return;
+
+ Animated.parallel([
+ Animated.timing(incomingOpacity, {
+ toValue: 1,
+ duration: 500,
+ easing: Easing.inOut(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(outgoingOpacity, {
+ toValue: 0,
+ duration: 500,
+ easing: Easing.inOut(Easing.quad),
+ useNativeDriver: true,
+ }),
+ ]).start(() => {
+ if (!isCancelled) {
+ setActiveLayer(incomingLayer);
+ }
+ });
+ };
+
+ performCrossfade();
+
+ return () => {
+ isCancelled = true;
+ };
+ }, [backdropUrl]);
+
+ // Handle card focus with debounce
+ const handleCardFocus = useCallback(
+ (item: BaseItemDto) => {
+ // Clear any pending debounce timer
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current);
+ }
+ // Set new timer to update active item after debounce delay
+ debounceTimerRef.current = setTimeout(() => {
+ setActiveItem(item);
+ onItemFocus?.(item);
+ }, BACKDROP_DEBOUNCE_MS);
+ },
+ [onItemFocus],
+ );
+
+ // Handle card press - navigate to item
+ const handleCardPress = useCallback(
+ (item: BaseItemDto) => {
+ const navigation = getItemNavigation(item, "(home)");
+ router.push(navigation as any);
+ },
+ [router],
+ );
+
+ // Get metadata for active item
+ const year = activeItem?.ProductionYear;
+ const duration = activeItem?.RunTimeTicks
+ ? runtimeTicksToMinutes(activeItem.RunTimeTicks)
+ : null;
+ const hasProgress = (activeItem?.UserData?.PlaybackPositionTicks ?? 0) > 0;
+ const playedPercent = activeItem?.UserData?.PlayedPercentage ?? 0;
+
+ // Get display title
+ const displayTitle = useMemo(() => {
+ if (!activeItem) return "";
+ if (activeItem.Type === "Episode") {
+ return activeItem.SeriesName || activeItem.Name || "";
+ }
+ return activeItem.Name || "";
+ }, [activeItem]);
+
+ // Get subtitle for episodes
+ const episodeSubtitle = useMemo(() => {
+ if (activeItem?.Type !== "Episode") return null;
+ return `S${activeItem.ParentIndexNumber} E${activeItem.IndexNumber} · ${activeItem.Name}`;
+ }, [activeItem]);
+
+ // Memoize hero items to prevent re-renders
+ const heroItems = useMemo(() => items.slice(0, 8), [items]);
+
+ // Memoize renderItem for FlatList
+ const renderHeroCard = useCallback(
+ ({ item, index }: { item: BaseItemDto; index: number }) => (
+
+ ),
+ [handleCardFocus, handleCardPress, onItemLongPress, sizes],
+ );
+
+ // Memoize keyExtractor
+ const keyExtractor = useCallback((item: BaseItemDto) => item.Id!, []);
+
+ if (items.length === 0) return null;
+
+ // Extra top padding for tvOS to clear the menu bar
+ const tvosTopPadding = scaleSize(145);
+ const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
+
+ return (
+
+ {/* Backdrop layers with crossfade */}
+
+ {/* Layer 0 */}
+
+ {layer0Url && (
+
+ )}
+
+ {/* Layer 1 */}
+
+ {layer1Url && (
+
+ )}
+
+
+ {/* Gradient overlays */}
+
+
+ {/* Horizontal gradient for left side text contrast */}
+
+
+
+ {/* Content overlay - text elements with padding */}
+
+ {/* Logo or Title */}
+ {logoUrl ? (
+
+ ) : (
+
+ {displayTitle}
+
+ )}
+
+ {/* Episode subtitle */}
+ {episodeSubtitle && (
+
+ {episodeSubtitle}
+
+ )}
+
+ {/* Description */}
+ {activeItem?.Overview && (
+
+ {activeItem.Overview}
+
+ )}
+
+ {/* Metadata badges */}
+
+ {year && (
+
+ {year}
+
+ )}
+ {duration && (
+
+ {duration}
+
+ )}
+ {activeItem?.OfficialRating && (
+
+
+ {activeItem.OfficialRating}
+
+
+ )}
+ {hasProgress && (
+
+
+
+
+
+ {Math.round(playedPercent)}%
+
+
+ )}
+
+
+
+ {/* Thumbnail carousel - edge-to-edge */}
+
+
+ // }
+ // contentInset={{
+ // left: sizes.padding.horizontal,
+ // right: sizes.padding.horizontal,
+ // }}
+ // contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
+ // contentContainerStyle={{ paddingVertical: sizes.gaps.small }}
+ renderItem={renderHeroCard}
+ removeClippedSubviews={false}
+ initialNumToRender={8}
+ maxToRenderPerBatch={8}
+ windowSize={3}
+ />
+
+
+ );
+};
diff --git a/components/inputs/TVPinInput.tsx b/components/inputs/TVPinInput.tsx
new file mode 100644
index 000000000..d55970ebd
--- /dev/null
+++ b/components/inputs/TVPinInput.tsx
@@ -0,0 +1,141 @@
+import React, { useRef, useState } from "react";
+import {
+ Animated,
+ Easing,
+ Pressable,
+ StyleSheet,
+ TextInput,
+ type TextInputProps,
+} from "react-native";
+import { Text } from "@/components/common/Text";
+
+interface TVPinInputProps
+ extends Omit {
+ value: string;
+ onChangeText: (text: string) => void;
+ length?: number;
+ label?: string;
+ hasTVPreferredFocus?: boolean;
+}
+
+export interface TVPinInputRef {
+ focus: () => void;
+}
+
+const TVPinInputComponent = React.forwardRef(
+ (props, ref) => {
+ const {
+ value,
+ onChangeText,
+ length = 4,
+ label,
+ hasTVPreferredFocus,
+ placeholder,
+ ...rest
+ } = props;
+
+ const inputRef = useRef(null);
+ const [isFocused, setIsFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+
+ React.useImperativeHandle(
+ ref,
+ () => ({
+ focus: () => inputRef.current?.focus(),
+ }),
+ [],
+ );
+
+ const animateFocus = (focused: boolean) => {
+ Animated.timing(scale, {
+ toValue: focused ? 1.02 : 1,
+ duration: 150,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start();
+ };
+
+ const handleChangeText = (text: string) => {
+ // Only allow numeric input and limit to length
+ const numericText = text.replace(/[^0-9]/g, "").slice(0, length);
+ onChangeText(numericText);
+ };
+
+ return (
+ inputRef.current?.focus()}
+ onFocus={() => {
+ setIsFocused(true);
+ animateFocus(true);
+ inputRef.current?.focus();
+ }}
+ onBlur={() => {
+ setIsFocused(false);
+ animateFocus(false);
+ }}
+ hasTVPreferredFocus={hasTVPreferredFocus}
+ >
+
+ {label && {label}}
+ {
+ setIsFocused(true);
+ animateFocus(true);
+ }}
+ onBlur={() => {
+ setIsFocused(false);
+ animateFocus(false);
+ }}
+ {...rest}
+ />
+
+
+ );
+ },
+);
+
+TVPinInputComponent.displayName = "TVPinInput";
+
+export const TVPinInput = TVPinInputComponent;
+
+const styles = StyleSheet.create({
+ container: {
+ backgroundColor: "#1F2937",
+ borderRadius: 12,
+ borderWidth: 2,
+ paddingHorizontal: 20,
+ paddingVertical: 4,
+ minWidth: 280,
+ },
+ label: {
+ fontSize: 14,
+ color: "rgba(255,255,255,0.6)",
+ marginBottom: 4,
+ marginTop: 8,
+ },
+ input: {
+ fontSize: 24,
+ color: "#fff",
+ fontWeight: "500",
+ textAlign: "center",
+ height: 56,
+ letterSpacing: 8,
+ },
+});
diff --git a/components/item/ItemPeopleSections.tsx b/components/item/ItemPeopleSections.tsx
new file mode 100644
index 000000000..456276da2
--- /dev/null
+++ b/components/item/ItemPeopleSections.tsx
@@ -0,0 +1,90 @@
+import type {
+ BaseItemDto,
+ BaseItemPerson,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import type React from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { InteractionManager, View, type ViewProps } from "react-native";
+import { MoreMoviesWithActor } from "@/components/MoreMoviesWithActor";
+import { CastAndCrew } from "@/components/series/CastAndCrew";
+import { useItemPeopleQuery } from "@/hooks/useItemPeopleQuery";
+import { useOfflineMode } from "@/providers/OfflineModeProvider";
+
+interface Props extends ViewProps {
+ item: BaseItemDto;
+}
+
+export const ItemPeopleSections: React.FC = ({ item, ...props }) => {
+ const isOffline = useOfflineMode();
+ const [enabled, setEnabled] = useState(false);
+
+ useEffect(() => {
+ if (isOffline) return;
+ const task = InteractionManager.runAfterInteractions(() =>
+ setEnabled(true),
+ );
+ return () => task.cancel();
+ }, [isOffline]);
+
+ const { data, isLoading } = useItemPeopleQuery(
+ item.Id,
+ enabled && !isOffline,
+ );
+
+ const people = useMemo(() => (Array.isArray(data) ? data : []), [data]);
+
+ const itemWithPeople = useMemo(() => {
+ return { ...item, People: people } as BaseItemDto;
+ }, [item, people]);
+
+ // Jellyfin can list the same person several times (e.g. an actor also
+ // credited as writer). Dedupe by Id so the same actor section isn't rendered
+ // twice and we still surface 3 distinct people.
+ const topPeople = useMemo(() => {
+ const seen = new Set();
+ const unique: BaseItemPerson[] = [];
+ for (const person of people) {
+ if (!person.Id || seen.has(person.Id)) continue;
+ seen.add(person.Id);
+ unique.push(person);
+ if (unique.length >= 3) break;
+ }
+ return unique;
+ }, [people]);
+
+ const renderActorSection = useCallback(
+ (person: BaseItemPerson, idx: number, total: number) => {
+ if (!person.Id) return null;
+
+ const spacingClassName = idx === total - 1 ? undefined : "mb-2";
+
+ return (
+
+ );
+ },
+ [item],
+ );
+
+ if (isOffline || !enabled) return null;
+
+ const shouldSpaceCastAndCrew = topPeople.length > 0;
+
+ return (
+
+
+ {topPeople.map((person, idx) =>
+ renderActorSection(person, idx, topPeople.length),
+ )}
+
+ );
+};
diff --git a/components/jellyseerr/GridSkeleton.tsx b/components/jellyseerr/GridSkeleton.tsx
new file mode 100644
index 000000000..75431cd7b
--- /dev/null
+++ b/components/jellyseerr/GridSkeleton.tsx
@@ -0,0 +1,21 @@
+import { View } from "react-native";
+
+interface Props {
+ index: number;
+}
+// Dev note might be a good idea to standardize skeletons across the app and have one "file" for it.
+export const GridSkeleton: React.FC = ({ index }) => {
+ return (
+
+
+
+
+
+
+
+ );
+};
diff --git a/components/jellyseerr/ParallaxSlideShow.tsx b/components/jellyseerr/ParallaxSlideShow.tsx
index b4b75592b..261da73dd 100644
--- a/components/jellyseerr/ParallaxSlideShow.tsx
+++ b/components/jellyseerr/ParallaxSlideShow.tsx
@@ -11,6 +11,7 @@ import { Animated, View, type ViewProps } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { ParallaxScrollView } from "@/components/ParallaxPage";
+import { GridSkeleton } from "./GridSkeleton";
const ANIMATION_ENTER = 250;
const ANIMATION_EXIT = 250;
@@ -28,6 +29,7 @@ interface Props {
renderItem: (item: T, index: number) => Render;
keyExtractor: (item: T) => string;
onEndReached?: (() => void) | null | undefined;
+ isLoading?: boolean;
}
const ParallaxSlideShow = ({
@@ -40,6 +42,7 @@ const ParallaxSlideShow = ({
renderItem,
keyExtractor,
onEndReached,
+ isLoading = false,
}: PropsWithChildren & ViewProps>) => {
const insets = useSafeAreaInsets();
@@ -124,27 +127,40 @@ const ParallaxSlideShow = ({
{MainContent?.()}
-
-
- No results
-
-
- }
- contentInsetAdjustmentBehavior='automatic'
- ListHeaderComponent={
+ {isLoading ? (
+
{listHeader}
- }
- nestedScrollEnabled
- showsVerticalScrollIndicator={false}
- //@ts-expect-error
- renderItem={({ item, index }) => renderItem(item, index)}
- keyExtractor={keyExtractor}
- numColumns={3}
- ItemSeparatorComponent={() => }
- />
+
+
+ {Array.from({ length: 9 }, (_, i) => (
+
+ ))}
+
+
+
+ ) : (
+
+
+ No results
+
+
+ }
+ contentInsetAdjustmentBehavior='automatic'
+ ListHeaderComponent={
+ {listHeader}
+ }
+ nestedScrollEnabled
+ showsVerticalScrollIndicator={false}
+ //@ts-expect-error
+ renderItem={({ item, index }) => renderItem(item, index)}
+ keyExtractor={keyExtractor}
+ numColumns={3}
+ ItemSeparatorComponent={() => }
+ />
+ )}
diff --git a/components/jellyseerr/PersonPoster.tsx b/components/jellyseerr/PersonPoster.tsx
index 70e44d351..e926b093d 100644
--- a/components/jellyseerr/PersonPoster.tsx
+++ b/components/jellyseerr/PersonPoster.tsx
@@ -1,8 +1,9 @@
-import { useRouter, useSegments } from "expo-router";
+import { useSegments } from "expo-router";
import type React from "react";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import Poster from "@/components/posters/Poster";
+import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
interface Props {
diff --git a/components/jellyseerr/discover/CompanySlide.tsx b/components/jellyseerr/discover/CompanySlide.tsx
index 9643f48e7..abba1d312 100644
--- a/components/jellyseerr/discover/CompanySlide.tsx
+++ b/components/jellyseerr/discover/CompanySlide.tsx
@@ -1,9 +1,10 @@
-import { router, useSegments } from "expo-router";
+import { useSegments } from "expo-router";
import type React from "react";
import { useCallback } from "react";
import { TouchableOpacity, type ViewProps } from "react-native";
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
+import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import {
COMPANY_LOGO_IMAGE_FILTER,
@@ -16,6 +17,7 @@ const CompanySlide: React.FC<
> = ({ slide, data, ...props }) => {
const segments = useSegments();
const { jellyseerrApi } = useJellyseerr();
+ const router = useRouter();
const from = (segments as string[])[2] || "(home)";
const navigate = useCallback(
diff --git a/components/jellyseerr/discover/GenreSlide.tsx b/components/jellyseerr/discover/GenreSlide.tsx
index 8edaf4c3d..312485407 100644
--- a/components/jellyseerr/discover/GenreSlide.tsx
+++ b/components/jellyseerr/discover/GenreSlide.tsx
@@ -1,10 +1,11 @@
import { useQuery } from "@tanstack/react-query";
-import { router, useSegments } from "expo-router";
+import { useSegments } from "expo-router";
import type React from "react";
import { useCallback } from "react";
import { TouchableOpacity, type ViewProps } from "react-native";
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
+import useRouter from "@/hooks/useAppRouter";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
@@ -13,6 +14,7 @@ import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/consta
const GenreSlide: React.FC = ({ slide, ...props }) => {
const segments = useSegments();
const { jellyseerrApi } = useJellyseerr();
+ const router = useRouter();
const from = (segments as string[])[2] || "(home)";
const navigate = useCallback(
diff --git a/components/jellyseerr/discover/TVDiscover.tsx b/components/jellyseerr/discover/TVDiscover.tsx
new file mode 100644
index 000000000..9dc55fe7e
--- /dev/null
+++ b/components/jellyseerr/discover/TVDiscover.tsx
@@ -0,0 +1,47 @@
+import { sortBy } from "lodash";
+import React, { useMemo } from "react";
+import { View } from "react-native";
+import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
+import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
+import { TVDiscoverSlide } from "./TVDiscoverSlide";
+
+interface TVDiscoverProps {
+ sliders?: DiscoverSlider[];
+}
+
+// Only show movie/TV slides on TV - skip genres, networks, studios for now
+const SUPPORTED_SLIDE_TYPES = [
+ DiscoverSliderType.TRENDING,
+ DiscoverSliderType.POPULAR_MOVIES,
+ DiscoverSliderType.UPCOMING_MOVIES,
+ DiscoverSliderType.POPULAR_TV,
+ DiscoverSliderType.UPCOMING_TV,
+];
+
+export const TVDiscover: React.FC = ({ sliders }) => {
+ const sortedSliders = useMemo(
+ () =>
+ sortBy(
+ (sliders ?? []).filter(
+ (s) => s.enabled && SUPPORTED_SLIDE_TYPES.includes(s.type),
+ ),
+ "order",
+ "asc",
+ ),
+ [sliders],
+ );
+
+ if (!sliders || sortedSliders.length === 0) return null;
+
+ return (
+
+ {sortedSliders.map((slide, index) => (
+
+ ))}
+
+ );
+};
diff --git a/components/jellyseerr/discover/TVDiscoverSlide.tsx b/components/jellyseerr/discover/TVDiscoverSlide.tsx
new file mode 100644
index 000000000..5f98fcf53
--- /dev/null
+++ b/components/jellyseerr/discover/TVDiscoverSlide.tsx
@@ -0,0 +1,270 @@
+import { Ionicons } from "@expo/vector-icons";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { Image } from "expo-image";
+import { uniqBy } from "lodash";
+import React, { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { Animated, FlatList, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
+import {
+ type DiscoverEndpoint,
+ Endpoints,
+ useJellyseerr,
+} from "@/hooks/useJellyseerr";
+import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
+import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
+import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
+import type {
+ MovieResult,
+ TvResult,
+} from "@/utils/jellyseerr/server/models/Search";
+
+const SCALE_PADDING = 20;
+
+interface TVDiscoverPosterProps {
+ item: MovieResult | TvResult;
+ isFirstItem?: boolean;
+}
+
+const TVDiscoverPoster: React.FC = ({
+ item,
+ isFirstItem = false,
+}) => {
+ const typography = useScaledTVTypography();
+ const router = useRouter();
+ const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.05 });
+
+ const posterUrl = item.posterPath
+ ? jellyseerrApi?.imageProxy(item.posterPath, "w342")
+ : null;
+
+ const title = getTitle(item);
+ const year = getYear(item);
+
+ const isInLibrary =
+ item.mediaInfo?.status === MediaStatus.AVAILABLE ||
+ item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
+
+ const handlePress = () => {
+ router.push({
+ pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
+ params: {
+ id: String(item.id),
+ mediaType: item.mediaType,
+ },
+ });
+ };
+
+ return (
+
+
+
+ {posterUrl ? (
+
+ ) : (
+
+
+
+ )}
+ {isInLibrary && (
+
+
+
+ )}
+
+
+ {title}
+
+ {year && (
+
+ {year}
+
+ )}
+
+
+ );
+};
+
+interface TVDiscoverSlideProps {
+ slide: DiscoverSlider;
+ isFirstSlide?: boolean;
+}
+
+export const TVDiscoverSlide: React.FC = ({
+ slide,
+ isFirstSlide = false,
+}) => {
+ const typography = useScaledTVTypography();
+ const { t } = useTranslation();
+ const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
+
+ const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
+ queryKey: ["jellyseerr", "discover", "tv", slide.id],
+ queryFn: async ({ pageParam }) => {
+ let endpoint: DiscoverEndpoint | undefined;
+ let params: Record = {
+ page: Number(pageParam),
+ };
+
+ switch (slide.type) {
+ case DiscoverSliderType.TRENDING:
+ endpoint = Endpoints.DISCOVER_TRENDING;
+ break;
+ case DiscoverSliderType.POPULAR_MOVIES:
+ case DiscoverSliderType.UPCOMING_MOVIES:
+ endpoint = Endpoints.DISCOVER_MOVIES;
+ if (slide.type === DiscoverSliderType.UPCOMING_MOVIES)
+ params = {
+ ...params,
+ primaryReleaseDateGte: new Date().toISOString().split("T")[0],
+ };
+ break;
+ case DiscoverSliderType.POPULAR_TV:
+ case DiscoverSliderType.UPCOMING_TV:
+ endpoint = Endpoints.DISCOVER_TV;
+ if (slide.type === DiscoverSliderType.UPCOMING_TV)
+ params = {
+ ...params,
+ firstAirDateGte: new Date().toISOString().split("T")[0],
+ };
+ break;
+ }
+
+ return endpoint ? jellyseerrApi?.discover(endpoint, params) : null;
+ },
+ initialPageParam: 1,
+ getNextPageParam: (lastPage, pages) =>
+ (lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
+ 1,
+ enabled: !!jellyseerrApi,
+ staleTime: 0,
+ });
+
+ const flatData = useMemo(
+ () =>
+ uniqBy(
+ data?.pages
+ ?.filter((p) => p?.results.length)
+ .flatMap((p) =>
+ p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)),
+ ),
+ "id",
+ ) as (MovieResult | TvResult)[],
+ [data, isJellyseerrMovieOrTvResult],
+ );
+
+ const slideTitle = t(
+ `search.${DiscoverSliderType[slide.type].toString().toLowerCase()}`,
+ );
+
+ if (!flatData || flatData.length === 0) return null;
+
+ return (
+
+
+ {slideTitle}
+
+ item.id.toString()}
+ showsHorizontalScrollIndicator={false}
+ contentContainerStyle={{
+ paddingHorizontal: SCALE_PADDING,
+ paddingVertical: SCALE_PADDING,
+ gap: 20,
+ }}
+ style={{ overflow: "visible" }}
+ onEndReached={() => {
+ if (hasNextPage) fetchNextPage();
+ }}
+ onEndReachedThreshold={0.5}
+ renderItem={({ item, index }) => (
+
+ )}
+ />
+
+ );
+};
diff --git a/components/jellyseerr/tv/TVJellyseerrPage.tsx b/components/jellyseerr/tv/TVJellyseerrPage.tsx
new file mode 100644
index 000000000..f44c7f8e9
--- /dev/null
+++ b/components/jellyseerr/tv/TVJellyseerrPage.tsx
@@ -0,0 +1,877 @@
+import { Ionicons } from "@expo/vector-icons";
+import { useQuery } from "@tanstack/react-query";
+import { BlurView } from "expo-blur";
+import { Image } from "expo-image";
+import { LinearGradient } from "expo-linear-gradient";
+import { useLocalSearchParams } from "expo-router";
+import React, { useCallback, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Animated,
+ Dimensions,
+ Pressable,
+ ScrollView,
+ TVFocusGuideView,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { toast } from "sonner-native";
+import { Text } from "@/components/common/Text";
+import { GenreTags } from "@/components/GenreTags";
+import { Loader } from "@/components/Loader";
+import { JellyserrRatings } from "@/components/Ratings";
+import { TVButton } from "@/components/tv";
+import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
+import { useJellyseerr } from "@/hooks/useJellyseerr";
+import { useTVRequestModal } from "@/hooks/useTVRequestModal";
+import { useTVSeasonSelectModal } from "@/hooks/useTVSeasonSelectModal";
+import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
+import {
+ MediaRequestStatus,
+ MediaStatus,
+ MediaType,
+} from "@/utils/jellyseerr/server/constants/media";
+import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
+import type Season from "@/utils/jellyseerr/server/entity/Season";
+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 { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
+
+// Cast card component
+interface TVCastCardProps {
+ person: {
+ id: number;
+ name: string;
+ character?: string;
+ profilePath?: string;
+ };
+ imageProxy: (path: string, size?: string) => string;
+ onPress: () => void;
+ refSetter?: (ref: View | null) => void;
+}
+
+const TVCastCard: React.FC = ({
+ person,
+ imageProxy,
+ onPress,
+ refSetter,
+}) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.08 });
+
+ const profileUrl = person.profilePath
+ ? imageProxy(person.profilePath, "w185")
+ : null;
+
+ return (
+
+
+
+ {profileUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {person.name}
+
+ {person.character && (
+
+ {person.character}
+
+ )}
+
+
+ );
+};
+
+export const TVJellyseerrPage: React.FC = () => {
+ const typography = useScaledTVTypography();
+ const insets = useSafeAreaInsets();
+ const params = useLocalSearchParams();
+ const { t } = useTranslation();
+ const router = useRouter();
+
+ const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } =
+ params as unknown as {
+ mediaTitle: string;
+ releaseYear: number;
+ canRequest: string;
+ posterSrc: string;
+ mediaType: MediaType;
+ } & Partial;
+
+ const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
+ const { showRequestModal } = useTVRequestModal();
+ const { showSeasonSelectModal } = useTVSeasonSelectModal();
+
+ // Refs for TVFocusGuideView destinations (useState triggers re-render when set)
+ const [playButtonRef, setPlayButtonRef] = useState(null);
+ const [firstCastCardRef, setFirstCastCardRef] = useState(null);
+
+ const {
+ data: details,
+ isFetching,
+ isLoading,
+ refetch,
+ } = useQuery({
+ enabled: !!jellyseerrApi && !!result && !!result.id,
+ queryKey: ["jellyseerr", "detail", mediaType, result.id],
+ staleTime: 0,
+ refetchOnMount: true,
+ queryFn: async () => {
+ return mediaType === MediaType.MOVIE
+ ? jellyseerrApi?.movieDetails(result.id!)
+ : jellyseerrApi?.tvDetails(result.id!);
+ },
+ });
+
+ 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]);
+
+ // Get seasons with status for TV shows
+ const seasons = useMemo(() => {
+ if (!details || mediaType !== MediaType.TV) return [];
+ const tvDetails = details as TvDetails;
+ const mediaInfoSeasons = tvDetails.mediaInfo?.seasons?.filter(
+ (s: Season) => s.seasonNumber !== 0,
+ );
+ const requestedSeasons =
+ tvDetails.mediaInfo?.requests?.flatMap((r: MediaRequest) => r.seasons) ??
+ [];
+ return (
+ tvDetails.seasons?.map((season) => ({
+ ...season,
+ status:
+ mediaInfoSeasons?.find(
+ (mediaSeason: Season) =>
+ mediaSeason.seasonNumber === season.seasonNumber,
+ )?.status ??
+ requestedSeasons?.find(
+ (s: Season) => s.seasonNumber === season.seasonNumber,
+ )?.status ??
+ MediaStatus.UNKNOWN,
+ })) ?? []
+ );
+ }, [details, mediaType]);
+
+ const _allSeasonsAvailable = useMemo(
+ () => seasons.every((season) => season.status === MediaStatus.AVAILABLE),
+ [seasons],
+ );
+
+ // Check if there are any requestable seasons (status === UNKNOWN)
+ const hasRequestableSeasons = useMemo(
+ () =>
+ seasons.some(
+ (season) =>
+ season.seasonNumber !== 0 && season.status === MediaStatus.UNKNOWN,
+ ),
+ [seasons],
+ );
+
+ // Get cast
+ const cast = useMemo(() => {
+ return details?.credits?.cast?.slice(0, 10) ?? [];
+ }, [details]);
+
+ // Backdrop URL
+ const backdropUrl = useMemo(() => {
+ const path = details?.backdropPath || result.backdropPath;
+ return path
+ ? jellyseerrApi?.imageProxy(path, "w1920_and_h800_multi_faces")
+ : null;
+ }, [details, result.backdropPath, jellyseerrApi]);
+
+ // Poster URL
+ const posterUrl = useMemo(() => {
+ if (posterSrc) return posterSrc;
+ const path = details?.posterPath;
+ return path ? jellyseerrApi?.imageProxy(path, "w342") : null;
+ }, [posterSrc, details, jellyseerrApi]);
+
+ // Handlers
+ 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"));
+ }
+ }, [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"));
+ }
+ }, [jellyseerrApi, pendingRequest, refetch, t]);
+
+ const handleRequest = useCallback(async () => {
+ const body: MediaRequestBody = {
+ mediaId: Number(result.id!),
+ mediaType: mediaType!,
+ tvdbId: details?.externalIds?.tvdbId,
+ ...(mediaType === MediaType.TV && {
+ seasons: (details as TvDetails)?.seasons
+ ?.filter?.((s) => s.seasonNumber !== 0)
+ ?.map?.((s) => s.seasonNumber),
+ }),
+ };
+
+ if (hasAdvancedRequestPermission) {
+ showRequestModal({
+ requestBody: body,
+ title: mediaTitle,
+ id: result.id!,
+ mediaType: mediaType!,
+ onRequested: refetch,
+ });
+ return;
+ }
+
+ requestMedia(mediaTitle, body, refetch);
+ }, [
+ details,
+ result,
+ requestMedia,
+ hasAdvancedRequestPermission,
+ mediaTitle,
+ refetch,
+ mediaType,
+ showRequestModal,
+ ]);
+
+ const handleRequestAll = useCallback(() => {
+ const body: MediaRequestBody = {
+ mediaId: Number(result.id!),
+ mediaType: MediaType.TV,
+ tvdbId: details?.externalIds?.tvdbId,
+ seasons: seasons
+ .filter((s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0)
+ .map((s) => s.seasonNumber),
+ };
+
+ if (hasAdvancedRequestPermission) {
+ showRequestModal({
+ requestBody: body,
+ title: mediaTitle,
+ id: result.id!,
+ mediaType: MediaType.TV,
+ onRequested: refetch,
+ });
+ return;
+ }
+
+ requestMedia(`${mediaTitle}, ${t("jellyseerr.season_all")}`, body, refetch);
+ }, [
+ details,
+ result,
+ seasons,
+ hasAdvancedRequestPermission,
+ requestMedia,
+ mediaTitle,
+ refetch,
+ t,
+ showRequestModal,
+ ]);
+
+ const handleOpenSeasonSelectModal = useCallback(() => {
+ showSeasonSelectModal({
+ seasons: seasons.filter((s) => s.seasonNumber !== 0),
+ title: mediaTitle,
+ mediaId: Number(result.id!),
+ tvdbId: details?.externalIds?.tvdbId,
+ hasAdvancedRequestPermission,
+ onRequested: refetch,
+ });
+ }, [
+ seasons,
+ mediaTitle,
+ result,
+ details,
+ hasAdvancedRequestPermission,
+ refetch,
+ showSeasonSelectModal,
+ ]);
+
+ const handlePlay = useCallback(() => {
+ const jellyfinMediaId = details?.mediaInfo?.jellyfinMediaId;
+ if (!jellyfinMediaId) return;
+ router.push({
+ pathname:
+ mediaType === MediaType.MOVIE
+ ? "/(auth)/(tabs)/(search)/items/page"
+ : "/(auth)/(tabs)/(search)/series/[id]",
+ params: { id: jellyfinMediaId },
+ });
+ }, [details, mediaType, router]);
+
+ const handleCastPress = useCallback(
+ (personId: number) => {
+ router.push(`/(auth)/jellyseerr/person/${personId}` as any);
+ },
+ [router],
+ );
+
+ const hasJellyfinMedia = !!details?.mediaInfo?.jellyfinMediaId;
+ const requestedByName =
+ pendingRequest?.requestedBy?.displayName ||
+ pendingRequest?.requestedBy?.username ||
+ pendingRequest?.requestedBy?.jellyfinUsername ||
+ t("jellyseerr.unknown_user");
+
+ if (isLoading || isFetching) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Full-screen backdrop */}
+
+ {backdropUrl ? (
+
+ ) : (
+
+ )}
+ {/* Bottom gradient */}
+
+ {/* Left gradient */}
+
+
+
+ {/* Main content */}
+
+ {/* Top section - Poster + Content */}
+
+ {/* Left side - Poster */}
+
+
+ {posterUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {/* Right side - Content */}
+
+ {/* Ratings */}
+ {details && (
+
+ )}
+
+ {/* Title */}
+
+ {mediaTitle}
+
+
+ {/* Year */}
+
+ {releaseYear}
+
+
+ {/* Genres */}
+ {details?.genres && details.genres.length > 0 && (
+
+ g.name)} />
+
+ )}
+
+ {/* Overview */}
+ {(details?.overview || result.overview) && (
+
+
+
+ {details?.overview || result.overview}
+
+
+
+ )}
+
+ {/* Action buttons */}
+
+ {hasJellyfinMedia && (
+
+
+
+ {t("common.play")}
+
+
+ )}
+
+ {/* Request button - only show for movies, TV series use Request All + season cards */}
+ {canRequest && mediaType === MediaType.MOVIE && (
+
+
+
+ {t("jellyseerr.request_button")}
+
+
+ )}
+
+ {/* Request All button for TV series */}
+ {mediaType === MediaType.TV &&
+ seasons.filter((s) => s.seasonNumber !== 0).length > 0 &&
+ hasRequestableSeasons && (
+
+
+
+
+ {t("jellyseerr.request_all")}
+
+
+
+ )}
+
+ {/* Request Seasons button for TV series */}
+ {mediaType === MediaType.TV &&
+ seasons.filter((s) => s.seasonNumber !== 0).length > 0 &&
+ hasRequestableSeasons && (
+
+
+
+
+ {t("jellyseerr.request_seasons")}
+
+
+
+ )}
+
+
+ {/* Approve/Decline for managers */}
+ {canManageRequests && pendingRequest && (
+
+
+
+
+ {t("jellyseerr.requested_by", { user: requestedByName })}
+
+
+
+
+
+
+
+ {t("jellyseerr.approve")}
+
+
+
+
+
+
+ {t("jellyseerr.decline")}
+
+
+
+
+ )}
+
+
+
+ {/* Cast section */}
+ {cast.length > 0 && jellyseerrApi && (
+
+
+ {t("jellyseerr.cast")}
+
+
+ {/* Focus guides for bidirectional navigation - stacked together */}
+ {/* Downward: action buttons → first cast card */}
+ {firstCastCardRef && (
+
+ )}
+ {/* Upward: cast → action buttons */}
+ {playButtonRef && (
+
+ )}
+
+
+ {cast.map((person, index) => (
+
+ jellyseerrApi.imageProxy(path, size || "w185")
+ }
+ onPress={() => handleCastPress(person.id)}
+ refSetter={index === 0 ? setFirstCastCardRef : undefined}
+ />
+ ))}
+
+
+ )}
+
+
+ );
+};
diff --git a/components/jellyseerr/tv/TVRequestModal.tsx b/components/jellyseerr/tv/TVRequestModal.tsx
new file mode 100644
index 000000000..72e99e22c
--- /dev/null
+++ b/components/jellyseerr/tv/TVRequestModal.tsx
@@ -0,0 +1,519 @@
+import { Ionicons } from "@expo/vector-icons";
+import { useQuery } from "@tanstack/react-query";
+import { BlurView } from "expo-blur";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Animated,
+ BackHandler,
+ Easing,
+ ScrollView,
+ TVFocusGuideView,
+ View,
+} from "react-native";
+import { Text } from "@/components/common/Text";
+import { TVButton, TVOptionSelector } from "@/components/tv";
+import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { useJellyseerr } from "@/hooks/useJellyseerr";
+import type {
+ QualityProfile,
+ RootFolder,
+ Tag,
+} from "@/utils/jellyseerr/server/api/servarr/base";
+import type { MediaType } from "@/utils/jellyseerr/server/constants/media";
+import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
+import { TVRequestOptionRow } from "./TVRequestOptionRow";
+import { TVToggleOptionRow } from "./TVToggleOptionRow";
+
+interface TVRequestModalProps {
+ visible: boolean;
+ requestBody?: MediaRequestBody;
+ title: string;
+ id: number;
+ mediaType: MediaType;
+ onClose: () => void;
+ onRequested: () => void;
+}
+
+export const TVRequestModal: React.FC = ({
+ visible,
+ requestBody,
+ title,
+ id,
+ mediaType,
+ onClose,
+ onRequested,
+}) => {
+ const typography = useScaledTVTypography();
+ const { t } = useTranslation();
+ const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
+
+ const [requestOverrides, setRequestOverrides] = useState({
+ mediaId: Number(id),
+ mediaType,
+ userId: jellyseerrUser?.id,
+ });
+
+ const [activeSelector, setActiveSelector] = useState<
+ "profile" | "folder" | "user" | null
+ >(null);
+
+ const overlayOpacity = useRef(new Animated.Value(0)).current;
+ const sheetTranslateY = useRef(new Animated.Value(200)).current;
+
+ useEffect(() => {
+ if (visible) {
+ overlayOpacity.setValue(0);
+ sheetTranslateY.setValue(200);
+
+ Animated.parallel([
+ Animated.timing(overlayOpacity, {
+ toValue: 1,
+ duration: 250,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(sheetTranslateY, {
+ toValue: 0,
+ duration: 300,
+ easing: Easing.out(Easing.cubic),
+ useNativeDriver: true,
+ }),
+ ]).start();
+ }
+ }, [visible, overlayOpacity, sheetTranslateY]);
+
+ // Handle back button to close modal
+ useEffect(() => {
+ if (!visible) return;
+
+ const handleBackPress = () => {
+ // If a sub-selector is open, close it first
+ if (activeSelector) {
+ setActiveSelector(null);
+ } else {
+ onClose();
+ }
+ return true; // Prevent default back behavior
+ };
+
+ const subscription = BackHandler.addEventListener(
+ "hardwareBackPress",
+ handleBackPress,
+ );
+
+ return () => subscription.remove();
+ }, [visible, activeSelector, onClose]);
+
+ const { data: serviceSettings } = useQuery({
+ queryKey: ["jellyseerr", "request", mediaType, "service"],
+ queryFn: async () =>
+ jellyseerrApi?.service(mediaType === "movie" ? "radarr" : "sonarr"),
+ enabled: !!jellyseerrApi && !!jellyseerrUser && visible,
+ });
+
+ const { data: users } = useQuery({
+ queryKey: ["jellyseerr", "users"],
+ queryFn: async () =>
+ jellyseerrApi?.user({ take: 1000, sort: "displayname" }),
+ enabled: !!jellyseerrApi && !!jellyseerrUser && visible,
+ });
+
+ const defaultService = useMemo(
+ () => serviceSettings?.find?.((v) => v.isDefault),
+ [serviceSettings],
+ );
+
+ const { data: defaultServiceDetails } = useQuery({
+ queryKey: [
+ "jellyseerr",
+ "request",
+ mediaType,
+ "service",
+ "details",
+ defaultService?.id,
+ ],
+ queryFn: async () => {
+ setRequestOverrides((prev) => ({
+ ...prev,
+ serverId: defaultService?.id,
+ }));
+ return jellyseerrApi?.serviceDetails(
+ mediaType === "movie" ? "radarr" : "sonarr",
+ defaultService!.id,
+ );
+ },
+ enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService && visible,
+ });
+
+ const defaultProfile: QualityProfile | undefined = useMemo(
+ () =>
+ defaultServiceDetails?.profiles.find(
+ (p) => p.id === defaultServiceDetails.server?.activeProfileId,
+ ),
+ [defaultServiceDetails],
+ );
+
+ const defaultFolder: RootFolder | undefined = useMemo(
+ () =>
+ defaultServiceDetails?.rootFolders.find(
+ (f) => f.path === defaultServiceDetails.server?.activeDirectory,
+ ),
+ [defaultServiceDetails],
+ );
+
+ const defaultTags: Tag[] = useMemo(() => {
+ return (
+ defaultServiceDetails?.tags.filter((t) =>
+ defaultServiceDetails?.server.activeTags?.includes(t.id),
+ ) ?? []
+ );
+ }, [defaultServiceDetails]);
+
+ const pathTitleExtractor = (item: RootFolder) =>
+ `${item.path} (${item.freeSpace.bytesToReadable()})`;
+
+ // Option builders
+ const qualityProfileOptions: TVOptionItem[] = useMemo(
+ () =>
+ defaultServiceDetails?.profiles.map((profile) => ({
+ label: profile.name,
+ value: profile.id,
+ selected:
+ (requestOverrides.profileId || defaultProfile?.id) === profile.id,
+ })) || [],
+ [
+ defaultServiceDetails?.profiles,
+ defaultProfile,
+ requestOverrides.profileId,
+ ],
+ );
+
+ const rootFolderOptions: TVOptionItem[] = useMemo(
+ () =>
+ defaultServiceDetails?.rootFolders.map((folder) => ({
+ label: pathTitleExtractor(folder),
+ value: folder.path,
+ selected:
+ (requestOverrides.rootFolder || defaultFolder?.path) === folder.path,
+ })) || [],
+ [
+ defaultServiceDetails?.rootFolders,
+ defaultFolder,
+ requestOverrides.rootFolder,
+ ],
+ );
+
+ const userOptions: TVOptionItem[] = useMemo(
+ () =>
+ users?.map((user) => ({
+ label: user.displayName,
+ value: user.id,
+ selected: (requestOverrides.userId || jellyseerrUser?.id) === user.id,
+ })) || [],
+ [users, jellyseerrUser, requestOverrides.userId],
+ );
+
+ const tagItems = useMemo(() => {
+ return (
+ defaultServiceDetails?.tags.map((tag) => ({
+ id: tag.id,
+ label: tag.label,
+ selected:
+ requestOverrides.tags?.includes(tag.id) ||
+ defaultTags.some((dt) => dt.id === tag.id),
+ })) ?? []
+ );
+ }, [defaultServiceDetails?.tags, defaultTags, requestOverrides.tags]);
+
+ // Selected display values
+ const selectedProfileName = useMemo(() => {
+ const profile = defaultServiceDetails?.profiles.find(
+ (p) => p.id === (requestOverrides.profileId || defaultProfile?.id),
+ );
+ return profile?.name || defaultProfile?.name || t("jellyseerr.select");
+ }, [
+ defaultServiceDetails?.profiles,
+ requestOverrides.profileId,
+ defaultProfile,
+ t,
+ ]);
+
+ const selectedFolderName = useMemo(() => {
+ const folder = defaultServiceDetails?.rootFolders.find(
+ (f) => f.path === (requestOverrides.rootFolder || defaultFolder?.path),
+ );
+ return folder
+ ? pathTitleExtractor(folder)
+ : defaultFolder
+ ? pathTitleExtractor(defaultFolder)
+ : t("jellyseerr.select");
+ }, [
+ defaultServiceDetails?.rootFolders,
+ requestOverrides.rootFolder,
+ defaultFolder,
+ t,
+ ]);
+
+ const selectedUserName = useMemo(() => {
+ const user = users?.find(
+ (u) => u.id === (requestOverrides.userId || jellyseerrUser?.id),
+ );
+ return (
+ user?.displayName || jellyseerrUser?.displayName || t("jellyseerr.select")
+ );
+ }, [users, requestOverrides.userId, jellyseerrUser, t]);
+
+ // Handlers
+ const handleProfileChange = useCallback((profileId: number) => {
+ setRequestOverrides((prev) => ({ ...prev, profileId }));
+ setActiveSelector(null);
+ }, []);
+
+ const handleFolderChange = useCallback((rootFolder: string) => {
+ setRequestOverrides((prev) => ({ ...prev, rootFolder }));
+ setActiveSelector(null);
+ }, []);
+
+ const handleUserChange = useCallback((userId: number) => {
+ setRequestOverrides((prev) => ({ ...prev, userId }));
+ setActiveSelector(null);
+ }, []);
+
+ const handleTagToggle = useCallback(
+ (tagId: number) => {
+ setRequestOverrides((prev) => {
+ const currentTags = prev.tags || defaultTags.map((t) => t.id);
+ const hasTag = currentTags.includes(tagId);
+ return {
+ ...prev,
+ tags: hasTag
+ ? currentTags.filter((id) => id !== tagId)
+ : [...currentTags, tagId],
+ };
+ });
+ },
+ [defaultTags],
+ );
+
+ const handleRequest = useCallback(() => {
+ const body = {
+ is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
+ profileId: defaultProfile?.id,
+ rootFolder: defaultFolder?.path,
+ tags: defaultTags.map((t) => t.id),
+ ...requestBody,
+ ...requestOverrides,
+ };
+
+ const seasonTitle =
+ requestBody?.seasons?.length === 1
+ ? t("jellyseerr.season_number", {
+ season_number: requestBody.seasons[0],
+ })
+ : requestBody?.seasons && requestBody.seasons.length > 1
+ ? t("jellyseerr.season_all")
+ : undefined;
+
+ requestMedia(
+ seasonTitle ? `${title}, ${seasonTitle}` : title,
+ body,
+ onRequested,
+ );
+ }, [
+ requestBody,
+ requestOverrides,
+ defaultProfile,
+ defaultFolder,
+ defaultTags,
+ defaultService,
+ defaultServiceDetails,
+ title,
+ requestMedia,
+ onRequested,
+ t,
+ ]);
+
+ if (!visible) return null;
+
+ const isDataLoaded = defaultService && defaultServiceDetails && users;
+
+ return (
+ <>
+
+
+
+
+
+ {t("jellyseerr.advanced")}
+
+
+ {title}
+
+
+ {isDataLoaded ? (
+
+
+ setActiveSelector("profile")}
+ hasTVPreferredFocus
+ />
+ setActiveSelector("folder")}
+ />
+ setActiveSelector("user")}
+ />
+
+ {tagItems.length > 0 && (
+
+ )}
+
+
+ ) : (
+
+
+ {t("common.loading")}
+
+
+ )}
+
+
+
+
+
+ {t("jellyseerr.request_button")}
+
+
+
+
+
+
+
+
+ {/* Sub-selectors */}
+ setActiveSelector(null)}
+ cancelLabel={t("jellyseerr.cancel")}
+ />
+ setActiveSelector(null)}
+ cancelLabel={t("jellyseerr.cancel")}
+ cardWidth={280}
+ />
+ setActiveSelector(null)}
+ cancelLabel={t("jellyseerr.cancel")}
+ />
+ >
+ );
+};
diff --git a/components/jellyseerr/tv/TVRequestOptionRow.tsx b/components/jellyseerr/tv/TVRequestOptionRow.tsx
new file mode 100644
index 000000000..f7c25c5ba
--- /dev/null
+++ b/components/jellyseerr/tv/TVRequestOptionRow.tsx
@@ -0,0 +1,86 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+
+interface TVRequestOptionRowProps {
+ label: string;
+ value: string;
+ onPress: () => void;
+ hasTVPreferredFocus?: boolean;
+ disabled?: boolean;
+}
+
+export const TVRequestOptionRow: React.FC = ({
+ label,
+ value,
+ onPress,
+ hasTVPreferredFocus = false,
+ disabled = false,
+}) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({
+ scaleAmount: 1.02,
+ });
+
+ return (
+
+
+
+ {label}
+
+
+
+ {value}
+
+
+
+
+
+ );
+};
diff --git a/components/jellyseerr/tv/TVToggleOptionRow.tsx b/components/jellyseerr/tv/TVToggleOptionRow.tsx
new file mode 100644
index 000000000..296dff6e7
--- /dev/null
+++ b/components/jellyseerr/tv/TVToggleOptionRow.tsx
@@ -0,0 +1,117 @@
+import React from "react";
+import { Animated, Pressable, ScrollView, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+
+interface ToggleItem {
+ id: number;
+ label: string;
+ selected: boolean;
+}
+
+interface TVToggleChipProps {
+ item: ToggleItem;
+ onToggle: (id: number) => void;
+ disabled?: boolean;
+}
+
+const TVToggleChip: React.FC = ({
+ item,
+ onToggle,
+ disabled = false,
+}) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({
+ scaleAmount: 1.08,
+ });
+
+ return (
+ onToggle(item.id)}
+ onFocus={handleFocus}
+ onBlur={handleBlur}
+ disabled={disabled}
+ focusable={!disabled}
+ >
+
+
+ {item.label}
+
+
+
+ );
+};
+
+interface TVToggleOptionRowProps {
+ label: string;
+ items: ToggleItem[];
+ onToggle: (id: number) => void;
+ disabled?: boolean;
+}
+
+export const TVToggleOptionRow: React.FC = ({
+ label,
+ items,
+ onToggle,
+ disabled = false,
+}) => {
+ const typography = useScaledTVTypography();
+ if (items.length === 0) return null;
+
+ return (
+
+
+ {label}
+
+
+ {items.map((item) => (
+
+ ))}
+
+
+ );
+};
diff --git a/components/jellyseerr/tv/index.ts b/components/jellyseerr/tv/index.ts
new file mode 100644
index 000000000..b20700c4b
--- /dev/null
+++ b/components/jellyseerr/tv/index.ts
@@ -0,0 +1,4 @@
+export { TVJellyseerrPage } from "./TVJellyseerrPage";
+export { TVRequestModal } from "./TVRequestModal";
+export { TVRequestOptionRow } from "./TVRequestOptionRow";
+export { TVToggleOptionRow } from "./TVToggleOptionRow";
diff --git a/components/library/Libraries.tsx b/components/library/Libraries.tsx
new file mode 100644
index 000000000..8be2436c9
--- /dev/null
+++ b/components/library/Libraries.tsx
@@ -0,0 +1,109 @@
+import {
+ getUserLibraryApi,
+ getUserViewsApi,
+} from "@jellyfin/sdk/lib/utils/api";
+import { FlashList } from "@shopify/flash-list";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useAtom } from "jotai";
+import { useEffect, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+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";
+import { LibraryItemCard } from "@/components/library/LibraryItemCard";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+
+export const Libraries: React.FC = () => {
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const queryClient = useQueryClient();
+ const { settings } = useSettings();
+
+ 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;
+ },
+ staleTime: 60,
+ });
+
+ const libraries = useMemo(
+ () =>
+ data
+ ?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
+ .filter((l) => l.CollectionType !== "books") || [],
+ [data, settings?.hiddenLibraries],
+ );
+
+ useEffect(() => {
+ for (const item of data || []) {
+ queryClient.prefetchQuery({
+ queryKey: ["library", item.Id],
+ queryFn: async () => {
+ if (!item.Id || !user?.Id || !api) return null;
+ const response = await getUserLibraryApi(api).getItem({
+ itemId: item.Id,
+ userId: user?.Id,
+ });
+ return response.data;
+ },
+ staleTime: 60 * 1000,
+ });
+ }
+ }, [data, api, queryClient, user?.Id]);
+
+ const insets = useSafeAreaInsets();
+
+ if (isLoading)
+ return (
+
+
+
+ );
+
+ if (!libraries)
+ return (
+
+
+ {t("library.no_libraries_found")}
+
+
+ );
+
+ return (
+ }
+ keyExtractor={(item) => item.Id || ""}
+ ItemSeparatorComponent={() =>
+ settings?.libraryOptions?.display === "row" ? (
+
+ ) : (
+
+ )
+ }
+ />
+ );
+};
diff --git a/components/library/LibraryItemCard.tsx b/components/library/LibraryItemCard.tsx
index efb29013d..c84364ce1 100644
--- a/components/library/LibraryItemCard.tsx
+++ b/components/library/LibraryItemCard.tsx
@@ -51,7 +51,7 @@ export const LibraryItemCard: React.FC = ({ library, ...props }) => {
api,
item: library,
}),
- [library],
+ [api, library],
);
const itemType = useMemo(() => {
@@ -63,6 +63,10 @@ export const LibraryItemCard: React.FC = ({ library, ...props }) => {
_itemType = "Series";
} else if (library.CollectionType === "boxsets") {
_itemType = "BoxSet";
+ } else if (library.CollectionType === "homevideos") {
+ _itemType = "Video";
+ } else if (library.CollectionType === "musicvideos") {
+ _itemType = "MusicVideo";
}
return _itemType;
diff --git a/components/library/TVLibraries.tsx b/components/library/TVLibraries.tsx
new file mode 100644
index 000000000..8bcff9da3
--- /dev/null
+++ b/components/library/TVLibraries.tsx
@@ -0,0 +1,406 @@
+import { Ionicons } from "@expo/vector-icons";
+import type {
+ BaseItemDto,
+ CollectionType,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import { getItemsApi, getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import { BlurView } from "expo-blur";
+import { Image } from "expo-image";
+import { LinearGradient } from "expo-linear-gradient";
+import { useAtom } from "jotai";
+import { useCallback, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Animated, Easing, FlatList, Pressable, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import { Loader } from "@/components/Loader";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
+
+const HORIZONTAL_PADDING = 80;
+const CARD_HEIGHT = 220;
+const CARD_GAP = 24;
+const SCALE_PADDING = 20;
+
+type IconName = React.ComponentProps["name"];
+
+const icons: Record = {
+ movies: "film",
+ tvshows: "tv",
+ music: "musical-notes",
+ books: "book",
+ homevideos: "videocam",
+ boxsets: "albums",
+ playlists: "list",
+ folders: "folder",
+ livetv: "tv",
+ musicvideos: "musical-notes",
+ photos: "images",
+ trailers: "videocam",
+ unknown: "help-circle",
+} as const;
+
+interface LibraryWithPreview extends BaseItemDto {
+ previewItems?: BaseItemDto[];
+ itemCount?: number;
+}
+
+const TVLibraryRow: React.FC<{
+ library: LibraryWithPreview;
+ isFirst: boolean;
+ onPress: () => void;
+}> = ({ library, isFirst, onPress }) => {
+ const [api] = useAtom(apiAtom);
+ const { t } = useTranslation();
+ const typography = useScaledTVTypography();
+ const [focused, setFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+ const opacity = useRef(new Animated.Value(0.7)).current;
+
+ const animateTo = (toScale: number, toOpacity: number) => {
+ Animated.parallel([
+ Animated.timing(scale, {
+ toValue: toScale,
+ duration: 200,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(opacity, {
+ toValue: toOpacity,
+ duration: 200,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ ]).start();
+ };
+
+ const backdropUrl = useMemo(() => {
+ // Try to get backdrop from library or first preview item
+ if (library.previewItems?.[0]) {
+ return getBackdropUrl({
+ api,
+ item: library.previewItems[0],
+ width: 1920,
+ });
+ }
+ return getBackdropUrl({
+ api,
+ item: library,
+ width: 1920,
+ });
+ }, [api, library]);
+
+ const iconName = icons[library.CollectionType!] || "folder";
+
+ const itemTypeName = useMemo(() => {
+ if (library.CollectionType === "movies")
+ return t("library.item_types.movies");
+ if (library.CollectionType === "tvshows")
+ return t("library.item_types.series");
+ if (library.CollectionType === "boxsets")
+ return t("library.item_types.boxsets");
+ if (library.CollectionType === "playlists")
+ return t("library.item_types.playlists");
+ if (library.CollectionType === "music")
+ return t("library.item_types.items");
+ return t("library.item_types.items");
+ }, [library.CollectionType, t]);
+
+ return (
+ {
+ setFocused(true);
+ animateTo(1.02, 1);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1, 0.7);
+ }}
+ hasTVPreferredFocus={isFirst}
+ >
+
+ {/* Background Image */}
+ {backdropUrl && (
+
+ )}
+
+ {/* Gradient Overlay */}
+
+
+ {/* Content */}
+
+ {/* Icon Container */}
+
+
+
+
+ {/* Text Content */}
+
+
+ {library.Name}
+
+ {library.itemCount !== undefined && (
+
+ {library.itemCount} {itemTypeName}
+
+ )}
+
+
+ {/* Arrow Indicator */}
+
+
+
+
+
+
+ );
+};
+
+export const TVLibraries: React.FC = () => {
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const { settings } = useSettings();
+ const insets = useSafeAreaInsets();
+ const router = useRouter();
+ const { t } = useTranslation();
+ const typography = useScaledTVTypography();
+
+ const { data: userViews, isLoading: viewsLoading } = useQuery({
+ queryKey: ["user-views", user?.Id],
+ queryFn: async () => {
+ const response = await getUserViewsApi(api!).getUserViews({
+ userId: user?.Id,
+ });
+ return response.data.Items || [];
+ },
+ staleTime: 60 * 1000,
+ enabled: !!api && !!user?.Id,
+ });
+
+ const libraries = useMemo(
+ () =>
+ userViews
+ ?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
+ .filter((l) => l.CollectionType !== "books")
+ .filter((l) => l.CollectionType !== "music") || [],
+ [userViews, settings?.hiddenLibraries],
+ );
+
+ // Fetch item counts and preview items for each library
+ const { data: librariesWithData, isLoading: dataLoading } = useQuery({
+ queryKey: ["library-data", libraries.map((l) => l.Id).join(",")],
+ queryFn: async () => {
+ const results: LibraryWithPreview[] = await Promise.all(
+ libraries.map(async (library) => {
+ let itemType: string | undefined;
+ if (library.CollectionType === "movies") itemType = "Movie";
+ else if (library.CollectionType === "tvshows") itemType = "Series";
+ else if (library.CollectionType === "boxsets") itemType = "BoxSet";
+ else if (library.CollectionType === "playlists")
+ itemType = "Playlist";
+
+ const isPlaylistsLib = library.CollectionType === "playlists";
+
+ // Fetch count
+ const countResponse = await getItemsApi(api!).getItems({
+ userId: user?.Id,
+ parentId: library.Id,
+ recursive: true,
+ limit: 0,
+ includeItemTypes: itemType ? [itemType as any] : undefined,
+ ...(isPlaylistsLib ? { mediaTypes: ["Video"] } : {}),
+ });
+
+ // Fetch preview items with backdrops
+ const previewResponse = await getItemsApi(api!).getItems({
+ userId: user?.Id,
+ parentId: library.Id,
+ recursive: true,
+ limit: 1,
+ sortBy: ["Random"],
+ includeItemTypes: itemType ? [itemType as any] : undefined,
+ imageTypes: ["Backdrop"],
+ ...(isPlaylistsLib ? { mediaTypes: ["Video"] } : {}),
+ });
+
+ return {
+ ...library,
+ itemCount: countResponse.data.TotalRecordCount,
+ previewItems: previewResponse.data.Items || [],
+ };
+ }),
+ );
+ return results;
+ },
+ enabled: !!api && !!user?.Id && libraries.length > 0,
+ staleTime: 60 * 1000,
+ });
+
+ const handleLibraryPress = useCallback(
+ (library: BaseItemDto) => {
+ if (library.CollectionType === "livetv") {
+ router.push("/(auth)/(tabs)/(libraries)/livetv/programs");
+ return;
+ }
+ if (library.CollectionType === "music") {
+ router.push({
+ pathname: `/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions`,
+ params: { libraryId: library.Id! },
+ });
+ } else {
+ router.push({
+ pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
+ params: { libraryId: library.Id! },
+ });
+ }
+ },
+ [router],
+ );
+
+ const renderItem = useCallback(
+ ({ item, index }: { item: LibraryWithPreview; index: number }) => (
+
+ handleLibraryPress(item)}
+ />
+
+ ),
+ [handleLibraryPress],
+ );
+
+ const isLoading = viewsLoading || dataLoading;
+ const displayLibraries = librariesWithData || libraries;
+
+ if (isLoading && libraries.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ if (!displayLibraries || displayLibraries.length === 0) {
+ return (
+
+
+ {t("library.no_libraries_found")}
+
+
+ );
+ }
+
+ return (
+
+ item.Id || ""}
+ renderItem={renderItem}
+ showsVerticalScrollIndicator={false}
+ removeClippedSubviews={false}
+ contentContainerStyle={{
+ paddingBottom: 40,
+ paddingHorizontal: insets.left + HORIZONTAL_PADDING,
+ paddingVertical: SCALE_PADDING,
+ }}
+ />
+
+ );
+};
diff --git a/components/library/TVLibraryCard.tsx b/components/library/TVLibraryCard.tsx
new file mode 100644
index 000000000..ef607b743
--- /dev/null
+++ b/components/library/TVLibraryCard.tsx
@@ -0,0 +1,176 @@
+import { Ionicons } from "@expo/vector-icons";
+import type {
+ BaseItemDto,
+ BaseItemKind,
+ CollectionType,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import { Image } from "expo-image";
+import { useAtom } from "jotai";
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+
+export const TV_LIBRARY_CARD_WIDTH = 280;
+export const TV_LIBRARY_CARD_HEIGHT = 180;
+
+interface Props {
+ library: BaseItemDto;
+}
+
+type IconName = React.ComponentProps["name"];
+
+const icons: Record = {
+ movies: "film",
+ tvshows: "tv",
+ music: "musical-notes",
+ books: "book",
+ homevideos: "videocam",
+ boxsets: "albums",
+ playlists: "list",
+ folders: "folder",
+ livetv: "tv",
+ musicvideos: "musical-notes",
+ photos: "images",
+ trailers: "videocam",
+ unknown: "help-circle",
+} as const;
+
+export const TVLibraryCard: React.FC = ({ library }) => {
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const { t } = useTranslation();
+ const typography = useScaledTVTypography();
+
+ const url = useMemo(
+ () =>
+ getPrimaryImageUrl({
+ api,
+ item: library,
+ }),
+ [api, library],
+ );
+
+ const itemType = useMemo(() => {
+ let _itemType: BaseItemKind | undefined;
+
+ if (library.CollectionType === "movies") {
+ _itemType = "Movie";
+ } else if (library.CollectionType === "tvshows") {
+ _itemType = "Series";
+ } else if (library.CollectionType === "boxsets") {
+ _itemType = "BoxSet";
+ } else if (library.CollectionType === "homevideos") {
+ _itemType = "Video";
+ } else if (library.CollectionType === "musicvideos") {
+ _itemType = "MusicVideo";
+ }
+
+ return _itemType;
+ }, [library.CollectionType]);
+
+ const itemTypeName = useMemo(() => {
+ let nameStr: string;
+
+ if (library.CollectionType === "movies") {
+ nameStr = t("library.item_types.movies");
+ } else if (library.CollectionType === "tvshows") {
+ nameStr = t("library.item_types.series");
+ } else if (library.CollectionType === "boxsets") {
+ nameStr = t("library.item_types.boxsets");
+ } else {
+ nameStr = t("library.item_types.items");
+ }
+
+ return nameStr;
+ }, [library.CollectionType, t]);
+
+ const { data: itemsCount } = useQuery({
+ queryKey: ["library-count", library.Id],
+ queryFn: async () => {
+ const response = await getItemsApi(api!).getItems({
+ userId: user?.Id,
+ parentId: library.Id,
+ recursive: true,
+ limit: 0,
+ includeItemTypes: itemType ? [itemType] : undefined,
+ });
+ return response.data.TotalRecordCount;
+ },
+ enabled: !!api && !!user?.Id && !!library.Id,
+ });
+
+ const iconName = icons[library.CollectionType!] || "folder";
+
+ return (
+
+ {url && (
+
+ )}
+
+
+
+
+ {library.Name}
+
+ {itemsCount !== undefined && (
+
+ {itemsCount} {itemTypeName}
+
+ )}
+
+
+ );
+};
diff --git a/components/list/ListItem.tsx b/components/list/ListItem.tsx
index 7ce339868..fed62315c 100644
--- a/components/list/ListItem.tsx
+++ b/components/list/ListItem.tsx
@@ -6,6 +6,7 @@ import { Text } from "../common/Text";
interface Props extends ViewProps {
title?: string | null | undefined;
subtitle?: string | null | undefined;
+ subtitleColor?: "default" | "red";
value?: string | null | undefined;
children?: ReactNode;
iconAfter?: ReactNode;
@@ -14,6 +15,7 @@ interface Props extends ViewProps {
textColor?: "default" | "blue" | "red";
onPress?: () => void;
disabled?: boolean;
+ disabledByAdmin?: boolean;
}
export const ListItem: React.FC> = ({
@@ -27,21 +29,23 @@ export const ListItem: React.FC> = ({
textColor = "default",
onPress,
disabled = false,
+ disabledByAdmin = false,
...viewProps
}) => {
+ const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
+ const isDisabled = disabled || disabledByAdmin;
if (onPress)
return (
> = ({
);
return (
> = ({
const ListItemContent = ({
title,
subtitle,
+ subtitleColor,
textColor,
icon,
value,
@@ -107,7 +111,7 @@ const ListItemContent = ({
{subtitle && (
{subtitle}
diff --git a/components/livetv/TVChannelCard.tsx b/components/livetv/TVChannelCard.tsx
new file mode 100644
index 000000000..95f337db5
--- /dev/null
+++ b/components/livetv/TVChannelCard.tsx
@@ -0,0 +1,183 @@
+import type { Api } from "@jellyfin/sdk";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import React from "react";
+import { Animated, Image, Pressable, StyleSheet, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+
+interface TVChannelCardProps {
+ channel: BaseItemDto;
+ api: Api | null;
+ onPress: () => void;
+ hasTVPreferredFocus?: boolean;
+ disabled?: boolean;
+}
+
+const CARD_WIDTH = 200;
+const CARD_HEIGHT = 160;
+
+export const TVChannelCard: React.FC = ({
+ channel,
+ api,
+ onPress,
+ hasTVPreferredFocus = false,
+ disabled = false,
+}) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({
+ scaleAmount: 1.05,
+ duration: 120,
+ });
+
+ const imageUrl = getPrimaryImageUrl({
+ api,
+ item: channel,
+ quality: 80,
+ width: 200,
+ });
+
+ return (
+
+
+ {/* Channel logo or number */}
+
+ {imageUrl ? (
+
+ ) : (
+
+
+ {channel.ChannelNumber || "?"}
+
+
+ )}
+
+
+ {/* Channel name */}
+
+ {channel.Name}
+
+
+ {/* Channel number (if name is shown) */}
+ {channel.ChannelNumber && (
+
+ Ch. {channel.ChannelNumber}
+
+ )}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ pressable: {
+ width: CARD_WIDTH,
+ height: CARD_HEIGHT,
+ },
+ container: {
+ flex: 1,
+ borderRadius: 12,
+ borderWidth: 1,
+ padding: 12,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ focusedShadow: {
+ shadowColor: "#FFFFFF",
+ shadowOffset: { width: 0, height: 0 },
+ shadowOpacity: 0.4,
+ shadowRadius: 12,
+ },
+ logoContainer: {
+ width: 80,
+ height: 60,
+ marginBottom: 8,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ logo: {
+ width: "100%",
+ height: "100%",
+ },
+ numberFallback: {
+ width: 60,
+ height: 60,
+ borderRadius: 30,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ numberText: {
+ fontWeight: "bold",
+ },
+ channelName: {
+ fontWeight: "600",
+ textAlign: "center",
+ marginBottom: 4,
+ },
+ channelNumber: {
+ fontWeight: "400",
+ },
+});
+
+export { CARD_HEIGHT, CARD_WIDTH };
diff --git a/components/livetv/TVChannelsGrid.tsx b/components/livetv/TVChannelsGrid.tsx
new file mode 100644
index 000000000..f93beb35c
--- /dev/null
+++ b/components/livetv/TVChannelsGrid.tsx
@@ -0,0 +1,136 @@
+import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import { useAtomValue } from "jotai";
+import React, { useCallback } from "react";
+import { useTranslation } from "react-i18next";
+import { ActivityIndicator, ScrollView, StyleSheet, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { TVChannelCard } from "./TVChannelCard";
+
+const HORIZONTAL_PADDING = 60;
+const GRID_GAP = 16;
+
+export const TVChannelsGrid: React.FC = () => {
+ const { t } = useTranslation();
+ const typography = useScaledTVTypography();
+ const insets = useSafeAreaInsets();
+ const router = useRouter();
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
+
+ // Fetch all channels
+ const { data: channelsData, isLoading } = useQuery({
+ queryKey: ["livetv", "channels-grid", "all"],
+ queryFn: async () => {
+ if (!api || !user?.Id) return null;
+ const res = await getLiveTvApi(api).getLiveTvChannels({
+ enableFavoriteSorting: true,
+ userId: user.Id,
+ addCurrentProgram: false,
+ enableUserData: false,
+ enableImageTypes: ["Primary"],
+ });
+ return res.data;
+ },
+ enabled: !!api && !!user?.Id,
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ });
+
+ const channels = channelsData?.Items ?? [];
+
+ const handleChannelPress = useCallback(
+ (channelId: string | undefined) => {
+ if (channelId) {
+ // Navigate directly to the player to start the channel
+ const queryParams = new URLSearchParams({
+ itemId: channelId,
+ audioIndex: "",
+ subtitleIndex: "",
+ mediaSourceId: "",
+ bitrateValue: "",
+ });
+ router.push(`/player/direct-player?${queryParams.toString()}`);
+ }
+ },
+ [router],
+ );
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (channels.length === 0) {
+ return (
+
+
+ {t("live_tv.no_channels")}
+
+
+ );
+ }
+
+ return (
+
+
+ {channels.map((channel, index) => (
+ handleChannelPress(channel.Id)}
+ // No hasTVPreferredFocus - tab buttons handle initial focus
+ />
+ ))}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ contentContainer: {
+ paddingTop: 24,
+ },
+ grid: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ justifyContent: "flex-start",
+ gap: GRID_GAP,
+ overflow: "visible",
+ paddingVertical: 10, // Extra padding for focus scale animation
+ },
+ loadingContainer: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ emptyContainer: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ emptyText: {
+ color: "rgba(255, 255, 255, 0.6)",
+ },
+});
diff --git a/components/livetv/TVGuideChannelRow.tsx b/components/livetv/TVGuideChannelRow.tsx
new file mode 100644
index 000000000..73ba74915
--- /dev/null
+++ b/components/livetv/TVGuideChannelRow.tsx
@@ -0,0 +1,146 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import React, { useMemo } from "react";
+import { StyleSheet, View } from "react-native";
+import { TVGuideProgramCell } from "./TVGuideProgramCell";
+
+interface TVGuideChannelRowProps {
+ programs: BaseItemDto[];
+ baseTime: Date;
+ pixelsPerHour: number;
+ minProgramWidth: number;
+ hoursToShow: number;
+ onProgramPress: (program: BaseItemDto) => void;
+ disabled?: boolean;
+ firstProgramRefSetter?: (ref: View | null) => void;
+}
+
+export const TVGuideChannelRow: React.FC = ({
+ programs,
+ baseTime,
+ pixelsPerHour,
+ minProgramWidth,
+ hoursToShow,
+ onProgramPress,
+ disabled = false,
+ firstProgramRefSetter,
+}) => {
+ const isCurrentlyAiring = (program: BaseItemDto): boolean => {
+ if (!program.StartDate || !program.EndDate) return false;
+ const now = new Date();
+ const start = new Date(program.StartDate);
+ const end = new Date(program.EndDate);
+ return now >= start && now <= end;
+ };
+
+ const getTimeOffset = (startDate: string): number => {
+ const start = new Date(startDate);
+ const diffMinutes = (start.getTime() - baseTime.getTime()) / 60000;
+ return Math.max(0, (diffMinutes / 60) * pixelsPerHour);
+ };
+
+ // Filter programs for this channel and within the time window
+ const filteredPrograms = useMemo(() => {
+ const endTime = new Date(baseTime.getTime() + hoursToShow * 60 * 60 * 1000);
+
+ return programs
+ .filter((p) => {
+ if (!p.StartDate || !p.EndDate) return false;
+ const start = new Date(p.StartDate);
+ const end = new Date(p.EndDate);
+ // Program overlaps with our time window
+ return end > baseTime && start < endTime;
+ })
+ .sort((a, b) => {
+ const dateA = new Date(a.StartDate || 0);
+ const dateB = new Date(b.StartDate || 0);
+ return dateA.getTime() - dateB.getTime();
+ });
+ }, [programs, baseTime, hoursToShow]);
+
+ // Calculate program cells with positions (absolute positioning)
+ const programCells = useMemo(() => {
+ return filteredPrograms.map((program) => {
+ if (!program.StartDate || !program.EndDate) {
+ return { program, width: minProgramWidth, left: 0 };
+ }
+
+ // Clamp the start time to baseTime if program started earlier
+ const programStart = new Date(program.StartDate);
+ const effectiveStart = programStart < baseTime ? baseTime : programStart;
+
+ // Clamp the end time to the window end
+ const windowEnd = new Date(
+ baseTime.getTime() + hoursToShow * 60 * 60 * 1000,
+ );
+ const programEnd = new Date(program.EndDate);
+ const effectiveEnd = programEnd > windowEnd ? windowEnd : programEnd;
+
+ const durationMinutes =
+ (effectiveEnd.getTime() - effectiveStart.getTime()) / 60000;
+ const width = Math.max(
+ (durationMinutes / 60) * pixelsPerHour - 4,
+ minProgramWidth,
+ ); // -4 for gap
+
+ const left = getTimeOffset(effectiveStart.toISOString());
+
+ return {
+ program,
+ width,
+ left,
+ };
+ });
+ }, [filteredPrograms, baseTime, pixelsPerHour, minProgramWidth, hoursToShow]);
+
+ const totalWidth = hoursToShow * pixelsPerHour;
+
+ return (
+
+ {programCells.map(({ program, width, left }, index) => (
+
+ onProgramPress(program)}
+ disabled={disabled}
+ refSetter={index === 0 ? firstProgramRefSetter : undefined}
+ />
+
+ ))}
+
+ {/* Empty state */}
+ {programCells.length === 0 && (
+
+ {/* Empty row indicator */}
+
+ )}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ height: 80,
+ position: "relative",
+ borderBottomWidth: 1,
+ borderBottomColor: "rgba(255, 255, 255, 0.2)",
+ backgroundColor: "rgba(20, 20, 20, 1)",
+ },
+ programCellWrapper: {
+ position: "absolute",
+ top: 4,
+ bottom: 4,
+ },
+ noPrograms: {
+ position: "absolute",
+ left: 4,
+ top: 4,
+ bottom: 4,
+ backgroundColor: "rgba(255, 255, 255, 0.05)",
+ borderRadius: 8,
+ },
+});
diff --git a/components/livetv/TVGuidePageNavigation.tsx b/components/livetv/TVGuidePageNavigation.tsx
new file mode 100644
index 000000000..5188c54ea
--- /dev/null
+++ b/components/livetv/TVGuidePageNavigation.tsx
@@ -0,0 +1,154 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { Animated, Pressable, StyleSheet, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+
+interface TVGuidePageNavigationProps {
+ currentPage: number;
+ totalPages: number;
+ onPrevious: () => void;
+ onNext: () => void;
+ disabled?: boolean;
+ prevButtonRefSetter?: (ref: View | null) => void;
+}
+
+interface NavButtonProps {
+ onPress: () => void;
+ icon: keyof typeof Ionicons.glyphMap;
+ label: string;
+ isDisabled: boolean;
+ disabled?: boolean;
+ refSetter?: (ref: View | null) => void;
+}
+
+const NavButton: React.FC = ({
+ onPress,
+ icon,
+ label,
+ isDisabled,
+ disabled = false,
+ refSetter,
+}) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({
+ scaleAmount: 1.05,
+ duration: 120,
+ });
+
+ const visuallyDisabled = isDisabled || disabled;
+
+ const handlePress = () => {
+ if (!visuallyDisabled) {
+ onPress();
+ }
+ };
+
+ return (
+
+
+
+
+ {label}
+
+
+
+ );
+};
+
+export const TVGuidePageNavigation: React.FC = ({
+ currentPage,
+ totalPages,
+ onPrevious,
+ onNext,
+ disabled = false,
+ prevButtonRefSetter,
+}) => {
+ const { t } = useTranslation();
+ const typography = useScaledTVTypography();
+
+ return (
+
+
+
+
+ = totalPages}
+ disabled={disabled}
+ />
+
+
+
+ {t("live_tv.page_of", { current: currentPage, total: totalPages })}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ paddingVertical: 16,
+ },
+ buttonsContainer: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 12,
+ },
+ navButton: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 8,
+ paddingHorizontal: 20,
+ paddingVertical: 12,
+ borderRadius: 8,
+ },
+ navButtonText: {
+ fontWeight: "600",
+ },
+ pageText: {
+ color: "rgba(255, 255, 255, 0.6)",
+ },
+});
diff --git a/components/livetv/TVGuideProgramCell.tsx b/components/livetv/TVGuideProgramCell.tsx
new file mode 100644
index 000000000..e8287132e
--- /dev/null
+++ b/components/livetv/TVGuideProgramCell.tsx
@@ -0,0 +1,148 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import React from "react";
+import { Animated, Pressable, StyleSheet, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+
+interface TVGuideProgramCellProps {
+ program: BaseItemDto;
+ width: number;
+ isCurrentlyAiring: boolean;
+ onPress: () => void;
+ disabled?: boolean;
+ refSetter?: (ref: View | null) => void;
+}
+
+export const TVGuideProgramCell: React.FC = ({
+ program,
+ width,
+ isCurrentlyAiring,
+ onPress,
+ disabled = false,
+ refSetter,
+}) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
+ scaleAmount: 1,
+ duration: 120,
+ });
+
+ const formatTime = (date: string | null | undefined) => {
+ if (!date) return "";
+ const d = new Date(date);
+ return d.toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ });
+ };
+
+ return (
+
+
+ {/* LIVE badge */}
+ {isCurrentlyAiring && (
+
+
+ LIVE
+
+
+ )}
+
+ {/* Program name */}
+
+ {program.Name}
+
+
+ {/* Time range */}
+
+ {formatTime(program.StartDate)} - {formatTime(program.EndDate)}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ height: 70,
+ borderRadius: 8,
+ borderWidth: 1,
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ justifyContent: "center",
+ overflow: "hidden",
+ },
+ focusedShadow: {
+ shadowColor: "#FFFFFF",
+ shadowOffset: { width: 0, height: 0 },
+ shadowOpacity: 0.4,
+ shadowRadius: 12,
+ },
+ liveBadge: {
+ position: "absolute",
+ top: 6,
+ right: 6,
+ backgroundColor: "#EF4444",
+ paddingHorizontal: 6,
+ paddingVertical: 2,
+ borderRadius: 4,
+ zIndex: 10,
+ elevation: 10,
+ },
+ liveBadgeText: {
+ color: "#FFFFFF",
+ fontWeight: "bold",
+ },
+ programName: {
+ fontWeight: "600",
+ marginBottom: 4,
+ },
+ timeText: {
+ fontWeight: "400",
+ },
+});
diff --git a/components/livetv/TVGuideTimeHeader.tsx b/components/livetv/TVGuideTimeHeader.tsx
new file mode 100644
index 000000000..a3ca8348a
--- /dev/null
+++ b/components/livetv/TVGuideTimeHeader.tsx
@@ -0,0 +1,64 @@
+import { BlurView } from "expo-blur";
+import React from "react";
+import { StyleSheet, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+
+interface TVGuideTimeHeaderProps {
+ baseTime: Date;
+ hoursToShow: number;
+ pixelsPerHour: number;
+}
+
+export const TVGuideTimeHeader: React.FC = ({
+ baseTime,
+ hoursToShow,
+ pixelsPerHour,
+}) => {
+ const typography = useScaledTVTypography();
+
+ const hours: Date[] = [];
+ for (let i = 0; i < hoursToShow; i++) {
+ const hour = new Date(baseTime);
+ hour.setMinutes(0, 0, 0);
+ hour.setHours(baseTime.getHours() + i);
+ hours.push(hour);
+ }
+
+ const formatHour = (date: Date) => {
+ return date.toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ });
+ };
+
+ return (
+
+ {hours.map((hour, index) => (
+
+
+ {formatHour(hour)}
+
+
+ ))}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: "row",
+ height: 44,
+ },
+ hourCell: {
+ justifyContent: "center",
+ paddingLeft: 12,
+ borderLeftWidth: 1,
+ borderLeftColor: "rgba(255, 255, 255, 0.1)",
+ },
+ hourText: {
+ color: "rgba(255, 255, 255, 0.6)",
+ fontWeight: "500",
+ },
+});
diff --git a/components/livetv/TVLiveTVGuide.tsx b/components/livetv/TVLiveTVGuide.tsx
new file mode 100644
index 000000000..ad99626f6
--- /dev/null
+++ b/components/livetv/TVLiveTVGuide.tsx
@@ -0,0 +1,433 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import { useAtomValue } from "jotai";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { useTranslation } from "react-i18next";
+import {
+ ActivityIndicator,
+ NativeScrollEvent,
+ NativeSyntheticEvent,
+ ScrollView,
+ StyleSheet,
+ TVFocusGuideView,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { TVGuideChannelRow } from "./TVGuideChannelRow";
+import { TVGuidePageNavigation } from "./TVGuidePageNavigation";
+import { TVGuideTimeHeader } from "./TVGuideTimeHeader";
+
+// Design constants
+const CHANNEL_COLUMN_WIDTH = 240;
+const PIXELS_PER_HOUR = 250;
+const ROW_HEIGHT = 80;
+const TIME_HEADER_HEIGHT = 44;
+const CHANNELS_PER_PAGE = 20;
+const MIN_PROGRAM_WIDTH = 80;
+const HORIZONTAL_PADDING = 60;
+
+// Channel label component
+const ChannelLabel: React.FC<{
+ channel: BaseItemDto;
+ typography: ReturnType;
+}> = ({ channel, typography }) => (
+
+
+ {channel.ChannelNumber}
+
+
+ {channel.Name}
+
+
+);
+
+export const TVLiveTVGuide: React.FC = () => {
+ const { t } = useTranslation();
+ const typography = useScaledTVTypography();
+ const insets = useSafeAreaInsets();
+ const router = useRouter();
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
+
+ const [currentPage, setCurrentPage] = useState(1);
+
+ // Scroll refs for synchronization
+ const channelListRef = useRef(null);
+ const mainVerticalRef = useRef(null);
+
+ // Focus guide refs for bidirectional navigation
+ const [firstProgramRef, setFirstProgramRef] = useState(null);
+ const [prevButtonRef, setPrevButtonRef] = useState(null);
+
+ // Base time - start of current hour, end time - end of day
+ const [{ baseTime, endOfDay, hoursToShow }] = useState(() => {
+ const now = new Date();
+ now.setMinutes(0, 0, 0);
+
+ const endOfDayTime = new Date(now);
+ endOfDayTime.setHours(23, 59, 59, 999);
+
+ const hoursUntilEndOfDay = Math.ceil(
+ (endOfDayTime.getTime() - now.getTime()) / (60 * 60 * 1000),
+ );
+
+ return {
+ baseTime: now,
+ endOfDay: endOfDayTime,
+ hoursToShow: Math.max(hoursUntilEndOfDay, 1), // At least 1 hour
+ };
+ });
+
+ // Current time indicator position (relative to program grid start)
+ const [currentTimeOffset, setCurrentTimeOffset] = useState(0);
+
+ // Update current time indicator every minute
+ useEffect(() => {
+ const updateCurrentTime = () => {
+ const now = new Date();
+ const diffMinutes = (now.getTime() - baseTime.getTime()) / 60000;
+ const offset = (diffMinutes / 60) * PIXELS_PER_HOUR;
+ setCurrentTimeOffset(offset);
+ };
+
+ updateCurrentTime();
+ const interval = setInterval(updateCurrentTime, 60000);
+ return () => clearInterval(interval);
+ }, [baseTime]);
+
+ // Sync vertical scroll between channel list and main grid
+ const handleVerticalScroll = useCallback(
+ (event: NativeSyntheticEvent) => {
+ const offsetY = event.nativeEvent.contentOffset.y;
+ channelListRef.current?.scrollTo({ y: offsetY, animated: false });
+ },
+ [],
+ );
+
+ // Fetch channels
+ const { data: channelsData, isLoading: isLoadingChannels } = useQuery({
+ queryKey: ["livetv", "tv-guide", "channels"],
+ queryFn: async () => {
+ if (!api || !user?.Id) return null;
+ const res = await getLiveTvApi(api).getLiveTvChannels({
+ enableFavoriteSorting: true,
+ userId: user.Id,
+ addCurrentProgram: false,
+ enableUserData: false,
+ enableImageTypes: ["Primary"],
+ });
+ return res.data;
+ },
+ enabled: !!api && !!user?.Id,
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ });
+
+ const totalChannels = channelsData?.TotalRecordCount ?? 0;
+ const totalPages = Math.ceil(totalChannels / CHANNELS_PER_PAGE);
+ const allChannels = channelsData?.Items ?? [];
+
+ // Get channels for current page
+ const paginatedChannels = useMemo(() => {
+ const startIndex = (currentPage - 1) * CHANNELS_PER_PAGE;
+ return allChannels.slice(startIndex, startIndex + CHANNELS_PER_PAGE);
+ }, [allChannels, currentPage]);
+
+ const channelIds = useMemo(
+ () => paginatedChannels.map((c) => c.Id).filter(Boolean) as string[],
+ [paginatedChannels],
+ );
+
+ // Fetch programs for visible channels
+ const { data: programsData } = useQuery({
+ queryKey: [
+ "livetv",
+ "tv-guide",
+ "programs",
+ channelIds,
+ baseTime.toISOString(),
+ endOfDay.toISOString(),
+ ],
+ queryFn: async () => {
+ if (!api || channelIds.length === 0) return null;
+ const res = await getLiveTvApi(api).getPrograms({
+ getProgramsDto: {
+ MaxStartDate: endOfDay.toISOString(),
+ MinEndDate: baseTime.toISOString(),
+ ChannelIds: channelIds,
+ ImageTypeLimit: 1,
+ EnableImages: false,
+ SortBy: ["StartDate"],
+ EnableTotalRecordCount: false,
+ EnableUserData: false,
+ },
+ });
+ return res.data;
+ },
+ enabled: channelIds.length > 0,
+ staleTime: 2 * 60 * 1000, // 2 minutes
+ });
+
+ const programs = programsData?.Items ?? [];
+
+ // Group programs by channel
+ const programsByChannel = useMemo(() => {
+ const grouped: Record = {};
+ for (const program of programs) {
+ const channelId = program.ChannelId;
+ if (channelId) {
+ if (!grouped[channelId]) {
+ grouped[channelId] = [];
+ }
+ grouped[channelId].push(program);
+ }
+ }
+ return grouped;
+ }, [programs]);
+
+ const handleProgramPress = useCallback(
+ (program: BaseItemDto) => {
+ // Navigate to play the program/channel
+ const queryParams = new URLSearchParams({
+ itemId: program.Id ?? "",
+ audioIndex: "",
+ subtitleIndex: "",
+ mediaSourceId: "",
+ bitrateValue: "",
+ });
+ router.push(`/player/direct-player?${queryParams.toString()}`);
+ },
+ [router],
+ );
+
+ const handlePreviousPage = useCallback(() => {
+ if (currentPage > 1) {
+ setCurrentPage((p) => p - 1);
+ }
+ }, [currentPage]);
+
+ const handleNextPage = useCallback(() => {
+ if (currentPage < totalPages) {
+ setCurrentPage((p) => p + 1);
+ }
+ }, [currentPage, totalPages]);
+
+ const isLoading = isLoadingChannels;
+ const totalWidth = hoursToShow * PIXELS_PER_HOUR;
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (paginatedChannels.length === 0) {
+ return (
+
+
+ {t("live_tv.no_programs")}
+
+
+ );
+ }
+
+ return (
+
+ {/* Page Navigation */}
+ {totalPages > 1 && (
+
+
+
+ )}
+
+ {/* Bidirectional focus guides */}
+ {firstProgramRef && (
+
+ )}
+ {prevButtonRef && (
+
+ )}
+
+ {/* Main grid container */}
+
+ {/* Fixed channel column */}
+
+ {/* Spacer for time header */}
+
+
+ {/* Channel labels - synced with main scroll */}
+
+ {paginatedChannels.map((channel, index) => (
+
+ ))}
+
+
+
+ {/* Scrollable programs area */}
+
+
+ {/* Time header */}
+
+
+ {/* Programs grid - vertical scroll */}
+
+ {paginatedChannels.map((channel, index) => {
+ const channelPrograms = channel.Id
+ ? (programsByChannel[channel.Id] ?? [])
+ : [];
+ return (
+
+ );
+ })}
+
+
+ {/* Current time indicator */}
+ {currentTimeOffset > 0 && currentTimeOffset < totalWidth && (
+
+ )}
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ loadingContainer: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ emptyContainer: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ emptyText: {
+ color: "rgba(255, 255, 255, 0.6)",
+ },
+ gridWrapper: {
+ flex: 1,
+ flexDirection: "row",
+ },
+ channelColumn: {
+ backgroundColor: "rgba(40, 40, 40, 1)",
+ borderRightWidth: 1,
+ borderRightColor: "rgba(255, 255, 255, 0.2)",
+ },
+ channelLabel: {
+ height: ROW_HEIGHT,
+ justifyContent: "center",
+ paddingHorizontal: 12,
+ borderBottomWidth: 1,
+ borderBottomColor: "rgba(255, 255, 255, 0.2)",
+ },
+ channelNumber: {
+ color: "rgba(255, 255, 255, 0.5)",
+ fontWeight: "400",
+ marginBottom: 2,
+ },
+ channelName: {
+ color: "#FFFFFF",
+ fontWeight: "600",
+ },
+ horizontalScroll: {
+ flex: 1,
+ },
+ currentTimeIndicator: {
+ position: "absolute",
+ width: 2,
+ backgroundColor: "#EF4444",
+ zIndex: 10,
+ pointerEvents: "none",
+ },
+});
diff --git a/components/livetv/TVLiveTVPage.tsx b/components/livetv/TVLiveTVPage.tsx
new file mode 100644
index 000000000..a3f3ed45d
--- /dev/null
+++ b/components/livetv/TVLiveTVPage.tsx
@@ -0,0 +1,265 @@
+import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
+import { useAtomValue } from "jotai";
+import React, { useCallback, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { ScrollView, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
+import { TVChannelsGrid } from "@/components/livetv/TVChannelsGrid";
+import { TVLiveTVGuide } from "@/components/livetv/TVLiveTVGuide";
+import { TVLiveTVPlaceholder } from "@/components/livetv/TVLiveTVPlaceholder";
+import { TVTabButton } from "@/components/tv/TVTabButton";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+
+const HORIZONTAL_PADDING = 60;
+const TOP_PADDING = 100;
+const SECTION_GAP = 24;
+
+type TabId =
+ | "programs"
+ | "guide"
+ | "channels"
+ | "recordings"
+ | "schedule"
+ | "series";
+
+interface Tab {
+ id: TabId;
+ labelKey: string;
+}
+
+const TABS: Tab[] = [
+ { id: "programs", labelKey: "live_tv.tabs.programs" },
+ { id: "guide", labelKey: "live_tv.tabs.guide" },
+ { id: "channels", labelKey: "live_tv.tabs.channels" },
+ { id: "recordings", labelKey: "live_tv.tabs.recordings" },
+ { id: "schedule", labelKey: "live_tv.tabs.schedule" },
+ { id: "series", labelKey: "live_tv.tabs.series" },
+];
+
+export const TVLiveTVPage: React.FC = () => {
+ const { t } = useTranslation();
+ const typography = useScaledTVTypography();
+ const insets = useSafeAreaInsets();
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
+
+ const [activeTab, setActiveTab] = useState("programs");
+
+ // Section configurations for Programs tab
+ const sections = useMemo(() => {
+ if (!api || !user?.Id) return [];
+
+ return [
+ {
+ title: t("live_tv.on_now"),
+ queryKey: ["livetv", "tv", "onNow"],
+ queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
+ const res = await getLiveTvApi(api).getRecommendedPrograms({
+ userId: user.Id,
+ isAiring: true,
+ limit: 24,
+ imageTypeLimit: 1,
+ enableImageTypes: ["Primary", "Thumb", "Backdrop"],
+ enableTotalRecordCount: false,
+ fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
+ });
+ const items = res.data.Items || [];
+ return items.slice(pageParam, pageParam + 10);
+ },
+ },
+ {
+ title: t("live_tv.shows"),
+ queryKey: ["livetv", "tv", "shows"],
+ queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
+ const res = await getLiveTvApi(api).getLiveTvPrograms({
+ userId: user.Id,
+ hasAired: false,
+ limit: 24,
+ isMovie: false,
+ isSeries: true,
+ isSports: false,
+ isNews: false,
+ isKids: false,
+ enableTotalRecordCount: false,
+ fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
+ enableImageTypes: ["Primary", "Thumb", "Backdrop"],
+ });
+ const items = res.data.Items || [];
+ return items.slice(pageParam, pageParam + 10);
+ },
+ },
+ {
+ title: t("live_tv.movies"),
+ queryKey: ["livetv", "tv", "movies"],
+ queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
+ const res = await getLiveTvApi(api).getLiveTvPrograms({
+ userId: user.Id,
+ hasAired: false,
+ limit: 24,
+ isMovie: true,
+ enableTotalRecordCount: false,
+ fields: ["ChannelInfo"],
+ enableImageTypes: ["Primary", "Thumb", "Backdrop"],
+ });
+ const items = res.data.Items || [];
+ return items.slice(pageParam, pageParam + 10);
+ },
+ },
+ {
+ title: t("live_tv.sports"),
+ queryKey: ["livetv", "tv", "sports"],
+ queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
+ const res = await getLiveTvApi(api).getLiveTvPrograms({
+ userId: user.Id,
+ hasAired: false,
+ limit: 24,
+ isSports: true,
+ enableTotalRecordCount: false,
+ fields: ["ChannelInfo"],
+ enableImageTypes: ["Primary", "Thumb", "Backdrop"],
+ });
+ const items = res.data.Items || [];
+ return items.slice(pageParam, pageParam + 10);
+ },
+ },
+ {
+ title: t("live_tv.for_kids"),
+ queryKey: ["livetv", "tv", "kids"],
+ queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
+ const res = await getLiveTvApi(api).getLiveTvPrograms({
+ userId: user.Id,
+ hasAired: false,
+ limit: 24,
+ isKids: true,
+ enableTotalRecordCount: false,
+ fields: ["ChannelInfo"],
+ enableImageTypes: ["Primary", "Thumb", "Backdrop"],
+ });
+ const items = res.data.Items || [];
+ return items.slice(pageParam, pageParam + 10);
+ },
+ },
+ {
+ title: t("live_tv.news"),
+ queryKey: ["livetv", "tv", "news"],
+ queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
+ const res = await getLiveTvApi(api).getLiveTvPrograms({
+ userId: user.Id,
+ hasAired: false,
+ limit: 24,
+ isNews: true,
+ enableTotalRecordCount: false,
+ fields: ["ChannelInfo"],
+ enableImageTypes: ["Primary", "Thumb", "Backdrop"],
+ });
+ const items = res.data.Items || [];
+ return items.slice(pageParam, pageParam + 10);
+ },
+ },
+ ];
+ }, [api, user?.Id, t]);
+
+ const handleTabSelect = useCallback((tabId: TabId) => {
+ setActiveTab(tabId);
+ }, []);
+
+ const renderProgramsContent = () => (
+
+
+ {sections.map((section) => (
+
+ ))}
+
+
+ );
+
+ const renderTabContent = () => {
+ if (activeTab === "programs") {
+ return renderProgramsContent();
+ }
+
+ if (activeTab === "guide") {
+ return ;
+ }
+
+ if (activeTab === "channels") {
+ return ;
+ }
+
+ // Placeholder for other tabs
+ const tab = TABS.find((t) => t.id === activeTab);
+ return ;
+ };
+
+ return (
+
+ {/* Header with Title and Tabs */}
+
+ {/* Title */}
+
+ Live TV
+
+
+ {/* Tab Bar */}
+
+ {TABS.map((tab) => (
+ handleTabSelect(tab.id)}
+ hasTVPreferredFocus={activeTab === tab.id}
+ switchOnFocus={true}
+ />
+ ))}
+
+
+
+ {/* Tab Content */}
+ {renderTabContent()}
+
+ );
+};
diff --git a/components/livetv/TVLiveTVPlaceholder.tsx b/components/livetv/TVLiveTVPlaceholder.tsx
new file mode 100644
index 000000000..2880cbedd
--- /dev/null
+++ b/components/livetv/TVLiveTVPlaceholder.tsx
@@ -0,0 +1,46 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+
+interface TVLiveTVPlaceholderProps {
+ tabName: string;
+}
+
+export const TVLiveTVPlaceholder: React.FC = ({
+ tabName,
+}) => {
+ const { t } = useTranslation();
+ const typography = useScaledTVTypography();
+
+ return (
+
+
+ {tabName}
+
+
+ {t("live_tv.coming_soon")}
+
+
+ );
+};
diff --git a/components/login/Login.tsx b/components/login/Login.tsx
new file mode 100644
index 000000000..da634ad09
--- /dev/null
+++ b/components/login/Login.tsx
@@ -0,0 +1,458 @@
+import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
+import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
+import { Image } from "expo-image";
+import { useLocalSearchParams, useNavigation } from "expo-router";
+import { t } from "i18next";
+import { useAtomValue } from "jotai";
+import { useCallback, useEffect, useState } from "react";
+import {
+ Alert,
+ Keyboard,
+ KeyboardAvoidingView,
+ Platform,
+ 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")),
+});
+
+export const Login: React.FC = () => {
+ const api = useAtomValue(apiAtom);
+ const navigation = useNavigation();
+ const params = useLocalSearchParams();
+ const {
+ setServer,
+ login,
+ removeServer,
+ initiateQuickConnect,
+ loginWithSavedCredential,
+ loginWithPassword,
+ } = useJellyfin();
+
+ const {
+ apiUrl: _apiUrl,
+ username: _username,
+ password: _password,
+ } = params as { apiUrl: string; username: string; password: string };
+
+ const [loadingServerCheck, setLoadingServerCheck] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [serverURL, setServerURL] = useState(_apiUrl || "");
+ const [serverName, setServerName] = useState("");
+ const [credentials, setCredentials] = useState<{
+ username: string;
+ password: string;
+ }>({
+ 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);
+
+ // Handle URL params for server connection
+ useEffect(() => {
+ (async () => {
+ if (_apiUrl) {
+ await setServer({
+ address: _apiUrl,
+ });
+ }
+ })();
+ }, [_apiUrl]);
+
+ // Handle auto-login when api is ready and credentials are provided via URL params
+ useEffect(() => {
+ if (api?.basePath && _apiUrl && _username && _password) {
+ setCredentials({ username: _username, password: _password });
+ login(_username, _password);
+ }
+ }, [api?.basePath, _apiUrl, _username, _password]);
+
+ useEffect(() => {
+ navigation.setOptions({
+ headerTitle: serverName,
+ headerLeft: () =>
+ api?.basePath ? (
+ {
+ removeServer();
+ }}
+ className='flex flex-row items-center pr-2 pl-1'
+ >
+
+
+ {t("login.change_server")}
+
+
+ ) : null,
+ });
+ }, [serverName, navigation, api?.basePath]);
+
+ const handleLogin = async () => {
+ Keyboard.dismiss();
+
+ const result = CredentialsSchema.safeParse(credentials);
+ if (!result.success) return;
+
+ if (saveAccount) {
+ setPendingLogin({
+ username: credentials.username,
+ password: credentials.password,
+ });
+ setShowSaveModal(true);
+ } else {
+ await performLogin(credentials.username, credentials.password);
+ }
+ };
+
+ const performLogin = async (
+ username: string,
+ password: string,
+ options?: {
+ saveAccount?: boolean;
+ securityType?: AccountSecurityType;
+ pinCode?: string;
+ },
+ ) => {
+ setLoading(true);
+ try {
+ await login(username, password, serverName, options);
+ } catch (error) {
+ if (error instanceof Error) {
+ Alert.alert(t("login.connection_failed"), error.message);
+ } else {
+ Alert.alert(
+ t("login.connection_failed"),
+ t("login.an_unexpected_error_occurred"),
+ );
+ }
+ } 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) => {
+ setServer({ address: server.address });
+ if (server.name) {
+ setServerName(server.name);
+ }
+ };
+
+ const checkUrl = useCallback(async (url: string) => {
+ setLoadingServerCheck(true);
+ const baseUrl = url.replace(/^https?:\/\//i, "");
+ const protocols = ["https", "http"];
+ try {
+ return checkHttp(baseUrl, protocols);
+ } catch (e) {
+ if (e instanceof Error && e.message === "Server too old") {
+ throw e;
+ }
+ return undefined;
+ } finally {
+ setLoadingServerCheck(false);
+ }
+ }, []);
+
+ async function checkHttp(baseUrl: string, protocols: string[]) {
+ for (const protocol of protocols) {
+ try {
+ const response = await fetch(
+ `${protocol}://${baseUrl}/System/Info/Public`,
+ {
+ mode: "cors",
+ },
+ );
+ if (response.ok) {
+ const data = (await response.json()) as PublicSystemInfo;
+ const serverVersion = data.Version?.split(".");
+ if (serverVersion && +serverVersion[0] <= 10) {
+ if (+serverVersion[1] < 10) {
+ Alert.alert(
+ t("login.too_old_server_text"),
+ t("login.too_old_server_description"),
+ );
+ throw new Error("Server too old");
+ }
+ }
+ setServerName(data.ServerName || "");
+ return `${protocol}://${baseUrl}`;
+ }
+ } catch (e) {
+ if (e instanceof Error && e.message === "Server too old") {
+ throw e;
+ }
+ }
+ }
+ return undefined;
+ }
+
+ const handleConnect = useCallback(async (url: string) => {
+ url = url.trim().replace(/\/$/, "");
+ try {
+ const result = await checkUrl(url);
+ if (result === undefined) {
+ Alert.alert(
+ t("login.connection_failed"),
+ t("login.could_not_connect_to_server"),
+ );
+ return;
+ }
+ await setServer({ address: result });
+ } catch {}
+ }, []);
+
+ const handleQuickConnect = async () => {
+ try {
+ const code = await initiateQuickConnect();
+ if (code) {
+ Alert.alert(
+ t("login.quick_connect"),
+ t("login.enter_code_to_login", { code: code }),
+ [
+ {
+ text: t("login.got_it"),
+ },
+ ],
+ );
+ }
+ } catch (_error) {
+ Alert.alert(
+ t("login.error_title"),
+ t("login.failed_to_initiate_quick_connect"),
+ );
+ }
+ };
+
+ return (
+
+
+ {api?.basePath ? (
+
+
+
+
+ {serverName ? (
+ <>
+ {`${t("login.login_to_title")} `}
+ {serverName}
+ >
+ ) : (
+ t("login.login_title")
+ )}
+
+ {api.basePath}
+
+ 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'
+ autoCorrect={false}
+ textContentType='username'
+ clearButtonMode='while-editing'
+ maxLength={500}
+ />
+
+
+ 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'
+ returnKeyType='done'
+ autoCapitalize='none'
+ textContentType='password'
+ clearButtonMode='while-editing'
+ maxLength={500}
+ />
+ setSaveAccount(!saveAccount)}
+ className='flex flex-row items-center py-2'
+ activeOpacity={0.7}
+ >
+
+
+ {t("save_account.save_for_later")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+ Streamyfin
+
+ {t("server.enter_url_to_jellyfin_server")}
+
+
+
+ {
+ setServerURL(server.address);
+ if (server.serverName) {
+ setServerName(server.serverName);
+ }
+ await handleConnect(server.address);
+ }}
+ />
+ {
+ await handleConnect(s.address);
+ }}
+ onQuickLogin={handleQuickLoginWithSavedCredential}
+ onPasswordLogin={handlePasswordLogin}
+ onAddAccount={handleAddAccount}
+ />
+
+
+ )}
+
+
+ {
+ setShowSaveModal(false);
+ setPendingLogin(null);
+ }}
+ onSave={handleSaveAccountConfirm}
+ username={pendingLogin?.username || credentials.username}
+ />
+
+ );
+};
diff --git a/components/login/TVAccountCard.tsx b/components/login/TVAccountCard.tsx
new file mode 100644
index 000000000..d555536d1
--- /dev/null
+++ b/components/login/TVAccountCard.tsx
@@ -0,0 +1,155 @@
+import { Ionicons } from "@expo/vector-icons";
+import React, { useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Animated, Easing, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { scaleSize } from "@/utils/scaleSize";
+import type { SavedServerAccount } from "@/utils/secureCredentials";
+
+interface TVAccountCardProps {
+ account: SavedServerAccount;
+ onPress: () => void;
+ onLongPress?: () => void;
+ hasTVPreferredFocus?: boolean;
+}
+
+export const TVAccountCard: React.FC = ({
+ account,
+ onPress,
+ onLongPress,
+ hasTVPreferredFocus,
+}) => {
+ const { t } = useTranslation();
+ const [isFocused, setIsFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+ const glowOpacity = useRef(new Animated.Value(0)).current;
+
+ const animateFocus = (focused: boolean) => {
+ Animated.parallel([
+ Animated.timing(scale, {
+ toValue: focused ? 1.03 : 1,
+ duration: 150,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(glowOpacity, {
+ toValue: focused ? 0.6 : 0,
+ duration: 150,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ ]).start();
+ };
+
+ const handleFocus = () => {
+ setIsFocused(true);
+ animateFocus(true);
+ };
+
+ const handleBlur = () => {
+ setIsFocused(false);
+ animateFocus(false);
+ };
+
+ const getSecurityIcon = (): keyof typeof Ionicons.glyphMap => {
+ switch (account.securityType) {
+ case "pin":
+ return "keypad";
+ case "password":
+ return "lock-closed";
+ default:
+ return "key";
+ }
+ };
+
+ const getSecurityText = (): string => {
+ switch (account.securityType) {
+ case "pin":
+ return t("save_account.pin_code");
+ case "password":
+ return t("save_account.password");
+ default:
+ return t("save_account.no_protection");
+ }
+ };
+
+ return (
+
+
+
+ {/* Avatar */}
+
+
+
+
+ {/* Account Info */}
+
+
+ {account.username}
+
+
+ {getSecurityText()}
+
+
+
+ {/* Security Icon */}
+
+
+
+
+ );
+};
diff --git a/components/login/TVAddIcon.tsx b/components/login/TVAddIcon.tsx
new file mode 100644
index 000000000..45b5f0415
--- /dev/null
+++ b/components/login/TVAddIcon.tsx
@@ -0,0 +1,83 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+
+export interface TVAddIconProps {
+ label: string;
+ onPress: () => void;
+ hasTVPreferredFocus?: boolean;
+ disabled?: boolean;
+}
+
+export const TVAddIcon = React.forwardRef(
+ ({ label, onPress, hasTVPreferredFocus, disabled = false }, ref) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation();
+
+ return (
+
+
+
+
+
+
+
+ {label}
+
+
+
+ );
+ },
+);
diff --git a/components/login/TVAddServerForm.tsx b/components/login/TVAddServerForm.tsx
new file mode 100644
index 000000000..cf38fa54c
--- /dev/null
+++ b/components/login/TVAddServerForm.tsx
@@ -0,0 +1,123 @@
+import { t } from "i18next";
+import React, { useCallback, useState } from "react";
+import { Platform, ScrollView, View } from "react-native";
+import { Button } from "@/components/Button";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { useTVBackPress } from "@/hooks/useTVBackPress";
+import { scaleSize } from "@/utils/scaleSize";
+import { TVInput } from "./TVInput";
+
+interface TVAddServerFormProps {
+ onConnect: (url: string) => Promise;
+ onStartPairing?: () => void;
+ onBack: () => void;
+ loading?: boolean;
+ disabled?: boolean;
+}
+
+export const TVAddServerForm: React.FC = ({
+ onConnect,
+ onStartPairing,
+ onBack,
+ loading = false,
+ disabled = false,
+}) => {
+ const typography = useScaledTVTypography();
+ const [serverURL, setServerURL] = useState("");
+
+ const handleConnect = async () => {
+ if (serverURL.trim()) {
+ await onConnect(serverURL.trim());
+ }
+ };
+
+ const isDisabled = disabled || loading;
+
+ const handleBack = useCallback(() => {
+ if (isDisabled) return false;
+ onBack();
+ return true;
+ }, [isDisabled, onBack]);
+
+ useTVBackPress(() => handleBack(), [handleBack]);
+
+ return (
+
+
+ {/* Title */}
+
+ {t("server.enter_url_to_jellyfin_server")}
+
+
+ {/* Server URL Input */}
+
+
+
+
+ {/* Connect Button */}
+
+
+
+
+ {/* Pair with Phone */}
+ {Platform.OS !== "ios" && onStartPairing && (
+
+
+
+ )}
+
+
+ );
+};
diff --git a/components/login/TVAddUserForm.tsx b/components/login/TVAddUserForm.tsx
new file mode 100644
index 000000000..3801f623f
--- /dev/null
+++ b/components/login/TVAddUserForm.tsx
@@ -0,0 +1,185 @@
+import { t } from "i18next";
+import React, { useCallback, useState } from "react";
+import { ScrollView, View } from "react-native";
+import { Button } from "@/components/Button";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { useTVBackPress } from "@/hooks/useTVBackPress";
+import { scaleSize } from "@/utils/scaleSize";
+import { TVInput } from "./TVInput";
+import { TVSaveAccountToggle } from "./TVSaveAccountToggle";
+
+interface TVAddUserFormProps {
+ serverName: string;
+ serverAddress: string;
+ onLogin: (
+ username: string,
+ password: string,
+ saveAccount: boolean,
+ ) => Promise;
+ onQuickConnect: () => Promise;
+ onBack: () => void;
+ loading?: boolean;
+ disabled?: boolean;
+}
+
+export const TVAddUserForm: React.FC = ({
+ serverName,
+ serverAddress,
+ onLogin,
+ onQuickConnect,
+ onBack,
+ loading = false,
+ disabled = false,
+}) => {
+ const typography = useScaledTVTypography();
+ const [credentials, setCredentials] = useState({
+ username: "",
+ password: "",
+ });
+ const [saveAccount, setSaveAccount] = useState(false);
+
+ const handleLogin = async () => {
+ if (credentials.username.trim()) {
+ await onLogin(credentials.username, credentials.password, saveAccount);
+ }
+ };
+
+ const isDisabled = disabled || loading;
+
+ const handleBack = useCallback(() => {
+ if (isDisabled) return false;
+ onBack();
+ return true;
+ }, [isDisabled, onBack]);
+
+ useTVBackPress(() => handleBack(), [handleBack]);
+
+ return (
+
+
+ {/* Title */}
+
+ {serverName ? (
+ <>
+ {`${t("login.login_to_title")} `}
+ {serverName}
+ >
+ ) : (
+ t("login.login_title")
+ )}
+
+
+ {serverAddress}
+
+
+ {/* Username Input */}
+
+
+ setCredentials((prev) => ({ ...prev, username: text }))
+ }
+ autoCapitalize='none'
+ autoCorrect={false}
+ textContentType='username'
+ returnKeyType='next'
+ hasTVPreferredFocus
+ disabled={isDisabled}
+ />
+
+
+ {/* Password Input */}
+
+
+ setCredentials((prev) => ({ ...prev, password: text }))
+ }
+ secureTextEntry
+ autoCapitalize='none'
+ textContentType='password'
+ returnKeyType='done'
+ disabled={isDisabled}
+ />
+
+
+ {/* Save Account Toggle */}
+
+
+
+
+ {/* Login Button */}
+
+
+
+
+ {/* Quick Connect Button */}
+
+
+
+ );
+};
diff --git a/components/login/TVBackIcon.tsx b/components/login/TVBackIcon.tsx
new file mode 100644
index 000000000..48dc8f3f2
--- /dev/null
+++ b/components/login/TVBackIcon.tsx
@@ -0,0 +1,83 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+
+export interface TVBackIconProps {
+ label: string;
+ onPress: () => void;
+ hasTVPreferredFocus?: boolean;
+ disabled?: boolean;
+}
+
+export const TVBackIcon = React.forwardRef(
+ ({ label, onPress, hasTVPreferredFocus, disabled = false }, ref) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation();
+
+ return (
+
+
+
+
+
+
+
+ {label}
+
+
+
+ );
+ },
+);
diff --git a/components/login/TVInput.tsx b/components/login/TVInput.tsx
new file mode 100644
index 000000000..8c5c75316
--- /dev/null
+++ b/components/login/TVInput.tsx
@@ -0,0 +1,91 @@
+import React, { useRef, useState } from "react";
+import {
+ Animated,
+ Easing,
+ Pressable,
+ TextInput,
+ type TextInputProps,
+} from "react-native";
+import { scaleSize } from "@/utils/scaleSize";
+
+interface TVInputProps extends TextInputProps {
+ label?: string;
+ hasTVPreferredFocus?: boolean;
+ disabled?: boolean;
+}
+
+export const TVInput: React.FC = ({
+ label,
+ placeholder,
+ hasTVPreferredFocus,
+ disabled = false,
+ style,
+ ...props
+}) => {
+ const [isFocused, setIsFocused] = useState(false);
+ const inputRef = useRef(null);
+ const scale = useRef(new Animated.Value(1)).current;
+
+ const animateFocus = (focused: boolean) => {
+ Animated.timing(scale, {
+ toValue: focused ? 1.02 : 1,
+ duration: 200,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start();
+ };
+
+ const handleFocus = () => {
+ setIsFocused(true);
+ animateFocus(true);
+ };
+
+ const handleBlur = () => {
+ setIsFocused(false);
+ animateFocus(false);
+ };
+
+ const displayPlaceholder = placeholder || label;
+
+ return (
+ inputRef.current?.focus()}
+ onFocus={handleFocus}
+ onBlur={handleBlur}
+ hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
+ disabled={disabled}
+ focusable={!disabled}
+ >
+
+
+
+
+ );
+};
diff --git a/components/login/TVLogin.tsx b/components/login/TVLogin.tsx
new file mode 100644
index 000000000..00bd7f65f
--- /dev/null
+++ b/components/login/TVLogin.tsx
@@ -0,0 +1,809 @@
+import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
+import { useLocalSearchParams, useNavigation } from "expo-router";
+import { t } from "i18next";
+import { useAtom, useAtomValue } from "jotai";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Alert, View } from "react-native";
+import { useMMKVString } from "react-native-mmkv";
+import { Text } from "@/components/common/Text";
+import { useTVMenuKeyInterception } from "@/hooks/useTVBackPress";
+import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
+import { selectedTVServerAtom } from "@/utils/atoms/selectedTVServer";
+import { storage } from "@/utils/mmkv";
+import {
+ generatePairingCode,
+ type PairingCredentials,
+ startPairingListener,
+} from "@/utils/pairingService";
+import { scaleSize } from "@/utils/scaleSize";
+import {
+ type AccountSecurityType,
+ getPreviousServers,
+ hashPIN,
+ removeServerFromList,
+ type SavedServer,
+ type SavedServerAccount,
+ saveAccountCredential,
+} from "@/utils/secureCredentials";
+import { TVAddServerForm } from "./TVAddServerForm";
+import { TVAddUserForm } from "./TVAddUserForm";
+import { TVPasswordEntryModal } from "./TVPasswordEntryModal";
+import { TVPINEntryModal } from "./TVPINEntryModal";
+import { TVQRCodeDisplay } from "./TVQRCodeDisplay";
+import { TVSaveAccountModal } from "./TVSaveAccountModal";
+import { TVServerSelectionScreen } from "./TVServerSelectionScreen";
+import { TVUserSelectionScreen } from "./TVUserSelectionScreen";
+
+type TVLoginScreen =
+ | "server-selection"
+ | "qr-code-display"
+ | "loading"
+ | "user-selection"
+ | "add-server"
+ | "add-user";
+
+export const TVLogin: React.FC = () => {
+ const api = useAtomValue(apiAtom);
+ const navigation = useNavigation();
+ const params = useLocalSearchParams();
+ const {
+ setServer,
+ login,
+ removeServer,
+ initiateQuickConnect,
+ stopQuickConnectPolling,
+ loginWithSavedCredential,
+ loginWithPassword,
+ } = useJellyfin();
+
+ const {
+ apiUrl: _apiUrl,
+ username: _username,
+ password: _password,
+ } = params as { apiUrl: string; username: string; password: string };
+
+ // Selected server persistence
+ const [selectedTVServer, setSelectedTVServer] = useAtom(selectedTVServerAtom);
+ const [_previousServers, setPreviousServers] =
+ useMMKVString("previousServers");
+
+ // Get current servers list
+ const previousServers = useMemo(() => {
+ try {
+ return JSON.parse(_previousServers || "[]") as SavedServer[];
+ } catch {
+ return [];
+ }
+ }, [_previousServers]);
+
+ // Current screen state
+ const [currentScreen, setCurrentScreen] =
+ useState("server-selection");
+ // No interception on server-selection so that it can go back to home screen on tvOS
+ useTVMenuKeyInterception(currentScreen !== "server-selection");
+
+ // Current selected server for user selection screen
+ const [currentServer, setCurrentServer] = useState(null);
+ const [serverName, setServerName] = useState("");
+
+ // Loading states
+ const [loadingServerCheck, setLoadingServerCheck] = useState(false);
+ const [loading, setLoading] = useState(false);
+
+ // Save account state
+ const [showSaveModal, setShowSaveModal] = useState(false);
+ const [pendingLogin, setPendingLogin] = useState<{
+ username: string;
+ password: string;
+ } | null>(null);
+
+ // PIN/Password entry for saved accounts
+ const [pinModalVisible, setPinModalVisible] = useState(false);
+ const [passwordModalVisible, setPasswordModalVisible] = useState(false);
+ const [selectedAccount, setSelectedAccount] =
+ useState(null);
+
+ // Track if any modal is open to disable background focus
+ const isAnyModalOpen =
+ showSaveModal || pinModalVisible || passwordModalVisible;
+
+ // Pairing state (companion login via phone)
+ const [showPairingQR, setShowPairingQR] = useState(false);
+ const [pairingCode, setPairingCode] = useState("");
+ const [pendingPairingCredentials, setPendingPairingCredentials] = useState<{
+ serverUrl: string;
+ username: string;
+ password: string;
+ } | null>(null);
+ // Ref to prevent double-handling when onSave and onClose both fire
+ const pairingHandledRef = useRef(false);
+
+ // Refresh servers list helper
+ const refreshServers = () => {
+ const servers = getPreviousServers();
+ setPreviousServers(JSON.stringify(servers));
+ };
+
+ // Initialize on mount - check if we have a persisted server
+ useEffect(() => {
+ if (selectedTVServer) {
+ // Find the full server data from previousServers
+ const server = previousServers.find(
+ (s) => s.address === selectedTVServer.address,
+ );
+ if (server) {
+ setCurrentServer(server);
+ setServerName(selectedTVServer.name || "");
+ setCurrentScreen("user-selection");
+ } else {
+ // Server no longer exists, clear persistence
+ setSelectedTVServer(null);
+ }
+ }
+ }, []);
+
+ // Stop Quick Connect polling when leaving the login page
+ useEffect(() => {
+ return () => {
+ stopQuickConnectPolling();
+ setShowPairingQR(false);
+ };
+ }, [stopQuickConnectPolling]);
+
+ // Handle URL params for server connection
+ useEffect(() => {
+ (async () => {
+ if (_apiUrl) {
+ await setServer({ address: _apiUrl });
+ }
+ })();
+ }, [_apiUrl]);
+
+ // Handle auto-login when api is ready and credentials are provided via URL params
+ useEffect(() => {
+ if (api?.basePath && _apiUrl && _username && _password) {
+ login(_username, _password);
+ }
+ }, [api?.basePath, _apiUrl, _username, _password]);
+
+ // Update header
+ useEffect(() => {
+ navigation.setOptions({
+ headerTitle: serverName,
+ headerShown: false,
+ });
+ }, [serverName, navigation]);
+
+ // Server URL checking
+ const checkUrl = useCallback(async (url: string) => {
+ setLoadingServerCheck(true);
+ const baseUrl = url.replace(/^https?:\/\//i, "");
+ const protocols = ["https", "http"];
+ try {
+ return checkHttp(baseUrl, protocols);
+ } catch (e) {
+ if (e instanceof Error && e.message === "Server too old") {
+ throw e;
+ }
+ return undefined;
+ } finally {
+ setLoadingServerCheck(false);
+ }
+ }, []);
+
+ async function checkHttp(baseUrl: string, protocols: string[]) {
+ for (const protocol of protocols) {
+ try {
+ const response = await fetch(
+ `${protocol}://${baseUrl}/System/Info/Public`,
+ { mode: "cors" },
+ );
+ if (response.ok) {
+ const data = (await response.json()) as PublicSystemInfo;
+ const serverVersion = data.Version?.split(".");
+ if (serverVersion && +serverVersion[0] <= 10) {
+ if (+serverVersion[1] < 10) {
+ Alert.alert(
+ t("login.too_old_server_text"),
+ t("login.too_old_server_description"),
+ );
+ throw new Error("Server too old");
+ }
+ }
+ setServerName(data.ServerName || "");
+ return `${protocol}://${baseUrl}`;
+ }
+ } catch (e) {
+ if (e instanceof Error && e.message === "Server too old") {
+ throw e;
+ }
+ }
+ }
+ return undefined;
+ }
+
+ // Handle connecting to a new server
+ const handleConnect = useCallback(
+ async (url: string) => {
+ url = url.trim().replace(/\/$/, "");
+ try {
+ const result = await checkUrl(url);
+ if (result === undefined) {
+ Alert.alert(
+ t("login.connection_failed"),
+ t("login.could_not_connect_to_server"),
+ );
+ return;
+ }
+ await setServer({ address: result });
+
+ // Update server list and get the new server data
+ refreshServers();
+
+ // Find or create server entry
+ const servers = getPreviousServers();
+ const server = servers.find((s) => s.address === result);
+
+ if (server) {
+ setCurrentServer(server);
+ setSelectedTVServer({ address: result, name: serverName });
+ setCurrentScreen("user-selection");
+ }
+ } catch (error) {
+ if (__DEV__) console.error("[TVLogin] Error in handleConnect:", error);
+ }
+ },
+ [checkUrl, setServer, serverName, setSelectedTVServer],
+ );
+
+ // Handle selecting an existing server
+ const handleServerSelect = (server: SavedServer) => {
+ setCurrentServer(server);
+ setServerName(server.name || "");
+ setSelectedTVServer({ address: server.address, name: server.name });
+ setCurrentScreen("user-selection");
+ };
+
+ // Handle changing server (back from user selection)
+ const handleChangeServer = () => {
+ setSelectedTVServer(null);
+ setCurrentServer(null);
+ setServerName("");
+ removeServer();
+ setCurrentScreen("server-selection");
+ };
+
+ // Handle deleting a server
+ const handleDeleteServer = async (server: SavedServer) => {
+ await removeServerFromList(server.address);
+ refreshServers();
+ // If we deleted the currently selected server, clear it
+ if (selectedTVServer?.address === server.address) {
+ setSelectedTVServer(null);
+ setCurrentServer(null);
+ }
+ };
+
+ // Handle user selection
+ const handleUserSelect = async (account: SavedServerAccount) => {
+ if (!currentServer) return;
+
+ switch (account.securityType) {
+ case "none":
+ setCurrentScreen("loading");
+ setLoading(true);
+ try {
+ await loginWithSavedCredential(currentServer.address, account.userId);
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error
+ ? error.message
+ : t("server.session_expired");
+ const isSessionExpired = errorMessage.includes(
+ t("server.session_expired"),
+ );
+ Alert.alert(
+ isSessionExpired
+ ? t("server.session_expired")
+ : t("login.connection_failed"),
+ isSessionExpired ? t("server.please_login_again") : errorMessage,
+ [
+ {
+ text: t("common.ok"),
+ onPress: () => setCurrentScreen("user-selection"),
+ },
+ ],
+ );
+ } finally {
+ setLoading(false);
+ }
+ break;
+
+ case "pin":
+ setSelectedAccount(account);
+ setPinModalVisible(true);
+ break;
+
+ case "password":
+ setSelectedAccount(account);
+ setPasswordModalVisible(true);
+ break;
+ }
+ };
+
+ // Handle PIN success
+ const handlePinSuccess = async () => {
+ setPinModalVisible(false);
+ if (currentServer && selectedAccount) {
+ setCurrentScreen("loading");
+ setLoading(true);
+ try {
+ await loginWithSavedCredential(
+ currentServer.address,
+ selectedAccount.userId,
+ );
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : t("server.session_expired");
+ const isSessionExpired = errorMessage.includes(
+ t("server.session_expired"),
+ );
+ Alert.alert(
+ isSessionExpired
+ ? t("server.session_expired")
+ : t("login.connection_failed"),
+ isSessionExpired ? t("server.please_login_again") : errorMessage,
+ [
+ {
+ text: t("common.ok"),
+ onPress: () => setCurrentScreen("user-selection"),
+ },
+ ],
+ );
+ } finally {
+ setLoading(false);
+ }
+ }
+ setSelectedAccount(null);
+ };
+
+ // Handle password submit
+ const handlePasswordSubmit = async (password: string) => {
+ if (currentServer && selectedAccount) {
+ setCurrentScreen("loading");
+ setLoading(true);
+ try {
+ await loginWithPassword(
+ currentServer.address,
+ selectedAccount.username,
+ password,
+ );
+ } catch {
+ Alert.alert(
+ t("login.connection_failed"),
+ t("login.invalid_username_or_password"),
+ [
+ {
+ text: t("common.ok"),
+ onPress: () => setCurrentScreen("user-selection"),
+ },
+ ],
+ );
+ } finally {
+ setLoading(false);
+ }
+ }
+ setPasswordModalVisible(false);
+ setSelectedAccount(null);
+ };
+
+ // Handle forgot PIN
+ const handleForgotPIN = async () => {
+ setSelectedAccount(null);
+ setPinModalVisible(false);
+ };
+
+ // Handle login with credentials (from add user form)
+ const handleLogin = async (
+ username: string,
+ password: string,
+ saveAccount: boolean,
+ ) => {
+ if (!currentServer) return;
+
+ if (saveAccount) {
+ setPendingLogin({ username, password });
+ setShowSaveModal(true);
+ } else {
+ await performLogin(username, password);
+ }
+ };
+
+ const performLogin = async (
+ username: string,
+ password: string,
+ options?: {
+ saveAccount?: boolean;
+ securityType?: AccountSecurityType;
+ pinCode?: string;
+ },
+ ) => {
+ setLoading(true);
+ try {
+ await login(username, password, serverName, options);
+ } catch (error) {
+ if (error instanceof Error) {
+ Alert.alert(t("login.connection_failed"), error.message);
+ } else {
+ Alert.alert(
+ t("login.connection_failed"),
+ t("login.an_unexpected_error_occurred"),
+ );
+ }
+ } finally {
+ setLoading(false);
+ setPendingLogin(null);
+ }
+ };
+
+ const handleSaveAccountConfirm = async (
+ securityType: AccountSecurityType,
+ pinCode?: string,
+ ) => {
+ setShowSaveModal(false);
+ const pairingCreds = pendingPairingCredentials;
+
+ if (pairingCreds) {
+ // Pairing flow: mark as handled, login, then save credential
+ pairingHandledRef.current = true;
+ setPendingPairingCredentials(null);
+ setPendingLogin(null);
+ setLoading(true);
+ try {
+ await loginWithPassword(
+ pairingCreds.serverUrl,
+ pairingCreds.username,
+ pairingCreds.password,
+ );
+ // Save credential after successful login
+ try {
+ const token = storage.getString("token");
+ const userJson = storage.getString("user");
+ const storedServerUrl = storage.getString("serverUrl");
+ if (token && userJson && storedServerUrl) {
+ const user = JSON.parse(userJson);
+ let pinHash: string | undefined;
+ if (securityType === "pin" && pinCode) {
+ pinHash = await hashPIN(pinCode);
+ }
+ await saveAccountCredential({
+ serverUrl: storedServerUrl,
+ serverName: storedServerUrl,
+ token,
+ userId: user.Id || "",
+ username: pairingCreds.username,
+ savedAt: Date.now(),
+ securityType,
+ pinHash,
+ primaryImageTag: user.PrimaryImageTag ?? undefined,
+ });
+ }
+ } catch (saveError) {
+ if (__DEV__)
+ console.error(
+ "[TVLogin] Failed to save pairing credential:",
+ saveError,
+ );
+ }
+ } catch (error) {
+ const message =
+ error instanceof Error
+ ? error.message
+ : t("login.an_unexpected_error_occurred");
+ Alert.alert(t("login.connection_failed"), message);
+ goToQRScreen();
+ } finally {
+ setLoading(false);
+ }
+ return;
+ }
+
+ // Normal login flow
+ if (pendingLogin && currentServer) {
+ setLoading(true);
+ try {
+ await login(pendingLogin.username, pendingLogin.password, serverName, {
+ saveAccount: true,
+ securityType,
+ pinCode,
+ });
+ } catch (error) {
+ if (error instanceof Error) {
+ Alert.alert(t("login.connection_failed"), error.message);
+ } else {
+ Alert.alert(
+ t("login.connection_failed"),
+ t("login.an_unexpected_error_occurred"),
+ );
+ }
+ } finally {
+ setLoading(false);
+ setPendingLogin(null);
+ }
+ }
+ };
+
+ // Handle quick connect
+ const handleQuickConnect = async () => {
+ try {
+ const code = await initiateQuickConnect();
+ if (code) {
+ Alert.alert(
+ t("login.quick_connect"),
+ t("login.enter_code_to_login", { code: code }),
+ [{ text: t("login.got_it") }],
+ );
+ }
+ } catch (_error) {
+ Alert.alert(
+ t("login.error_title"),
+ t("login.failed_to_initiate_quick_connect"),
+ );
+ }
+ };
+
+ // Navigate to QR screen with a fresh code and active listener
+ const goToQRScreen = useCallback(() => {
+ const code = generatePairingCode();
+ setPairingCode(code);
+ setShowPairingQR(true);
+ setCurrentScreen("qr-code-display");
+ }, []);
+
+ // Handle pairing with companion phone
+ const handleStartPairing = useCallback(() => {
+ goToQRScreen();
+ }, [goToQRScreen]);
+
+ // Handle credentials received from companion
+ const handlePairingCredentials = useCallback(
+ (credentials: PairingCredentials) => {
+ setShowPairingQR(false);
+ setCurrentScreen("loading");
+
+ // Store credentials and show save modal (same UX as normal login)
+ setPendingPairingCredentials({
+ serverUrl: credentials.serverUrl,
+ username: credentials.username,
+ password: credentials.password,
+ });
+ setPendingLogin({
+ username: credentials.username,
+ password: credentials.password,
+ });
+ setShowSaveModal(true);
+ },
+ [],
+ );
+
+ // Listen for pairing credentials when QR is shown
+ useEffect(() => {
+ if (!showPairingQR || !pairingCode) return;
+
+ const cleanup = startPairingListener(
+ pairingCode,
+ handlePairingCredentials,
+ (error) => {
+ if (__DEV__) console.error("[TVLogin] Pairing error:", error);
+ setShowPairingQR(false);
+ Alert.alert(t("login.error_title"), t("companion_login.error_generic"));
+ },
+ );
+
+ // Auto-dismiss after 5 minutes
+ const timeout = setTimeout(
+ () => {
+ setShowPairingQR(false);
+ },
+ 5 * 60 * 1000,
+ );
+
+ return () => {
+ cleanup();
+ clearTimeout(timeout);
+ };
+ }, [showPairingQR, pairingCode, handlePairingCredentials]);
+
+ // Render current screen
+ const renderScreen = () => {
+ // If API is connected but we're on server/user selection,
+ // it means we need to show add-user form
+ if (
+ api?.basePath &&
+ currentScreen !== "add-user" &&
+ currentScreen !== "loading"
+ ) {
+ // API is ready, show add-user form
+ return (
+
+ );
+ }
+
+ switch (currentScreen) {
+ case "server-selection":
+ return (
+ setCurrentScreen("add-server")}
+ onDeleteServer={handleDeleteServer}
+ disabled={isAnyModalOpen}
+ />
+ );
+
+ case "user-selection":
+ if (!currentServer) {
+ setCurrentScreen("server-selection");
+ return null;
+ }
+ return (
+ {
+ // Set the server in JellyfinProvider and go to add-user
+ setServer({ address: currentServer.address });
+ setCurrentScreen("add-user");
+ }}
+ onChangeServer={handleChangeServer}
+ disabled={isAnyModalOpen || loading}
+ />
+ );
+
+ case "add-server":
+ return (
+ setCurrentScreen("server-selection")}
+ loading={loadingServerCheck}
+ disabled={isAnyModalOpen}
+ />
+ );
+
+ case "qr-code-display":
+ return (
+ {
+ setShowPairingQR(false);
+ setCurrentScreen("add-server");
+ }}
+ />
+ );
+
+ case "loading":
+ return (
+
+
+ {t("pairing.logging_in")}
+
+
+ {t("pairing.logging_in_description")}
+
+
+ );
+
+ case "add-user":
+ return (
+ {
+ removeServer();
+ setCurrentScreen("user-selection");
+ }}
+ loading={loading}
+ disabled={isAnyModalOpen}
+ />
+ );
+
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+ {renderScreen()}
+
+ {/* Save Account Modal */}
+ {
+ // If onSave already handled this, just clean up
+ if (pairingHandledRef.current) {
+ pairingHandledRef.current = false;
+ return;
+ }
+ setShowSaveModal(false);
+ if (pendingPairingCredentials) {
+ // Pairing: user dismissed without saving, login anyway
+ const creds = pendingPairingCredentials;
+ setPendingPairingCredentials(null);
+ setPendingLogin(null);
+ loginWithPassword(
+ creds.serverUrl,
+ creds.username,
+ creds.password,
+ ).catch((error) => {
+ const message =
+ error instanceof Error
+ ? error.message
+ : t("login.an_unexpected_error_occurred");
+ Alert.alert(t("login.connection_failed"), message);
+ goToQRScreen();
+ });
+ return;
+ }
+ setPendingLogin(null);
+ }}
+ onSave={handleSaveAccountConfirm}
+ username={pendingLogin?.username || ""}
+ />
+
+ {/* PIN Entry Modal */}
+ {
+ setPinModalVisible(false);
+ setSelectedAccount(null);
+ }}
+ onSuccess={handlePinSuccess}
+ onForgotPIN={handleForgotPIN}
+ serverUrl={currentServer?.address || ""}
+ userId={selectedAccount?.userId || ""}
+ username={selectedAccount?.username || ""}
+ />
+
+ {/* Password Entry Modal */}
+ {
+ setPasswordModalVisible(false);
+ setSelectedAccount(null);
+ }}
+ onSubmit={handlePasswordSubmit}
+ username={selectedAccount?.username || ""}
+ />
+
+ );
+};
diff --git a/components/login/TVPINEntryModal.tsx b/components/login/TVPINEntryModal.tsx
new file mode 100644
index 000000000..9428850da
--- /dev/null
+++ b/components/login/TVPINEntryModal.tsx
@@ -0,0 +1,488 @@
+import { Ionicons } from "@expo/vector-icons";
+import { BlurView } from "expo-blur";
+import React, { useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Alert,
+ Animated,
+ Easing,
+ Pressable,
+ StyleSheet,
+ TVFocusGuideView,
+ View,
+} from "react-native";
+import { Text } from "@/components/common/Text";
+import { scaleSize } from "@/utils/scaleSize";
+import { verifyAccountPIN } from "@/utils/secureCredentials";
+
+interface TVPINEntryModalProps {
+ visible: boolean;
+ onClose: () => void;
+ onSuccess: () => void;
+ onForgotPIN?: () => void;
+ serverUrl: string;
+ userId: string;
+ username: string;
+}
+
+// Number pad button
+const NumberPadButton: React.FC<{
+ value: string;
+ onPress: () => void;
+ hasTVPreferredFocus?: boolean;
+ isBackspace?: boolean;
+ disabled?: boolean;
+}> = ({ value, onPress, hasTVPreferredFocus, isBackspace, disabled }) => {
+ const [focused, setFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+
+ const animateTo = (v: number) =>
+ Animated.timing(scale, {
+ toValue: v,
+ duration: 100,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start();
+
+ return (
+ {
+ setFocused(true);
+ animateTo(1.1);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
+ hasTVPreferredFocus={hasTVPreferredFocus}
+ disabled={disabled}
+ focusable={!disabled}
+ >
+
+ {isBackspace ? (
+
+ ) : (
+
+ {value}
+
+ )}
+
+
+ );
+};
+
+// PIN dot indicator
+const PinDot: React.FC<{ filled: boolean; error: boolean }> = ({
+ filled,
+ error,
+}) => (
+
+);
+
+// Forgot PIN link
+const ForgotPINLink: React.FC<{
+ onPress: () => void;
+ label: string;
+}> = ({ onPress, label }) => {
+ const [focused, setFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+
+ const animateTo = (v: number) =>
+ Animated.timing(scale, {
+ toValue: v,
+ duration: 100,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start();
+
+ return (
+ {
+ setFocused(true);
+ animateTo(1.05);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
+ >
+
+
+ {label}
+
+
+
+ );
+};
+
+export const TVPINEntryModal: React.FC = ({
+ visible,
+ onClose,
+ onSuccess,
+ onForgotPIN,
+ serverUrl,
+ userId,
+ username,
+}) => {
+ const { t } = useTranslation();
+ const [isReady, setIsReady] = useState(false);
+ const [pinCode, setPinCode] = useState("");
+ const [error, setError] = useState(false);
+ const [isVerifying, setIsVerifying] = useState(false);
+
+ const overlayOpacity = useRef(new Animated.Value(0)).current;
+ const contentScale = useRef(new Animated.Value(0.9)).current;
+ const shakeAnimation = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ if (visible) {
+ setPinCode("");
+ setError(false);
+ setIsVerifying(false);
+
+ overlayOpacity.setValue(0);
+ contentScale.setValue(0.9);
+
+ Animated.parallel([
+ Animated.timing(overlayOpacity, {
+ toValue: 1,
+ duration: 250,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(contentScale, {
+ toValue: 1,
+ duration: 300,
+ easing: Easing.out(Easing.cubic),
+ useNativeDriver: true,
+ }),
+ ]).start();
+
+ const timer = setTimeout(() => setIsReady(true), 100);
+ return () => clearTimeout(timer);
+ }
+ setIsReady(false);
+ }, [visible, overlayOpacity, contentScale]);
+
+ const shake = () => {
+ Animated.sequence([
+ Animated.timing(shakeAnimation, {
+ toValue: 15,
+ duration: 50,
+ useNativeDriver: true,
+ }),
+ Animated.timing(shakeAnimation, {
+ toValue: -15,
+ duration: 50,
+ useNativeDriver: true,
+ }),
+ Animated.timing(shakeAnimation, {
+ toValue: 15,
+ duration: 50,
+ useNativeDriver: true,
+ }),
+ Animated.timing(shakeAnimation, {
+ toValue: 0,
+ duration: 50,
+ useNativeDriver: true,
+ }),
+ ]).start();
+ };
+
+ const handleNumberPress = async (num: string) => {
+ if (isVerifying || pinCode.length >= 4) return;
+
+ setError(false);
+ const newPin = pinCode + num;
+ setPinCode(newPin);
+
+ // Auto-verify when 4 digits entered
+ if (newPin.length === 4) {
+ setIsVerifying(true);
+ try {
+ const isValid = await verifyAccountPIN(serverUrl, userId, newPin);
+ if (isValid) {
+ onSuccess();
+ setPinCode("");
+ } else {
+ setError(true);
+ shake();
+ setTimeout(() => setPinCode(""), 300);
+ }
+ } catch {
+ setError(true);
+ shake();
+ setTimeout(() => setPinCode(""), 300);
+ } finally {
+ setIsVerifying(false);
+ }
+ }
+ };
+
+ const handleBackspace = () => {
+ if (isVerifying) return;
+ setError(false);
+ setPinCode((prev) => prev.slice(0, -1));
+ };
+
+ const handleForgotPIN = () => {
+ Alert.alert(t("pin.forgot_pin"), t("pin.forgot_pin_desc"), [
+ { text: t("common.cancel"), style: "cancel" },
+ {
+ text: t("common.continue"),
+ style: "destructive",
+ onPress: () => {
+ onClose();
+ onForgotPIN?.();
+ },
+ },
+ ]);
+ };
+
+ if (!visible) return null;
+
+ return (
+
+
+
+
+ {/* Header */}
+ {t("pin.enter_pin")}
+ {username}
+
+ {/* PIN Dots */}
+
+ {[0, 1, 2, 3].map((i) => (
+ i} error={error} />
+ ))}
+
+
+ {/* Number Pad */}
+ {isReady && (
+
+ {/* Row 1: 1-3 */}
+
+ handleNumberPress("1")}
+ hasTVPreferredFocus
+ disabled={isVerifying}
+ />
+ handleNumberPress("2")}
+ disabled={isVerifying}
+ />
+ handleNumberPress("3")}
+ disabled={isVerifying}
+ />
+
+ {/* Row 2: 4-6 */}
+
+ handleNumberPress("4")}
+ disabled={isVerifying}
+ />
+ handleNumberPress("5")}
+ disabled={isVerifying}
+ />
+ handleNumberPress("6")}
+ disabled={isVerifying}
+ />
+
+ {/* Row 3: 7-9 */}
+
+ handleNumberPress("7")}
+ disabled={isVerifying}
+ />
+ handleNumberPress("8")}
+ disabled={isVerifying}
+ />
+ handleNumberPress("9")}
+ disabled={isVerifying}
+ />
+
+ {/* Row 4: empty, 0, backspace */}
+
+
+ handleNumberPress("0")}
+ disabled={isVerifying}
+ />
+
+
+
+ )}
+
+ {/* Forgot PIN */}
+ {isReady && onForgotPIN && (
+
+
+
+ )}
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ overlay: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: "rgba(0, 0, 0, 0.8)",
+ justifyContent: "center",
+ alignItems: "center",
+ zIndex: 1000,
+ },
+ contentContainer: {
+ width: "100%",
+ maxWidth: 400,
+ },
+ blurContainer: {
+ borderRadius: scaleSize(24),
+ overflow: "hidden",
+ },
+ content: {
+ padding: scaleSize(40),
+ alignItems: "center",
+ },
+ title: {
+ fontSize: scaleSize(28),
+ fontWeight: "bold",
+ color: "#fff",
+ marginBottom: scaleSize(8),
+ textAlign: "center",
+ },
+ subtitle: {
+ fontSize: scaleSize(18),
+ color: "rgba(255,255,255,0.6)",
+ marginBottom: scaleSize(32),
+ textAlign: "center",
+ },
+ pinDotsContainer: {
+ flexDirection: "row",
+ gap: scaleSize(16),
+ marginBottom: scaleSize(32),
+ },
+ pinDot: {
+ width: scaleSize(20),
+ height: scaleSize(20),
+ borderRadius: scaleSize(10),
+ borderWidth: scaleSize(2),
+ borderColor: "rgba(255,255,255,0.4)",
+ backgroundColor: "transparent",
+ },
+ pinDotFilled: {
+ backgroundColor: "#fff",
+ borderColor: "#fff",
+ },
+ pinDotError: {
+ borderColor: "#ef4444",
+ backgroundColor: "#ef4444",
+ },
+ numberPad: {
+ gap: scaleSize(12),
+ marginBottom: scaleSize(24),
+ },
+ numberRow: {
+ flexDirection: "row",
+ gap: scaleSize(12),
+ },
+ numberButton: {
+ width: scaleSize(72),
+ height: scaleSize(72),
+ borderRadius: scaleSize(36),
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ numberButtonPlaceholder: {
+ width: scaleSize(72),
+ height: scaleSize(72),
+ },
+ numberText: {
+ fontSize: scaleSize(28),
+ fontWeight: "600",
+ },
+ forgotContainer: {
+ marginTop: 8,
+ },
+});
diff --git a/components/login/TVPasswordEntryModal.tsx b/components/login/TVPasswordEntryModal.tsx
new file mode 100644
index 000000000..7db1671e9
--- /dev/null
+++ b/components/login/TVPasswordEntryModal.tsx
@@ -0,0 +1,352 @@
+import { Ionicons } from "@expo/vector-icons";
+import { BlurView } from "expo-blur";
+import React, { useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ ActivityIndicator,
+ Animated,
+ Easing,
+ Pressable,
+ StyleSheet,
+ TextInput,
+ TVFocusGuideView,
+ View,
+} from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "@/components/tv";
+import { useTVBackPress } from "@/hooks/useTVBackPress";
+import { scaleSize } from "@/utils/scaleSize";
+
+interface TVPasswordEntryModalProps {
+ visible: boolean;
+ onClose: () => void;
+ onSubmit: (password: string) => Promise;
+ username: string;
+}
+
+// TV Submit Button
+const TVSubmitButton: React.FC<{
+ onPress: () => void;
+ label: string;
+ loading?: boolean;
+ disabled?: boolean;
+}> = ({ onPress, label, loading = false, disabled = false }) => {
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
+
+ const isDisabled = disabled || loading;
+
+ return (
+
+
+ {loading ? (
+
+ ) : (
+ <>
+
+
+ {label}
+
+ >
+ )}
+
+
+ );
+};
+
+// TV Focusable Password Input
+const TVPasswordInput: React.FC<{
+ value: string;
+ onChangeText: (text: string) => void;
+ placeholder: string;
+ onSubmitEditing: () => void;
+ hasTVPreferredFocus?: boolean;
+}> = ({
+ value,
+ onChangeText,
+ placeholder,
+ onSubmitEditing,
+ hasTVPreferredFocus,
+}) => {
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 });
+ const inputRef = useRef(null);
+
+ return (
+ inputRef.current?.focus()}
+ onFocus={() => {
+ handleFocus();
+ inputRef.current?.focus();
+ }}
+ onBlur={handleBlur}
+ hasTVPreferredFocus={hasTVPreferredFocus}
+ >
+
+
+
+
+ );
+};
+
+export const TVPasswordEntryModal: React.FC = ({
+ visible,
+ onClose,
+ onSubmit,
+ username,
+}) => {
+ const { t } = useTranslation();
+ const [isReady, setIsReady] = useState(false);
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const overlayOpacity = useRef(new Animated.Value(0)).current;
+ const sheetTranslateY = useRef(new Animated.Value(200)).current;
+
+ useEffect(() => {
+ if (visible) {
+ // Reset state when opening
+ setPassword("");
+ setError(null);
+ setIsLoading(false);
+
+ overlayOpacity.setValue(0);
+ sheetTranslateY.setValue(200);
+
+ Animated.parallel([
+ Animated.timing(overlayOpacity, {
+ toValue: 1,
+ duration: 250,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(sheetTranslateY, {
+ toValue: 0,
+ duration: 300,
+ easing: Easing.out(Easing.cubic),
+ useNativeDriver: true,
+ }),
+ ]).start();
+ }
+ }, [visible, overlayOpacity, sheetTranslateY]);
+
+ useEffect(() => {
+ if (visible) {
+ const timer = setTimeout(() => setIsReady(true), 100);
+ return () => clearTimeout(timer);
+ }
+ setIsReady(false);
+ }, [visible]);
+
+ // Close the modal on the TV remote back/menu button while it is open.
+ useTVBackPress(() => {
+ if (!visible) return false;
+ onClose();
+ return true;
+ }, [visible, onClose]);
+
+ const handleSubmit = async () => {
+ if (!password) {
+ setError(t("password.enter_password"));
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ await onSubmit(password);
+ setPassword("");
+ } catch {
+ setError(t("password.invalid_password"));
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ if (!visible) return null;
+
+ return (
+
+
+
+
+ {/* Header */}
+
+ {t("password.enter_password")}
+
+ {t("password.enter_password_for", { username })}
+
+
+
+ {/* Password Input */}
+ {isReady && (
+
+
+ {t("login.password_placeholder")}
+
+ {
+ setPassword(text);
+ setError(null);
+ }}
+ placeholder={t("login.password_placeholder")}
+ onSubmitEditing={handleSubmit}
+ hasTVPreferredFocus
+ />
+ {error && {error}}
+
+ )}
+
+ {/* Submit Button */}
+ {isReady && (
+
+
+
+ )}
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ overlay: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ justifyContent: "flex-end",
+ zIndex: 1000,
+ },
+ sheetContainer: {
+ width: "100%",
+ },
+ blurContainer: {
+ borderTopLeftRadius: scaleSize(24),
+ borderTopRightRadius: scaleSize(24),
+ overflow: "hidden",
+ },
+ content: {
+ paddingTop: scaleSize(24),
+ paddingBottom: scaleSize(50),
+ overflow: "visible",
+ },
+ header: {
+ paddingHorizontal: scaleSize(48),
+ marginBottom: scaleSize(24),
+ },
+ title: {
+ fontSize: scaleSize(28),
+ fontWeight: "bold",
+ color: "#fff",
+ marginBottom: scaleSize(4),
+ },
+ subtitle: {
+ fontSize: scaleSize(16),
+ color: "rgba(255,255,255,0.6)",
+ },
+ inputContainer: {
+ paddingHorizontal: scaleSize(48),
+ marginBottom: scaleSize(20),
+ },
+ inputLabel: {
+ fontSize: scaleSize(14),
+ color: "rgba(255,255,255,0.6)",
+ marginBottom: scaleSize(8),
+ },
+ errorText: {
+ color: "#ef4444",
+ fontSize: scaleSize(14),
+ marginTop: scaleSize(8),
+ },
+ buttonContainer: {
+ paddingHorizontal: scaleSize(48),
+ alignItems: "flex-start",
+ },
+});
diff --git a/components/login/TVQRCodeDisplay.tsx b/components/login/TVQRCodeDisplay.tsx
new file mode 100644
index 000000000..16cced246
--- /dev/null
+++ b/components/login/TVQRCodeDisplay.tsx
@@ -0,0 +1,115 @@
+import { t } from "i18next";
+import React, { useCallback } from "react";
+import { View } from "react-native";
+import QRCode from "react-native-qrcode-svg";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { useTVBackPress } from "@/hooks/useTVBackPress";
+import { scaleSize } from "@/utils/scaleSize";
+
+interface TVQRCodeDisplayProps {
+ code: string;
+ onBack?: () => void;
+}
+
+export const TVQRCodeDisplay: React.FC = ({
+ code,
+ onBack,
+}) => {
+ const typography = useScaledTVTypography();
+
+ const qrSize = scaleSize(280);
+ const cardPadding = scaleSize(16);
+ const sectionPadding = scaleSize(32);
+ const outerPadding = scaleSize(60);
+
+ const qrData = JSON.stringify({
+ action: "streamyfin-pair",
+ code,
+ });
+
+ const handleBack = useCallback(() => {
+ if (!onBack) return false;
+ onBack();
+ return true;
+ }, [onBack]);
+
+ useTVBackPress(() => handleBack(), [handleBack]);
+
+ return (
+
+
+ {/* QR Code */}
+
+
+ {t("pairing.waiting_for_phone")}
+
+
+
+
+
+
+
+ {code}
+
+
+
+ {t("pairing.scan_with_phone")}
+
+
+
+
+ );
+};
diff --git a/components/login/TVSaveAccountModal.tsx b/components/login/TVSaveAccountModal.tsx
new file mode 100644
index 000000000..dd224b5b9
--- /dev/null
+++ b/components/login/TVSaveAccountModal.tsx
@@ -0,0 +1,440 @@
+import { Ionicons } from "@expo/vector-icons";
+import { BlurView } from "expo-blur";
+import React, { useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Animated,
+ Easing,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ TVFocusGuideView,
+ View,
+} from "react-native";
+import { Text } from "@/components/common/Text";
+import { TVPinInput, type TVPinInputRef } from "@/components/inputs/TVPinInput";
+import { TVOptionCard, useTVFocusAnimation } from "@/components/tv";
+import { scaleSize } from "@/utils/scaleSize";
+import type { AccountSecurityType } from "@/utils/secureCredentials";
+
+interface TVSaveAccountModalProps {
+ visible: boolean;
+ onClose: () => void;
+ onSave: (securityType: AccountSecurityType, pinCode?: string) => void;
+ username: string;
+}
+
+interface SecurityOption {
+ type: AccountSecurityType;
+ titleKey: string;
+ descriptionKey: string;
+ icon: keyof typeof Ionicons.glyphMap;
+}
+
+const SECURITY_OPTIONS: SecurityOption[] = [
+ {
+ type: "none",
+ titleKey: "save_account.no_protection",
+ descriptionKey: "save_account.no_protection_desc",
+ icon: "flash-outline",
+ },
+ {
+ type: "pin",
+ titleKey: "save_account.pin_code",
+ descriptionKey: "save_account.pin_code_desc",
+ icon: "keypad-outline",
+ },
+ {
+ type: "password",
+ titleKey: "save_account.password",
+ descriptionKey: "save_account.password_desc",
+ icon: "lock-closed-outline",
+ },
+];
+
+// Custom Save Button with TV focus
+const TVSaveButton: React.FC<{
+ onPress: () => void;
+ label: string;
+ disabled?: boolean;
+ hasTVPreferredFocus?: boolean;
+}> = ({ onPress, label, disabled = false, hasTVPreferredFocus = false }) => {
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
+
+ return (
+
+
+
+
+ {label}
+
+
+
+ );
+};
+
+// Back Button for PIN step
+const TVBackButton: React.FC<{
+ onPress: () => void;
+ label: string;
+ hasTVPreferredFocus?: boolean;
+}> = ({ onPress, label, hasTVPreferredFocus = false }) => {
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
+
+ return (
+
+
+
+
+ {label}
+
+
+
+ );
+};
+
+export const TVSaveAccountModal: React.FC = ({
+ visible,
+ onClose,
+ onSave,
+ username,
+}) => {
+ const { t } = useTranslation();
+ const [isReady, setIsReady] = useState(false);
+ const [step, setStep] = useState<"select" | "pin">("select");
+ const [selectedType, setSelectedType] = useState("none");
+ const [pinCode, setPinCode] = useState("");
+ const [pinError, setPinError] = useState(null);
+ const pinInputRef = useRef(null);
+
+ // Use useState for focus tracking (per TV focus guide)
+ const [firstCardRef, setFirstCardRef] = useState(null);
+
+ const overlayOpacity = useRef(new Animated.Value(0)).current;
+ const sheetTranslateY = useRef(new Animated.Value(200)).current;
+
+ useEffect(() => {
+ if (visible) {
+ // Reset state when opening
+ setStep("select");
+ setSelectedType("none");
+ setPinCode("");
+ setPinError(null);
+
+ overlayOpacity.setValue(0);
+ sheetTranslateY.setValue(200);
+
+ Animated.parallel([
+ Animated.timing(overlayOpacity, {
+ toValue: 1,
+ duration: 250,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(sheetTranslateY, {
+ toValue: 0,
+ duration: 300,
+ easing: Easing.out(Easing.cubic),
+ useNativeDriver: true,
+ }),
+ ]).start();
+ }
+ }, [visible, overlayOpacity, sheetTranslateY]);
+
+ useEffect(() => {
+ if (visible) {
+ const timer = setTimeout(() => setIsReady(true), 100);
+ return () => clearTimeout(timer);
+ }
+ setIsReady(false);
+ }, [visible]);
+
+ // Focus the first card when ready
+ useEffect(() => {
+ if (isReady && firstCardRef && step === "select") {
+ const timer = setTimeout(() => {
+ (firstCardRef as any)?.requestTVFocus?.();
+ }, 50);
+ return () => clearTimeout(timer);
+ }
+ }, [isReady, firstCardRef, step]);
+
+ useEffect(() => {
+ if (step === "pin" && isReady) {
+ const timer = setTimeout(() => {
+ pinInputRef.current?.focus();
+ }, 150);
+ return () => clearTimeout(timer);
+ }
+ }, [step, isReady]);
+
+ const handleOptionSelect = (type: AccountSecurityType) => {
+ setSelectedType(type);
+ if (type === "pin") {
+ setStep("pin");
+ setPinCode("");
+ setPinError(null);
+ } else {
+ // For "none" or "password", save immediately
+ onSave(type);
+ resetAndClose();
+ }
+ };
+
+ const handlePinSave = () => {
+ if (pinCode.length !== 4) {
+ setPinError(t("pin.enter_4_digits"));
+ return;
+ }
+ onSave("pin", pinCode);
+ resetAndClose();
+ };
+
+ const handleBack = () => {
+ setStep("select");
+ setPinCode("");
+ setPinError(null);
+ };
+
+ const resetAndClose = () => {
+ setStep("select");
+ setSelectedType("none");
+ setPinCode("");
+ setPinError(null);
+ onClose();
+ };
+
+ if (!visible) return null;
+
+ return (
+
+
+
+
+ {/* Header */}
+
+ {t("save_account.title")}
+ {username}
+
+
+ {step === "select" ? (
+ // Security selection step
+ <>
+
+ {t("save_account.security_option")}
+
+ {isReady && (
+
+ {SECURITY_OPTIONS.map((option, index) => (
+ handleOptionSelect(option.type)}
+ width={220}
+ height={100}
+ />
+ ))}
+
+ )}
+ >
+ ) : (
+ // PIN entry step
+ <>
+ {t("pin.setup_pin")}
+
+ {
+ setPinCode(text);
+ setPinError(null);
+ }}
+ length={4}
+ autoFocus
+ />
+ {pinError && {pinError}}
+
+
+ {isReady && (
+
+
+
+
+ )}
+ >
+ )}
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ overlay: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ justifyContent: "flex-end",
+ zIndex: 1000,
+ },
+ sheetContainer: {
+ width: "100%",
+ },
+ blurContainer: {
+ borderTopLeftRadius: scaleSize(24),
+ borderTopRightRadius: scaleSize(24),
+ overflow: "hidden",
+ },
+ content: {
+ paddingTop: scaleSize(24),
+ paddingBottom: scaleSize(50),
+ overflow: "visible",
+ },
+ header: {
+ paddingHorizontal: scaleSize(48),
+ marginBottom: scaleSize(20),
+ },
+ title: {
+ fontSize: scaleSize(28),
+ fontWeight: "bold",
+ color: "#fff",
+ marginBottom: scaleSize(4),
+ },
+ subtitle: {
+ fontSize: scaleSize(16),
+ color: "rgba(255,255,255,0.6)",
+ },
+ sectionTitle: {
+ fontSize: scaleSize(16),
+ fontWeight: "500",
+ color: "rgba(255,255,255,0.6)",
+ marginBottom: scaleSize(16),
+ paddingHorizontal: scaleSize(48),
+ textTransform: "uppercase",
+ letterSpacing: 1,
+ },
+ scrollView: {
+ overflow: "visible",
+ },
+ scrollContent: {
+ paddingHorizontal: scaleSize(48),
+ paddingVertical: scaleSize(10),
+ gap: scaleSize(12),
+ },
+ buttonRow: {
+ marginTop: scaleSize(20),
+ paddingHorizontal: scaleSize(48),
+ flexDirection: "row",
+ gap: scaleSize(16),
+ alignItems: "center",
+ },
+ pinContainer: {
+ paddingHorizontal: scaleSize(48),
+ alignItems: "center",
+ marginBottom: scaleSize(10),
+ },
+ errorText: {
+ color: "#ef4444",
+ fontSize: scaleSize(14),
+ marginTop: scaleSize(12),
+ textAlign: "center",
+ },
+});
diff --git a/components/login/TVSaveAccountToggle.tsx b/components/login/TVSaveAccountToggle.tsx
new file mode 100644
index 000000000..4591a8871
--- /dev/null
+++ b/components/login/TVSaveAccountToggle.tsx
@@ -0,0 +1,119 @@
+import React, { useRef, useState } from "react";
+import { Animated, Easing, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { scaleSize } from "@/utils/scaleSize";
+
+interface TVSaveAccountToggleProps {
+ value: boolean;
+ onValueChange: (value: boolean) => void;
+ label: string;
+ hasTVPreferredFocus?: boolean;
+ disabled?: boolean;
+}
+
+export const TVSaveAccountToggle: React.FC = ({
+ value,
+ onValueChange,
+ label,
+ hasTVPreferredFocus,
+ disabled = false,
+}) => {
+ const [isFocused, setIsFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+ const glowOpacity = useRef(new Animated.Value(0)).current;
+
+ const animateFocus = (focused: boolean) => {
+ Animated.parallel([
+ Animated.timing(scale, {
+ toValue: focused ? 1.02 : 1,
+ duration: 150,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(glowOpacity, {
+ toValue: focused ? 0.6 : 0,
+ duration: 150,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ ]).start();
+ };
+
+ const handleFocus = () => {
+ setIsFocused(true);
+ animateFocus(true);
+ };
+
+ const handleBlur = () => {
+ setIsFocused(false);
+ animateFocus(false);
+ };
+
+ return (
+ onValueChange(!value)}
+ onFocus={handleFocus}
+ onBlur={handleBlur}
+ hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
+ disabled={disabled}
+ focusable={!disabled}
+ >
+
+
+
+ {label}
+
+
+
+
+
+
+
+ );
+};
diff --git a/components/login/TVServerIcon.tsx b/components/login/TVServerIcon.tsx
new file mode 100644
index 000000000..10d17171e
--- /dev/null
+++ b/components/login/TVServerIcon.tsx
@@ -0,0 +1,212 @@
+import { LinearGradient } from "expo-linear-gradient";
+import React, { useMemo } from "react";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+
+// Sci-fi gradient color pairs (from, to) - cyberpunk/neon vibes
+const SERVER_GRADIENTS: [string, string][] = [
+ ["#00D4FF", "#0066FF"], // Cyan to Blue
+ ["#FF00E5", "#7B00FF"], // Magenta to Purple
+ ["#00FF94", "#00B4D8"], // Neon Green to Cyan
+ ["#FF6B35", "#F72585"], // Orange to Pink
+ ["#4CC9F0", "#7209B7"], // Sky Blue to Violet
+ ["#06D6A0", "#118AB2"], // Mint to Ocean Blue
+ ["#FFD60A", "#FF006E"], // Yellow to Hot Pink
+ ["#8338EC", "#3A86FF"], // Purple to Blue
+ ["#FB5607", "#FFBE0B"], // Orange to Gold
+ ["#00F5D4", "#00BBF9"], // Aqua to Azure
+ ["#F15BB5", "#9B5DE5"], // Pink to Lavender
+ ["#00C49A", "#00509D"], // Teal to Navy
+ ["#E63946", "#F4A261"], // Red to Peach
+ ["#2EC4B6", "#011627"], // Turquoise to Dark Blue
+ ["#FF0099", "#493240"], // Hot Pink to Plum
+ ["#11998E", "#38EF7D"], // Teal to Lime
+ ["#FC466B", "#3F5EFB"], // Pink to Indigo
+ ["#C471ED", "#12C2E9"], // Orchid to Sky
+ ["#F857A6", "#FF5858"], // Pink to Coral
+ ["#00B09B", "#96C93D"], // Emerald to Lime
+ ["#7F00FF", "#E100FF"], // Violet to Magenta
+ ["#1FA2FF", "#12D8FA"], // Blue to Cyan
+ ["#F09819", "#EDDE5D"], // Orange to Yellow
+ ["#FF416C", "#FF4B2B"], // Pink to Red Orange
+ ["#654EA3", "#EAAFC8"], // Purple to Rose
+ ["#00C6FF", "#0072FF"], // Light Blue to Blue
+ ["#F7971E", "#FFD200"], // Orange to Gold
+ ["#56AB2F", "#A8E063"], // Green to Lime
+ ["#DA22FF", "#9733EE"], // Magenta to Purple
+ ["#02AAB0", "#00CDAC"], // Teal variations
+ ["#ED213A", "#93291E"], // Red to Dark Red
+ ["#FDC830", "#F37335"], // Yellow to Orange
+ ["#00B4DB", "#0083B0"], // Ocean Blue
+ ["#C33764", "#1D2671"], // Berry to Navy
+ ["#E55D87", "#5FC3E4"], // Pink to Sky Blue
+ ["#403B4A", "#E7E9BB"], // Dark to Cream
+ ["#F2709C", "#FF9472"], // Rose to Peach
+ ["#1D976C", "#93F9B9"], // Forest to Mint
+ ["#CC2B5E", "#753A88"], // Crimson to Purple
+ ["#42275A", "#734B6D"], // Plum shades
+ ["#BDC3C7", "#2C3E50"], // Silver to Slate
+ ["#DE6262", "#FFB88C"], // Salmon to Apricot
+ ["#06BEB6", "#48B1BF"], // Teal shades
+ ["#EB3349", "#F45C43"], // Red to Orange Red
+ ["#DD5E89", "#F7BB97"], // Pink to Tan
+ ["#56CCF2", "#2F80ED"], // Sky to Blue
+ ["#007991", "#78FFD6"], // Deep Teal to Mint
+ ["#C6FFDD", "#FBD786"], // Mint to Yellow
+ ["#F953C6", "#B91D73"], // Pink to Magenta
+ ["#B24592", "#F15F79"], // Purple to Coral
+];
+
+// Generate a consistent gradient index based on URL (deterministic hash)
+// Uses cyrb53 hash - fast and good distribution
+const getGradientForString = (str: string): [string, string] => {
+ let h1 = 0xdeadbeef;
+ let h2 = 0x41c6ce57;
+ for (let i = 0; i < str.length; i++) {
+ const ch = str.charCodeAt(i);
+ h1 = Math.imul(h1 ^ ch, 2654435761);
+ h2 = Math.imul(h2 ^ ch, 1597334677);
+ }
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
+ h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
+ h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
+ const hash = 4294967296 * (2097151 & h2) + (h1 >>> 0);
+ const index = Math.abs(hash) % SERVER_GRADIENTS.length;
+ return SERVER_GRADIENTS[index];
+};
+
+export interface TVServerIconProps {
+ name: string;
+ address: string;
+ onPress: () => void;
+ onLongPress?: () => void;
+ hasTVPreferredFocus?: boolean;
+ disabled?: boolean;
+}
+
+export const TVServerIcon = React.forwardRef(
+ (
+ {
+ name,
+ address,
+ onPress,
+ onLongPress,
+ hasTVPreferredFocus,
+ disabled = false,
+ },
+ ref,
+ ) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation();
+
+ // Get the first letter of the server name (or address if no name)
+ const displayName = name || address;
+ const initial = displayName.charAt(0).toUpperCase();
+
+ // Get a consistent gradient based on the server URL (deterministic)
+ // Use address as primary key, fallback to name + displayName for uniqueness
+ const hashKey = address || name || displayName;
+ const [gradientStart, gradientEnd] = useMemo(
+ () => getGradientForString(hashKey),
+ [hashKey],
+ );
+
+ return (
+
+
+
+
+
+ {initial}
+
+
+
+
+
+ {displayName}
+
+
+ {name && (
+
+ {address.replace(/^https?:\/\//, "")}
+
+ )}
+
+
+ );
+ },
+);
diff --git a/components/login/TVServerSelectionScreen.tsx b/components/login/TVServerSelectionScreen.tsx
new file mode 100644
index 000000000..4e68eed50
--- /dev/null
+++ b/components/login/TVServerSelectionScreen.tsx
@@ -0,0 +1,138 @@
+import { Image } from "expo-image";
+import { t } from "i18next";
+import React, { useMemo } from "react";
+import { Alert, ScrollView, View } from "react-native";
+import { useMMKVString } from "react-native-mmkv";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+import type { SavedServer } from "@/utils/secureCredentials";
+import { TVAddIcon } from "./TVAddIcon";
+import { TVServerIcon } from "./TVServerIcon";
+
+interface TVServerSelectionScreenProps {
+ onServerSelect: (server: SavedServer) => void;
+ onAddServer: () => void;
+ onDeleteServer: (server: SavedServer) => void;
+ disabled?: boolean;
+}
+
+export const TVServerSelectionScreen: React.FC<
+ TVServerSelectionScreenProps
+> = ({ onServerSelect, onAddServer, onDeleteServer, disabled = false }) => {
+ const typography = useScaledTVTypography();
+ const [_previousServers] = useMMKVString("previousServers");
+
+ const previousServers = useMemo(() => {
+ try {
+ return JSON.parse(_previousServers || "[]") as SavedServer[];
+ } catch {
+ return [];
+ }
+ }, [_previousServers]);
+
+ const hasServers = previousServers.length > 0;
+
+ const handleDeleteServer = (server: SavedServer) => {
+ Alert.alert(
+ t("server.remove_server"),
+ t("server.remove_server_description", {
+ server: server.name || server.address,
+ }),
+ [
+ { text: t("common.cancel"), style: "cancel" },
+ {
+ text: t("common.delete"),
+ style: "destructive",
+ onPress: () => onDeleteServer(server),
+ },
+ ],
+ );
+ };
+
+ return (
+
+
+ {/* Logo */}
+
+
+
+
+ {/* Title */}
+
+ Streamyfin
+
+
+ {hasServers
+ ? t("server.select_your_server")
+ : t("server.add_server_to_get_started")}
+
+
+ {/* Server Icons Grid */}
+
+ {previousServers.map((server, index) => (
+ onServerSelect(server)}
+ onLongPress={() => handleDeleteServer(server)}
+ hasTVPreferredFocus={index === 0}
+ disabled={disabled}
+ />
+ ))}
+
+ {/* Add Server Button */}
+
+
+
+
+ );
+};
diff --git a/components/login/TVUserIcon.tsx b/components/login/TVUserIcon.tsx
new file mode 100644
index 000000000..60c8ec07f
--- /dev/null
+++ b/components/login/TVUserIcon.tsx
@@ -0,0 +1,167 @@
+import { Ionicons } from "@expo/vector-icons";
+import { Image } from "expo-image";
+import React, { useState } from "react";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { getUserImageUrl } from "@/utils/jellyfin/image/getUserImageUrl";
+import { scaleSize } from "@/utils/scaleSize";
+import type { AccountSecurityType } from "@/utils/secureCredentials";
+
+export interface TVUserIconProps {
+ username: string;
+ securityType: AccountSecurityType;
+ onPress: () => void;
+ hasTVPreferredFocus?: boolean;
+ disabled?: boolean;
+ serverAddress?: string;
+ userId?: string;
+ primaryImageTag?: string;
+}
+
+export const TVUserIcon = React.forwardRef(
+ (
+ {
+ username,
+ securityType,
+ onPress,
+ hasTVPreferredFocus,
+ disabled = false,
+ serverAddress,
+ userId,
+ primaryImageTag,
+ },
+ ref,
+ ) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation();
+ const [imageError, setImageError] = useState(false);
+
+ const getSecurityIcon = (): keyof typeof Ionicons.glyphMap => {
+ switch (securityType) {
+ case "pin":
+ return "keypad";
+ case "password":
+ return "lock-closed";
+ default:
+ return "key";
+ }
+ };
+
+ const hasSecurityProtection = securityType !== "none";
+
+ const imageUrl =
+ serverAddress && userId && primaryImageTag && !imageError
+ ? getUserImageUrl({
+ serverAddress,
+ userId,
+ primaryImageTag,
+ width: 280,
+ })
+ : null;
+
+ return (
+
+
+
+
+ {imageUrl ? (
+ setImageError(true)}
+ />
+ ) : (
+
+ )}
+
+
+ {/* Security badge */}
+ {hasSecurityProtection && (
+
+
+
+ )}
+
+
+
+ {username}
+
+
+
+ );
+ },
+);
diff --git a/components/login/TVUserSelectionScreen.tsx b/components/login/TVUserSelectionScreen.tsx
new file mode 100644
index 000000000..78fbd8927
--- /dev/null
+++ b/components/login/TVUserSelectionScreen.tsx
@@ -0,0 +1,143 @@
+import { t } from "i18next";
+import React, { useCallback } from "react";
+import { ScrollView, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { useTVBackPress } from "@/hooks/useTVBackPress";
+import { scaleSize } from "@/utils/scaleSize";
+import type {
+ SavedServer,
+ SavedServerAccount,
+} from "@/utils/secureCredentials";
+import { TVAddIcon } from "./TVAddIcon";
+import { TVBackIcon } from "./TVBackIcon";
+import { TVUserIcon } from "./TVUserIcon";
+
+interface TVUserSelectionScreenProps {
+ server: SavedServer;
+ onUserSelect: (account: SavedServerAccount) => void;
+ onAddUser: () => void;
+ onChangeServer: () => void;
+ disabled?: boolean;
+}
+
+export const TVUserSelectionScreen: React.FC = ({
+ server,
+ onUserSelect,
+ onAddUser,
+ onChangeServer,
+ disabled = false,
+}) => {
+ const typography = useScaledTVTypography();
+
+ const accounts = server.accounts || [];
+ const hasAccounts = accounts.length > 0;
+
+ const handleBackPress = useCallback(() => {
+ if (disabled) return false;
+ onChangeServer();
+ return true;
+ }, [disabled, onChangeServer]);
+
+ useTVBackPress(handleBackPress, [handleBackPress]);
+
+ return (
+
+
+ {/* Server Info Header */}
+
+
+ {server.name || server.address}
+
+ {server.name && (
+
+ {server.address.replace(/^https?:\/\//, "")}
+
+ )}
+
+ {hasAccounts
+ ? t("login.select_user")
+ : t("login.add_user_to_login")}
+
+
+
+ {/* User Icons Grid with Back and Add buttons */}
+
+ {/* Back/Change Server Button (left) */}
+
+
+ {/* User Icons */}
+ {accounts.map((account, index) => (
+ onUserSelect(account)}
+ hasTVPreferredFocus={index === 0}
+ disabled={disabled}
+ serverAddress={server.address}
+ userId={account.userId}
+ primaryImageTag={account.primaryImageTag}
+ />
+ ))}
+
+ {/* Add User Button (right) */}
+
+
+
+
+ );
+};
diff --git a/components/medialists/MediaListSection.tsx b/components/medialists/MediaListSection.tsx
index 2c11c5cee..0ada9dc58 100644
--- a/components/medialists/MediaListSection.tsx
+++ b/components/medialists/MediaListSection.tsx
@@ -9,7 +9,7 @@ import {
useQuery,
} from "@tanstack/react-query";
import { useAtom } from "jotai";
-import { useCallback } from "react";
+import { useCallback, useMemo } from "react";
import { View, type ViewProps } from "react-native";
import { useInView } from "@/hooks/useInView";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -67,6 +67,12 @@ export const MediaListSection: React.FC = ({
[api, user?.Id, collection?.Id],
);
+ const snapOffsets = useMemo(() => {
+ const itemWidth = 120; // w-28 (112px) + mr-2 (8px)
+ // Generate offsets for a reasonable number of items
+ return Array.from({ length: 50 }, (_, index) => index * itemWidth);
+ }, []);
+
if (!collection) return null;
return (
@@ -92,6 +98,8 @@ export const MediaListSection: React.FC = ({
)}
queryFn={fetchItems}
queryKey={["media-list", collection.Id!]}
+ snapToOffsets={snapOffsets}
+ decelerationRate='fast'
/>
);
diff --git a/components/music/AnimatedEqualizer.tsx b/components/music/AnimatedEqualizer.tsx
new file mode 100644
index 000000000..350929318
--- /dev/null
+++ b/components/music/AnimatedEqualizer.tsx
@@ -0,0 +1,98 @@
+import React, { useEffect } from "react";
+import { View } from "react-native";
+import Animated, {
+ Easing,
+ interpolate,
+ useAnimatedStyle,
+ useSharedValue,
+ withDelay,
+ withRepeat,
+ withTiming,
+} from "react-native-reanimated";
+
+interface Props {
+ color?: string;
+ barWidth?: number;
+ barCount?: number;
+ height?: number;
+ gap?: number;
+}
+
+const MIN_SCALE = 0.35;
+const MAX_SCALE = 1;
+const DURATIONS = [800, 650, 750];
+const DELAYS = [0, 200, 100];
+
+const Bar: React.FC<{
+ color: string;
+ barWidth: number;
+ height: number;
+ duration: number;
+ delay: number;
+}> = ({ color, barWidth, height, duration, delay }) => {
+ const progress = useSharedValue(0);
+
+ useEffect(() => {
+ progress.value = withDelay(
+ delay,
+ withRepeat(
+ withTiming(1, { duration, easing: Easing.inOut(Easing.ease) }),
+ -1,
+ true,
+ ),
+ );
+ }, []);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [
+ {
+ scaleY: interpolate(progress.value, [0, 1], [MIN_SCALE, MAX_SCALE]),
+ },
+ ],
+ }));
+
+ return (
+
+ );
+};
+
+export const AnimatedEqualizer: React.FC = ({
+ color = "#9334E9",
+ barWidth = 3,
+ barCount = 3,
+ height = 12,
+ gap = 2,
+}) => {
+ return (
+
+ {Array.from({ length: barCount }).map((_, index) => (
+
+ ))}
+
+ );
+};
diff --git a/components/music/CreatePlaylistModal.tsx b/components/music/CreatePlaylistModal.tsx
new file mode 100644
index 000000000..fea1421f7
--- /dev/null
+++ b/components/music/CreatePlaylistModal.tsx
@@ -0,0 +1,157 @@
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetTextInput,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { useTranslation } from "react-i18next";
+import { ActivityIndicator, Keyboard } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Button } from "@/components/Button";
+import { Text } from "@/components/common/Text";
+import { useCreatePlaylist } from "@/hooks/usePlaylistMutations";
+
+interface Props {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ onPlaylistCreated?: (playlistId: string) => void;
+ initialTrackId?: string;
+}
+
+export const CreatePlaylistModal: React.FC = ({
+ open,
+ setOpen,
+ onPlaylistCreated,
+ initialTrackId,
+}) => {
+ const bottomSheetModalRef = useRef(null);
+ const insets = useSafeAreaInsets();
+ const { t } = useTranslation();
+ const createPlaylist = useCreatePlaylist();
+
+ const [name, setName] = useState("");
+ const snapPoints = useMemo(() => ["40%"], []);
+
+ useEffect(() => {
+ if (open) {
+ setName("");
+ bottomSheetModalRef.current?.present();
+ } else {
+ bottomSheetModalRef.current?.dismiss();
+ }
+ }, [open]);
+
+ const handleSheetChanges = useCallback(
+ (index: number) => {
+ if (index === -1) {
+ setOpen(false);
+ Keyboard.dismiss();
+ }
+ },
+ [setOpen],
+ );
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const handleCreate = useCallback(async () => {
+ if (!name.trim()) return;
+
+ const result = await createPlaylist.mutateAsync({
+ name: name.trim(),
+ trackIds: initialTrackId ? [initialTrackId] : undefined,
+ });
+
+ if (result) {
+ onPlaylistCreated?.(result);
+ }
+ setOpen(false);
+ }, [name, createPlaylist, initialTrackId, onPlaylistCreated, setOpen]);
+
+ const isValid = name.trim().length > 0;
+
+ return (
+
+
+
+ {t("music.playlists.create_playlist")}
+
+
+
+ {t("music.playlists.playlist_name")}
+
+
+
+
+
+
+ );
+};
diff --git a/components/music/MiniPlayerBar.tsx b/components/music/MiniPlayerBar.tsx
new file mode 100644
index 000000000..c0ffd094d
--- /dev/null
+++ b/components/music/MiniPlayerBar.tsx
@@ -0,0 +1,359 @@
+import { Ionicons } from "@expo/vector-icons";
+import { Image } from "expo-image";
+import { useAtom } from "jotai";
+import React, { useCallback, useMemo } from "react";
+import {
+ ActivityIndicator,
+ Platform,
+ StyleSheet,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { Gesture, GestureDetector } from "react-native-gesture-handler";
+import { GlassEffectView } from "react-native-glass-effect-view";
+import Animated, {
+ Easing,
+ Extrapolation,
+ interpolate,
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
+
+const HORIZONTAL_MARGIN = Platform.OS === "android" ? 12 : 20;
+const BOTTOM_TAB_HEIGHT = Platform.OS === "android" ? 56 : 52;
+const BAR_HEIGHT = Platform.OS === "android" ? 58 : 50;
+
+// Gesture thresholds
+const VELOCITY_THRESHOLD = 1000;
+
+// Logarithmic slowdown - never stops, just gets progressively slower
+const rubberBand = (distance: number, scale: number = 8): number => {
+ "worklet";
+ const absDistance = Math.abs(distance);
+ const sign = distance < 0 ? -1 : 1;
+ // Logarithmic: keeps growing but slower and slower
+ return sign * scale * Math.log(1 + absDistance / scale);
+};
+
+export const MiniPlayerBar: React.FC = () => {
+ const [api] = useAtom(apiAtom);
+ const insets = useSafeAreaInsets();
+ const router = useRouter();
+ const {
+ currentTrack,
+ isPlaying,
+ isLoading,
+ progress,
+ duration,
+ togglePlayPause,
+ next,
+ stop,
+ } = useMusicPlayer();
+
+ // Gesture state
+ const translateY = useSharedValue(0);
+
+ const imageUrl = useMemo(() => {
+ if (!api || !currentTrack) return null;
+ const albumId = currentTrack.AlbumId || currentTrack.ParentId;
+ if (albumId) {
+ return `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=100&maxWidth=100`;
+ }
+ return `${api.basePath}/Items/${currentTrack.Id}/Images/Primary?maxHeight=100&maxWidth=100`;
+ }, [api, currentTrack]);
+
+ const _progressPercentage = useMemo(() => {
+ if (!duration || duration === 0) return 0;
+ return (progress / duration) * 100;
+ }, [progress, duration]);
+
+ const handlePress = useCallback(() => {
+ router.push("/(auth)/now-playing");
+ }, [router]);
+
+ const handlePlayPause = useCallback(
+ (e: any) => {
+ e.stopPropagation();
+ togglePlayPause();
+ },
+ [togglePlayPause],
+ );
+
+ const handleNext = useCallback(
+ (e: any) => {
+ e.stopPropagation();
+ next();
+ },
+ [next],
+ );
+
+ const handleDismiss = useCallback(() => {
+ stop();
+ }, [stop]);
+
+ // Pan gesture for swipe up (open modal) and swipe down (dismiss)
+ const panGesture = Gesture.Pan()
+ .activeOffsetY([-15, 15])
+ .onUpdate((event) => {
+ // Logarithmic slowdown - keeps moving but progressively slower
+ translateY.value = rubberBand(event.translationY, 6);
+ })
+ .onEnd((event) => {
+ const velocity = event.velocityY;
+ const currentPosition = translateY.value;
+
+ // Swipe up - open modal (check position OR velocity)
+ if (currentPosition < -16 || velocity < -VELOCITY_THRESHOLD) {
+ // Slow return animation - won't jank with navigation
+ translateY.value = withTiming(0, {
+ duration: 600,
+ easing: Easing.out(Easing.cubic),
+ });
+ runOnJS(handlePress)();
+ return;
+ }
+ // Swipe down - stop playback and dismiss (check position OR velocity)
+ if (currentPosition > 16 || velocity > VELOCITY_THRESHOLD) {
+ // No need to reset - component will unmount
+ runOnJS(handleDismiss)();
+ return;
+ }
+
+ // Only animate back if no action was triggered
+ translateY.value = withTiming(0, {
+ duration: 200,
+ easing: Easing.out(Easing.cubic),
+ });
+ });
+
+ // Animated styles for the container
+ const animatedContainerStyle = useAnimatedStyle(() => ({
+ transform: [{ translateY: translateY.value }],
+ }));
+
+ // Animated styles for the inner bar
+ const animatedBarStyle = useAnimatedStyle(() => ({
+ height: interpolate(
+ translateY.value,
+ [-50, 0, 50],
+ [BAR_HEIGHT + 12, BAR_HEIGHT, BAR_HEIGHT],
+ Extrapolation.EXTEND,
+ ),
+ opacity: interpolate(
+ translateY.value,
+ [0, 30],
+ [1, 0.6],
+ Extrapolation.CLAMP,
+ ),
+ }));
+
+ if (!currentTrack) return null;
+
+ const content = (
+ <>
+ {/* Tappable area: Album art + Track info */}
+
+ {/* Album art */}
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Track info */}
+
+
+ {currentTrack.Name}
+
+
+ {currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
+
+
+
+
+ {/* Controls */}
+
+ {isLoading ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+
+ {/* Progress bar at bottom */}
+ {/*
+
+ */}
+ >
+ );
+
+ return (
+
+
+
+ {Platform.OS === "ios" && !Platform.isTV ? (
+
+
+ {content}
+
+
+ ) : (
+ {content}
+ )}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ position: "absolute",
+ left: HORIZONTAL_MARGIN,
+ right: HORIZONTAL_MARGIN,
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.3,
+ shadowRadius: 8,
+ elevation: 8,
+ },
+ touchable: {
+ borderRadius: 50,
+ overflow: "hidden",
+ },
+ blurContainer: {
+ flex: 1,
+ },
+ androidContainer: {
+ flex: 1,
+ flexDirection: "row",
+ alignItems: "center",
+ paddingHorizontal: 10,
+ paddingVertical: 8,
+ backgroundColor: "rgba(28, 28, 30, 0.97)",
+ borderRadius: 14,
+ borderWidth: 0.5,
+ borderColor: "rgba(255, 255, 255, 0.1)",
+ },
+ tappableArea: {
+ flex: 1,
+ flexDirection: "row",
+ alignItems: "center",
+ },
+ albumArt: {
+ width: 32,
+ height: 32,
+ borderRadius: 8,
+ overflow: "hidden",
+ backgroundColor: "#333",
+ },
+ albumImage: {
+ width: "100%",
+ height: "100%",
+ },
+ albumPlaceholder: {
+ flex: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ backgroundColor: "#2a2a2a",
+ },
+ trackInfo: {
+ flex: 1,
+ marginLeft: 12,
+ marginRight: 8,
+ justifyContent: "center",
+ },
+ trackTitle: {
+ color: "white",
+ fontSize: 14,
+ fontWeight: "600",
+ },
+ artistName: {
+ color: "rgba(255, 255, 255, 0.6)",
+ fontSize: 12,
+ },
+ controls: {
+ flexDirection: "row",
+ alignItems: "center",
+ },
+ controlButton: {
+ padding: 8,
+ },
+ loader: {
+ marginHorizontal: 16,
+ },
+ progressContainer: {
+ position: "absolute",
+ bottom: 0,
+ left: 10,
+ right: 10,
+ height: 3,
+ backgroundColor: "rgba(255, 255, 255, 0.15)",
+ borderRadius: 1.5,
+ },
+ progressFill: {
+ height: "100%",
+ backgroundColor: "white",
+ borderRadius: 1.5,
+ },
+});
diff --git a/components/music/MusicAlbumCard.tsx b/components/music/MusicAlbumCard.tsx
new file mode 100644
index 000000000..1a747c8ec
--- /dev/null
+++ b/components/music/MusicAlbumCard.tsx
@@ -0,0 +1,68 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { Image } from "expo-image";
+import { useAtom } from "jotai";
+import React, { useCallback, useMemo } from "react";
+import { TouchableOpacity, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+
+interface Props {
+ album: BaseItemDto;
+ width?: number;
+}
+
+export const MusicAlbumCard: React.FC = ({ album, width = 130 }) => {
+ const [api] = useAtom(apiAtom);
+ const router = useRouter();
+
+ const imageUrl = useMemo(
+ () => getPrimaryImageUrl({ api, item: album }),
+ [api, album],
+ );
+
+ const handlePress = useCallback(() => {
+ router.push({
+ pathname: "/music/album/[albumId]",
+ params: { albumId: album.Id! },
+ });
+ }, [router, album.Id]);
+
+ return (
+
+
+ {imageUrl ? (
+
+ ) : (
+
+ 🎵
+
+ )}
+
+
+ {album.Name}
+
+
+ {album.AlbumArtist || album.Artists?.join(", ")}
+
+
+ );
+};
diff --git a/components/music/MusicAlbumRowCard.tsx b/components/music/MusicAlbumRowCard.tsx
new file mode 100644
index 000000000..6612a3922
--- /dev/null
+++ b/components/music/MusicAlbumRowCard.tsx
@@ -0,0 +1,70 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { Image } from "expo-image";
+import { useAtom } from "jotai";
+import React, { useCallback, useMemo } from "react";
+import { TouchableOpacity, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+
+interface Props {
+ album: BaseItemDto;
+}
+
+const IMAGE_SIZE = 56;
+
+export const MusicAlbumRowCard: React.FC = ({ album }) => {
+ const [api] = useAtom(apiAtom);
+ const router = useRouter();
+
+ const imageUrl = useMemo(
+ () => getPrimaryImageUrl({ api, item: album }),
+ [api, album],
+ );
+
+ const handlePress = useCallback(() => {
+ router.push({
+ pathname: "/music/album/[albumId]",
+ params: { albumId: album.Id! },
+ });
+ }, [router, album.Id]);
+
+ return (
+
+
+ {imageUrl ? (
+
+ ) : (
+
+ 🎵
+
+ )}
+
+
+
+ {album.Name}
+
+
+ {album.AlbumArtist || album.Artists?.join(", ")}
+
+
+
+ );
+};
diff --git a/components/music/MusicArtistCard.tsx b/components/music/MusicArtistCard.tsx
new file mode 100644
index 000000000..c98e3fce9
--- /dev/null
+++ b/components/music/MusicArtistCard.tsx
@@ -0,0 +1,69 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { Image } from "expo-image";
+import { useAtom } from "jotai";
+import React, { useCallback, useMemo } from "react";
+import { TouchableOpacity, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+
+interface Props {
+ artist: BaseItemDto;
+ size?: number;
+}
+
+const IMAGE_SIZE = 48;
+
+export const MusicArtistCard: React.FC = ({ artist }) => {
+ const [api] = useAtom(apiAtom);
+ const router = useRouter();
+
+ const imageUrl = useMemo(
+ () => getPrimaryImageUrl({ api, item: artist }),
+ [api, artist],
+ );
+
+ const handlePress = useCallback(() => {
+ router.push({
+ pathname: "/music/artist/[artistId]",
+ params: { artistId: artist.Id! },
+ });
+ }, [router, artist.Id]);
+
+ return (
+
+
+ {imageUrl ? (
+
+ ) : (
+
+ 👤
+
+ )}
+
+
+ {artist.Name}
+
+
+ );
+};
diff --git a/components/music/MusicPlaybackEngine.tsx b/components/music/MusicPlaybackEngine.tsx
new file mode 100644
index 000000000..ae1b07cdf
--- /dev/null
+++ b/components/music/MusicPlaybackEngine.tsx
@@ -0,0 +1,222 @@
+import { useEffect, useRef } from "react";
+import TrackPlayer, {
+ Event,
+ type PlaybackActiveTrackChangedEvent,
+ State,
+ useActiveTrack,
+ usePlaybackState,
+ useProgress,
+} from "react-native-track-player";
+import {
+ audioStorageEvents,
+ deleteTrack,
+ getLocalPath,
+} from "@/providers/AudioStorage";
+import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
+
+export const MusicPlaybackEngine: React.FC = () => {
+ const { position, duration } = useProgress(1000);
+ const playbackState = usePlaybackState();
+ const activeTrack = useActiveTrack();
+ const {
+ setProgress,
+ setDuration,
+ setIsPlaying,
+ reportProgress,
+ onTrackEnd,
+ syncFromTrackPlayer,
+ triggerLookahead,
+ } = useMusicPlayer();
+
+ const lastReportedProgressRef = useRef(0);
+
+ // Sync progress from TrackPlayer to our state
+ useEffect(() => {
+ if (position > 0) {
+ setProgress(position);
+ }
+ }, [position, setProgress]);
+
+ // Sync duration from TrackPlayer to our state
+ useEffect(() => {
+ if (duration > 0) {
+ setDuration(duration);
+ }
+ }, [duration, setDuration]);
+
+ // Sync playback state from TrackPlayer to our state
+ useEffect(() => {
+ const isPlaying = playbackState.state === State.Playing;
+ setIsPlaying(isPlaying);
+ }, [playbackState.state, setIsPlaying]);
+
+ // Sync active track changes
+ useEffect(() => {
+ if (activeTrack) {
+ syncFromTrackPlayer();
+ }
+ }, [activeTrack?.id, syncFromTrackPlayer]);
+
+ // Report progress every ~10 seconds
+ useEffect(() => {
+ if (
+ Math.floor(position) - Math.floor(lastReportedProgressRef.current) >=
+ 10
+ ) {
+ lastReportedProgressRef.current = position;
+ reportProgress();
+ }
+ }, [position, reportProgress]);
+
+ // Listen for track changes (native -> JS)
+ // This triggers look-ahead caching, checks for cached versions, and handles track end
+ useEffect(() => {
+ const subscription =
+ TrackPlayer.addEventListener(
+ Event.PlaybackActiveTrackChanged,
+ async (event) => {
+ // Trigger look-ahead caching when a new track starts playing
+ if (event.track) {
+ triggerLookahead();
+
+ // Check if there's a cached version we should use instead
+ const trackId = event.track.id;
+ const currentUrl = event.track.url as string;
+
+ // Only check if currently using a remote URL
+ if (trackId && currentUrl && !currentUrl.startsWith("file://")) {
+ const cachedPath = getLocalPath(trackId);
+ if (cachedPath) {
+ console.log(
+ `[AudioCache] Switching to cached version for ${trackId}`,
+ );
+ try {
+ // Load the cached version, preserving position if any
+ const currentIndex = await TrackPlayer.getActiveTrackIndex();
+ if (currentIndex !== undefined && currentIndex >= 0) {
+ const queue = await TrackPlayer.getQueue();
+ const track = queue[currentIndex];
+ // Remove and re-add with cached URL
+ await TrackPlayer.remove(currentIndex);
+ await TrackPlayer.add(
+ { ...track, url: cachedPath },
+ currentIndex,
+ );
+ await TrackPlayer.skip(currentIndex);
+ await TrackPlayer.play();
+ }
+ } catch (error) {
+ console.warn(
+ "[AudioCache] Failed to switch to cached version:",
+ error,
+ );
+ }
+ }
+ }
+ }
+
+ // If there's no next track and the previous track ended, call onTrackEnd
+ if (event.lastTrack && !event.track) {
+ onTrackEnd();
+ }
+ },
+ );
+
+ return () => subscription.remove();
+ }, [onTrackEnd, triggerLookahead]);
+
+ // Listen for audio cache download completion and update queue URLs
+ useEffect(() => {
+ const onComplete = async ({
+ itemId,
+ localPath,
+ }: {
+ itemId: string;
+ localPath: string;
+ }) => {
+ console.log(`[AudioCache] Track ${itemId} cached successfully`);
+
+ try {
+ const queue = await TrackPlayer.getQueue();
+ const currentIndex = await TrackPlayer.getActiveTrackIndex();
+
+ // Find the track in the queue
+ const trackIndex = queue.findIndex((t) => t.id === itemId);
+
+ // Only update if track is in queue and not currently playing
+ if (trackIndex >= 0 && trackIndex !== currentIndex) {
+ const track = queue[trackIndex];
+ const localUrl = localPath.startsWith("file://")
+ ? localPath
+ : `file://${localPath}`;
+
+ // Skip if already using local URL
+ if (track.url === localUrl) return;
+
+ console.log(
+ `[AudioCache] Updating queue track ${trackIndex} to use cached file`,
+ );
+
+ // Remove old track and insert updated one at same position
+ await TrackPlayer.remove(trackIndex);
+ await TrackPlayer.add({ ...track, url: localUrl }, trackIndex);
+ }
+ } catch (error) {
+ console.warn("[AudioCache] Failed to update queue:", error);
+ }
+ };
+
+ audioStorageEvents.on("complete", onComplete);
+ return () => {
+ audioStorageEvents.off("complete", onComplete);
+ };
+ }, []);
+
+ // Listen for playback errors (corrupted cache files)
+ useEffect(() => {
+ const subscription = TrackPlayer.addEventListener(
+ Event.PlaybackError,
+ async (event) => {
+ const activeTrack = await TrackPlayer.getActiveTrack();
+ if (!activeTrack?.url) return;
+
+ // Only handle local file errors
+ const url = activeTrack.url as string;
+ if (!url.startsWith("file://")) return;
+
+ console.warn(
+ `[MusicPlayer] Playback error for cached file: ${activeTrack.id}`,
+ event,
+ );
+
+ // Delete corrupted cache file
+ if (activeTrack.id) {
+ try {
+ await deleteTrack(activeTrack.id);
+ console.log(
+ `[MusicPlayer] Deleted corrupted cache file: ${activeTrack.id}`,
+ );
+ } catch (error) {
+ console.warn(
+ "[MusicPlayer] Failed to delete corrupted file:",
+ error,
+ );
+ }
+ }
+
+ // Skip to next track
+ try {
+ await TrackPlayer.skipToNext();
+ } catch {
+ // No next track available, stop playback
+ await TrackPlayer.stop();
+ }
+ },
+ );
+
+ return () => subscription.remove();
+ }, []);
+
+ // No visual component needed - TrackPlayer is headless
+ return null;
+};
diff --git a/components/music/MusicPlaylistCard.tsx b/components/music/MusicPlaylistCard.tsx
new file mode 100644
index 000000000..93caf7a44
--- /dev/null
+++ b/components/music/MusicPlaylistCard.tsx
@@ -0,0 +1,120 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import { Image } from "expo-image";
+import { useAtom } from "jotai";
+import React, { useCallback, useMemo } from "react";
+import { TouchableOpacity, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import { getLocalPath } from "@/providers/AudioStorage";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+
+interface Props {
+ playlist: BaseItemDto;
+ width?: number;
+}
+
+const IMAGE_SIZE = 56;
+
+export const MusicPlaylistCard: React.FC = ({ playlist }) => {
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const router = useRouter();
+
+ const imageUrl = useMemo(
+ () => getPrimaryImageUrl({ api, item: playlist }),
+ [api, playlist],
+ );
+
+ // Fetch playlist tracks to check download status
+ const { data: tracks } = useQuery({
+ queryKey: ["playlist-tracks-status", playlist.Id, user?.Id],
+ queryFn: async () => {
+ const response = await getItemsApi(api!).getItems({
+ userId: user?.Id,
+ parentId: playlist.Id,
+ fields: ["MediaSources"],
+ });
+ return response.data.Items || [];
+ },
+ enabled: !!api && !!user?.Id && !!playlist.Id,
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ });
+
+ // Calculate download status
+ const downloadStatus = useMemo(() => {
+ if (!tracks || tracks.length === 0) {
+ return { downloaded: 0, total: playlist.ChildCount || 0 };
+ }
+ const downloaded = tracks.filter(
+ (track) => !!getLocalPath(track.Id),
+ ).length;
+ return { downloaded, total: tracks.length };
+ }, [tracks, playlist.ChildCount]);
+
+ const allDownloaded =
+ downloadStatus.total > 0 &&
+ downloadStatus.downloaded === downloadStatus.total;
+ const hasDownloads = downloadStatus.downloaded > 0;
+
+ const handlePress = useCallback(() => {
+ router.push({
+ pathname: "/music/playlist/[playlistId]",
+ params: { playlistId: playlist.Id! },
+ });
+ }, [router, playlist.Id]);
+
+ return (
+
+
+ {imageUrl ? (
+
+ ) : (
+
+ 🎶
+
+ )}
+
+
+
+ {playlist.Name}
+
+
+ {playlist.ChildCount} tracks
+
+
+ {/* Download status indicator */}
+ {allDownloaded ? (
+
+ ) : hasDownloads ? (
+
+ {downloadStatus.downloaded}/{downloadStatus.total}
+
+ ) : null}
+
+ );
+};
diff --git a/components/music/MusicTrackItem.tsx b/components/music/MusicTrackItem.tsx
new file mode 100644
index 000000000..a3f80efce
--- /dev/null
+++ b/components/music/MusicTrackItem.tsx
@@ -0,0 +1,226 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { Image } from "expo-image";
+import { useAtom } from "jotai";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+import { ActivityIndicator, TouchableOpacity, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { AnimatedEqualizer } from "@/components/music/AnimatedEqualizer";
+import { useHaptic } from "@/hooks/useHaptic";
+import { useNetworkStatus } from "@/hooks/useNetworkStatus";
+import {
+ audioStorageEvents,
+ getLocalPath,
+ isCached,
+ isPermanentDownloading,
+ isPermanentlyDownloaded,
+} from "@/providers/AudioStorage";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+import { formatDuration } from "@/utils/time";
+
+interface Props {
+ track: BaseItemDto;
+ index?: number;
+ queue?: BaseItemDto[];
+ showArtwork?: boolean;
+ onOptionsPress?: (track: BaseItemDto) => void;
+}
+
+export const MusicTrackItem: React.FC = ({
+ track,
+ index: _index,
+ queue,
+ showArtwork = true,
+ onOptionsPress,
+}) => {
+ const [api] = useAtom(apiAtom);
+ const { playTrack, currentTrack, isPlaying, loadingTrackId } =
+ useMusicPlayer();
+ const { isConnected, serverConnected } = useNetworkStatus();
+ const haptic = useHaptic("light");
+
+ const imageUrl = useMemo(() => {
+ const albumId = track.AlbumId || track.ParentId;
+ if (albumId) {
+ return `${api?.basePath}/Items/${albumId}/Images/Primary?maxHeight=100&maxWidth=100`;
+ }
+ return getPrimaryImageUrl({ api, item: track });
+ }, [api, track]);
+
+ const isCurrentTrack = currentTrack?.Id === track.Id;
+ const isTrackLoading = loadingTrackId === track.Id;
+
+ // Track download status with reactivity to completion events
+ const [downloadStatus, setDownloadStatus] = useState<
+ "none" | "downloading" | "downloaded" | "cached"
+ >(() => {
+ if (isPermanentlyDownloaded(track.Id)) return "downloaded";
+ if (isPermanentDownloading(track.Id)) return "downloading";
+ if (isCached(track.Id)) return "cached";
+ return "none";
+ });
+
+ // Listen for download completion/error events
+ useEffect(() => {
+ const onComplete = (event: { itemId: string; permanent: boolean }) => {
+ if (event.itemId === track.Id) {
+ setDownloadStatus(event.permanent ? "downloaded" : "cached");
+ }
+ };
+ const onError = (event: { itemId: string }) => {
+ if (event.itemId === track.Id) {
+ setDownloadStatus("none");
+ }
+ };
+
+ audioStorageEvents.on("complete", onComplete);
+ audioStorageEvents.on("error", onError);
+
+ return () => {
+ audioStorageEvents.off("complete", onComplete);
+ audioStorageEvents.off("error", onError);
+ };
+ }, [track.Id]);
+
+ // Re-check status when track changes (for list item recycling)
+ useEffect(() => {
+ if (isPermanentlyDownloaded(track.Id)) {
+ setDownloadStatus("downloaded");
+ } else if (isPermanentDownloading(track.Id)) {
+ setDownloadStatus("downloading");
+ } else if (isCached(track.Id)) {
+ setDownloadStatus("cached");
+ } else {
+ setDownloadStatus("none");
+ }
+ }, [track.Id]);
+
+ const _isDownloaded = downloadStatus === "downloaded";
+ // Check if available locally (either cached or permanently downloaded)
+ const isAvailableLocally = !!getLocalPath(track.Id);
+ // Consider offline if either no network connection OR server is unreachable
+ const isOffline = !isConnected || serverConnected === false;
+ const isUnavailableOffline = isOffline && !isAvailableLocally;
+
+ const duration = useMemo(() => {
+ if (!track.RunTimeTicks) return "";
+ return formatDuration(track.RunTimeTicks);
+ }, [track.RunTimeTicks]);
+
+ const handlePress = useCallback(() => {
+ if (isUnavailableOffline) return;
+ playTrack(track, queue);
+ }, [playTrack, track, queue, isUnavailableOffline]);
+
+ const handleLongPress = useCallback(() => {
+ onOptionsPress?.(track);
+ }, [onOptionsPress, track]);
+
+ const handleOptionsPress = useCallback(() => {
+ haptic();
+ onOptionsPress?.(track);
+ }, [haptic, onOptionsPress, track]);
+
+ return (
+
+ {/* Album artwork */}
+ {showArtwork && (
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+ {isTrackLoading && (
+
+
+
+ )}
+
+ )}
+
+ {/* Track info */}
+
+
+ {isCurrentTrack && isPlaying && }
+
+ {track.Name}
+
+
+
+ {track.Artists?.join(", ") || track.AlbumArtist}
+
+
+
+ {/* Download/cache status indicator */}
+ {downloadStatus === "downloading" && (
+
+ )}
+ {downloadStatus === "downloaded" && (
+
+ )}
+
+ {/* Duration */}
+ {duration}
+
+ {/* Options button */}
+ {onOptionsPress && (
+
+
+
+ )}
+
+ );
+};
diff --git a/components/music/PlaylistOptionsSheet.tsx b/components/music/PlaylistOptionsSheet.tsx
new file mode 100644
index 000000000..ab2b3a24d
--- /dev/null
+++ b/components/music/PlaylistOptionsSheet.tsx
@@ -0,0 +1,136 @@
+import { Ionicons } from "@expo/vector-icons";
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import React, { useCallback, useEffect, useMemo, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { Alert, StyleSheet, TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import { useDeletePlaylist } from "@/hooks/usePlaylistMutations";
+
+interface Props {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ playlist: BaseItemDto | null;
+}
+
+export const PlaylistOptionsSheet: React.FC = ({
+ open,
+ setOpen,
+ playlist,
+}) => {
+ const bottomSheetModalRef = useRef(null);
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const { t } = useTranslation();
+ const deletePlaylist = useDeletePlaylist();
+
+ const snapPoints = useMemo(() => ["25%"], []);
+
+ useEffect(() => {
+ if (open) bottomSheetModalRef.current?.present();
+ else bottomSheetModalRef.current?.dismiss();
+ }, [open]);
+
+ const handleSheetChanges = useCallback(
+ (index: number) => {
+ if (index === -1) {
+ setOpen(false);
+ }
+ },
+ [setOpen],
+ );
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const handleDeletePlaylist = useCallback(() => {
+ if (!playlist?.Id) return;
+
+ Alert.alert(
+ t("music.playlists.delete_playlist"),
+ t("music.playlists.delete_confirm", { name: playlist.Name }),
+ [
+ {
+ text: t("common.cancel"),
+ style: "cancel",
+ },
+ {
+ text: t("common.delete"),
+ style: "destructive",
+ onPress: () => {
+ deletePlaylist.mutate(
+ { playlistId: playlist.Id! },
+ {
+ onSuccess: () => {
+ setOpen(false);
+ router.back();
+ },
+ },
+ );
+ },
+ },
+ ],
+ );
+ }, [playlist, deletePlaylist, setOpen, router, t]);
+
+ if (!playlist) return null;
+
+ return (
+
+
+
+
+
+
+ {t("music.playlists.delete_playlist")}
+
+
+
+
+
+ );
+};
+
+const _styles = StyleSheet.create({
+ separator: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: "#404040",
+ },
+});
diff --git a/components/music/PlaylistPickerSheet.tsx b/components/music/PlaylistPickerSheet.tsx
new file mode 100644
index 000000000..6cd7dc1b9
--- /dev/null
+++ b/components/music/PlaylistPickerSheet.tsx
@@ -0,0 +1,262 @@
+import { Ionicons } from "@expo/vector-icons";
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetScrollView,
+} from "@gorhom/bottom-sheet";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import { Image } from "expo-image";
+import { useAtom } from "jotai";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { useTranslation } from "react-i18next";
+import {
+ ActivityIndicator,
+ StyleSheet,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Input } from "@/components/common/Input";
+import { Text } from "@/components/common/Text";
+import { useAddToPlaylist } from "@/hooks/usePlaylistMutations";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+
+interface Props {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ trackToAdd: BaseItemDto | null;
+ onCreateNew: () => void;
+}
+
+export const PlaylistPickerSheet: React.FC = ({
+ open,
+ setOpen,
+ trackToAdd,
+ onCreateNew,
+}) => {
+ const bottomSheetModalRef = useRef(null);
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const insets = useSafeAreaInsets();
+ const { t } = useTranslation();
+ const addToPlaylist = useAddToPlaylist();
+
+ const [search, setSearch] = useState("");
+ const snapPoints = useMemo(() => ["75%"], []);
+
+ // Fetch all playlists
+ const { data: playlists, isLoading } = useQuery({
+ queryKey: ["music-playlists-picker", user?.Id],
+ queryFn: async () => {
+ if (!api || !user?.Id) return [];
+
+ const response = await getItemsApi(api).getItems({
+ userId: user.Id,
+ includeItemTypes: ["Playlist"],
+ sortBy: ["SortName"],
+ sortOrder: ["Ascending"],
+ recursive: true,
+ mediaTypes: ["Audio"],
+ });
+
+ return response.data.Items || [];
+ },
+ enabled: Boolean(api && user?.Id && open),
+ });
+
+ const filteredPlaylists = useMemo(() => {
+ if (!playlists) return [];
+ if (!search) return playlists;
+ return playlists.filter((playlist) =>
+ playlist.Name?.toLowerCase().includes(search.toLowerCase()),
+ );
+ }, [playlists, search]);
+
+ const showSearch = (playlists?.length || 0) > 10;
+
+ useEffect(() => {
+ if (open) {
+ setSearch("");
+ bottomSheetModalRef.current?.present();
+ } else {
+ bottomSheetModalRef.current?.dismiss();
+ }
+ }, [open]);
+
+ const handleSheetChanges = useCallback(
+ (index: number) => {
+ if (index === -1) {
+ setOpen(false);
+ }
+ },
+ [setOpen],
+ );
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const handleSelectPlaylist = useCallback(
+ async (playlist: BaseItemDto) => {
+ if (!trackToAdd?.Id || !playlist.Id) return;
+
+ await addToPlaylist.mutateAsync({
+ playlistId: playlist.Id,
+ trackIds: [trackToAdd.Id],
+ playlistName: playlist.Name || undefined,
+ });
+
+ setOpen(false);
+ },
+ [trackToAdd, addToPlaylist, setOpen],
+ );
+
+ const handleCreateNew = useCallback(() => {
+ setOpen(false);
+ setTimeout(() => {
+ onCreateNew();
+ }, 300);
+ }, [onCreateNew, setOpen]);
+
+ const getPlaylistImageUrl = useCallback(
+ (playlist: BaseItemDto) => {
+ if (!api) return null;
+ return `${api.basePath}/Items/${playlist.Id}/Images/Primary?maxHeight=100&maxWidth=100`;
+ },
+ [api],
+ );
+
+ return (
+
+
+
+ {t("music.track_options.add_to_playlist")}
+
+ {trackToAdd?.Name}
+
+ {showSearch && (
+
+ )}
+
+ {/* Create New Playlist Button */}
+
+
+
+
+
+ {t("music.playlists.create_new")}
+
+
+
+ {isLoading ? (
+
+
+
+ ) : filteredPlaylists.length === 0 ? (
+
+
+ {search ? t("search.no_results") : t("music.no_playlists")}
+
+
+ ) : (
+
+ {filteredPlaylists.map((playlist, index) => (
+
+ handleSelectPlaylist(playlist)}
+ className='flex-row items-center px-4 py-3'
+ disabled={addToPlaylist.isPending}
+ >
+
+
+
+
+
+ {playlist.Name}
+
+
+ {playlist.ChildCount} {t("music.tabs.tracks")}
+
+
+ {addToPlaylist.isPending && (
+
+ )}
+
+ {index < filteredPlaylists.length - 1 && (
+
+ )}
+
+ ))}
+
+ )}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ separator: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: "#404040",
+ },
+});
diff --git a/components/music/PlaylistSortSheet.tsx b/components/music/PlaylistSortSheet.tsx
new file mode 100644
index 000000000..07c8467cf
--- /dev/null
+++ b/components/music/PlaylistSortSheet.tsx
@@ -0,0 +1,173 @@
+import { Ionicons } from "@expo/vector-icons";
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import React, { useCallback, useEffect, useMemo, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { StyleSheet, TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+
+export type PlaylistSortOption = "SortName" | "DateCreated";
+
+export type PlaylistSortOrder = "Ascending" | "Descending";
+
+interface Props {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ sortBy: PlaylistSortOption;
+ sortOrder: PlaylistSortOrder;
+ onSortChange: (
+ sortBy: PlaylistSortOption,
+ sortOrder: PlaylistSortOrder,
+ ) => void;
+}
+
+const SORT_OPTIONS: { key: PlaylistSortOption; label: string; icon: string }[] =
+ [
+ { key: "SortName", label: "music.sort.alphabetical", icon: "text-outline" },
+ {
+ key: "DateCreated",
+ label: "music.sort.date_created",
+ icon: "time-outline",
+ },
+ ];
+
+export const PlaylistSortSheet: React.FC = ({
+ open,
+ setOpen,
+ sortBy,
+ sortOrder,
+ onSortChange,
+}) => {
+ const bottomSheetModalRef = useRef(null);
+ const insets = useSafeAreaInsets();
+ const { t } = useTranslation();
+
+ const snapPoints = useMemo(() => ["40%"], []);
+
+ useEffect(() => {
+ if (open) bottomSheetModalRef.current?.present();
+ else bottomSheetModalRef.current?.dismiss();
+ }, [open]);
+
+ const handleSheetChanges = useCallback(
+ (index: number) => {
+ if (index === -1) {
+ setOpen(false);
+ }
+ },
+ [setOpen],
+ );
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const handleSortSelect = useCallback(
+ (option: PlaylistSortOption) => {
+ // If selecting same option, toggle order; otherwise use sensible default
+ if (option === sortBy) {
+ onSortChange(
+ option,
+ sortOrder === "Ascending" ? "Descending" : "Ascending",
+ );
+ } else {
+ // Default order based on sort type
+ const defaultOrder: PlaylistSortOrder =
+ option === "SortName" ? "Ascending" : "Descending";
+ onSortChange(option, defaultOrder);
+ }
+ setOpen(false);
+ },
+ [sortBy, sortOrder, onSortChange, setOpen],
+ );
+
+ return (
+
+
+
+ {t("music.sort.title")}
+
+
+ {SORT_OPTIONS.map((option, index) => {
+ const isSelected = sortBy === option.key;
+ return (
+
+ {index > 0 && }
+ handleSortSelect(option.key)}
+ className='flex-row items-center px-4 py-3.5'
+ >
+
+
+ {t(option.label)}
+
+ {isSelected && (
+
+
+
+
+ )}
+
+
+ );
+ })}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ separator: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: "#404040",
+ },
+});
diff --git a/components/music/TrackOptionsSheet.tsx b/components/music/TrackOptionsSheet.tsx
new file mode 100644
index 000000000..a0b78472c
--- /dev/null
+++ b/components/music/TrackOptionsSheet.tsx
@@ -0,0 +1,464 @@
+import { Ionicons } from "@expo/vector-icons";
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import type { BaseItemDto } 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 { useTranslation } from "react-i18next";
+import {
+ ActivityIndicator,
+ StyleSheet,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import { useFavorite } from "@/hooks/useFavorite";
+import {
+ audioStorageEvents,
+ deleteTrack,
+ downloadTrack,
+ isCached,
+ isPermanentDownloading,
+ 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";
+
+interface Props {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ track: BaseItemDto | null;
+ onAddToPlaylist: () => void;
+ playlistId?: string;
+ onRemoveFromPlaylist?: () => void;
+}
+
+export const TrackOptionsSheet: React.FC = ({
+ open,
+ setOpen,
+ track,
+ onAddToPlaylist,
+ playlistId,
+ onRemoveFromPlaylist,
+}) => {
+ const bottomSheetModalRef = useRef(null);
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const router = useRouter();
+ const { playNext, addToQueue } = useMusicPlayer();
+ const insets = useSafeAreaInsets();
+ const { t } = useTranslation();
+ const [isDownloadingTrack, setIsDownloadingTrack] = useState(false);
+ // Counter to trigger re-evaluation of download status when storage changes
+ const [storageUpdateCounter, setStorageUpdateCounter] = useState(0);
+
+ // Listen for storage events to update download status
+ useEffect(() => {
+ const handleComplete = (event: { itemId: string }) => {
+ if (event.itemId === track?.Id) {
+ setStorageUpdateCounter((c) => c + 1);
+ }
+ };
+
+ audioStorageEvents.on("complete", handleComplete);
+ return () => {
+ audioStorageEvents.off("complete", handleComplete);
+ };
+ }, [track?.Id]);
+
+ // Force re-evaluation of cache status when track changes
+ useEffect(() => {
+ setStorageUpdateCounter((c) => c + 1);
+ }, [track?.Id]);
+
+ // Use a placeholder item for useFavorite when track is null
+ const { isFavorite, toggleFavorite } = useFavorite(
+ track ?? ({ Id: "", UserData: { IsFavorite: false } } as BaseItemDto),
+ );
+
+ // Check download status (storageUpdateCounter triggers re-evaluation when download completes)
+ const isAlreadyDownloaded = useMemo(
+ () => isPermanentlyDownloaded(track?.Id),
+ [track?.Id, storageUpdateCounter],
+ );
+ const isOnlyCached = useMemo(
+ () => isCached(track?.Id),
+ [track?.Id, storageUpdateCounter],
+ );
+ const isCurrentlyDownloading = useMemo(
+ () => isPermanentDownloading(track?.Id),
+ [track?.Id, storageUpdateCounter],
+ );
+
+ const imageUrl = useMemo(() => {
+ if (!track) return null;
+ const albumId = track.AlbumId || track.ParentId;
+ if (albumId) {
+ return `${api?.basePath}/Items/${albumId}/Images/Primary?maxHeight=200&maxWidth=200`;
+ }
+ return getPrimaryImageUrl({ api, item: track });
+ }, [api, track]);
+
+ useEffect(() => {
+ if (open) bottomSheetModalRef.current?.present();
+ else bottomSheetModalRef.current?.dismiss();
+ }, [open]);
+
+ const handleSheetChanges = useCallback(
+ (index: number) => {
+ if (index === -1) {
+ setOpen(false);
+ }
+ },
+ [setOpen],
+ );
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const handlePlayNext = useCallback(() => {
+ if (track) {
+ playNext(track);
+ setOpen(false);
+ }
+ }, [track, playNext, setOpen]);
+
+ const handleAddToQueue = useCallback(() => {
+ if (track) {
+ addToQueue(track);
+ setOpen(false);
+ }
+ }, [track, addToQueue, setOpen]);
+
+ const handleAddToPlaylist = useCallback(() => {
+ setOpen(false);
+ setTimeout(() => {
+ onAddToPlaylist();
+ }, 300);
+ }, [onAddToPlaylist, setOpen]);
+
+ const handleRemoveFromPlaylist = useCallback(() => {
+ if (onRemoveFromPlaylist) {
+ onRemoveFromPlaylist();
+ setOpen(false);
+ }
+ }, [onRemoveFromPlaylist, setOpen]);
+
+ const handleDownload = useCallback(async () => {
+ if (!track?.Id || !api || !user?.Id || isAlreadyDownloaded) return;
+
+ setIsDownloadingTrack(true);
+ try {
+ 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
+ }
+ setIsDownloadingTrack(false);
+ setOpen(false);
+ }, [track?.Id, api, user?.Id, isAlreadyDownloaded, setOpen]);
+
+ const handleDelete = useCallback(async () => {
+ if (!track?.Id) return;
+ await deleteTrack(track.Id);
+ setStorageUpdateCounter((c) => c + 1);
+ setOpen(false);
+ }, [track?.Id, setOpen]);
+
+ const handleGoToArtist = useCallback(() => {
+ const artistId = track?.ArtistItems?.[0]?.Id;
+ if (artistId) {
+ setOpen(false);
+ router.push({
+ pathname: "/music/artist/[artistId]",
+ params: { artistId },
+ });
+ }
+ }, [track?.ArtistItems, router, setOpen]);
+
+ const handleGoToAlbum = useCallback(() => {
+ const albumId = track?.AlbumId || track?.ParentId;
+ if (albumId) {
+ setOpen(false);
+ router.push({
+ pathname: "/music/album/[albumId]",
+ params: { albumId },
+ });
+ }
+ }, [track?.AlbumId, track?.ParentId, router, setOpen]);
+
+ const handleToggleFavorite = useCallback(() => {
+ if (track) {
+ toggleFavorite();
+ setOpen(false);
+ }
+ }, [track, toggleFavorite, setOpen]);
+
+ // Check if navigation options are available
+ const hasArtist = !!track?.ArtistItems?.[0]?.Id;
+ const hasAlbum = !!(track?.AlbumId || track?.ParentId);
+
+ if (!track) return null;
+
+ return (
+
+
+ {/* Track Info Header */}
+
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {track.Name}
+
+
+ {track.Artists?.join(", ") || track.AlbumArtist}
+
+
+
+
+ {/* Playback Options */}
+
+
+
+
+ {t("music.track_options.play_next")}
+
+
+
+
+
+
+
+
+ {t("music.track_options.add_to_queue")}
+
+
+
+
+ {/* Library Options */}
+
+
+
+
+ {isFavorite
+ ? t("music.track_options.remove_from_favorites")
+ : t("music.track_options.add_to_favorites")}
+
+
+
+
+
+
+
+
+ {t("music.track_options.add_to_playlist")}
+
+
+
+ {playlistId && (
+ <>
+
+
+
+
+ {t("music.track_options.remove_from_playlist")}
+
+
+ >
+ )}
+
+
+
+
+ {isCurrentlyDownloading || isDownloadingTrack ? (
+
+ ) : (
+
+ )}
+
+ {isCurrentlyDownloading || isDownloadingTrack
+ ? t("music.track_options.downloading")
+ : isAlreadyDownloaded
+ ? t("music.track_options.downloaded")
+ : t("music.track_options.download")}
+
+
+
+ {isOnlyCached && !isAlreadyDownloaded && (
+ <>
+
+
+
+
+ {t("music.track_options.cached")}
+
+
+ >
+ )}
+
+ {(isAlreadyDownloaded || isOnlyCached) && (
+ <>
+
+
+
+
+ {isAlreadyDownloaded
+ ? t("music.track_options.delete_download")
+ : t("music.track_options.delete_cache")}
+
+
+ >
+ )}
+
+
+ {/* Navigation Options */}
+ {(hasArtist || hasAlbum) && (
+
+ {hasArtist && (
+ <>
+
+
+
+ {t("music.track_options.go_to_artist")}
+
+
+ {hasAlbum && }
+ >
+ )}
+
+ {hasAlbum && (
+
+
+
+ {t("music.track_options.go_to_album")}
+
+
+ )}
+
+ )}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ separator: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: "#404040",
+ },
+});
diff --git a/components/music/index.ts b/components/music/index.ts
new file mode 100644
index 000000000..3d0767ad2
--- /dev/null
+++ b/components/music/index.ts
@@ -0,0 +1,6 @@
+export * from "./MiniPlayerBar";
+export * from "./MusicAlbumCard";
+export * from "./MusicArtistCard";
+export * from "./MusicPlaybackEngine";
+export * from "./MusicPlaylistCard";
+export * from "./MusicTrackItem";
diff --git a/components/navigation/TabBarIcon.tsx b/components/navigation/TabBarIcon.tsx
deleted file mode 100644
index a28bba840..000000000
--- a/components/navigation/TabBarIcon.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
-
-import type { IconProps } from "@expo/vector-icons/build/createIconSet";
-import Ionicons from "@expo/vector-icons/Ionicons";
-import type { ComponentProps } from "react";
-
-export function TabBarIcon({
- style,
- ...rest
-}: IconProps["name"]>) {
- return ;
-}
diff --git a/components/persons/TVActorPage.tsx b/components/persons/TVActorPage.tsx
new file mode 100644
index 000000000..df8ee8ffa
--- /dev/null
+++ b/components/persons/TVActorPage.tsx
@@ -0,0 +1,570 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import { Image } from "expo-image";
+import { LinearGradient } from "expo-linear-gradient";
+import { useSegments } from "expo-router";
+import { useAtom } from "jotai";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Animated,
+ Dimensions,
+ Easing,
+ FlatList,
+ ScrollView,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import { getItemNavigation } from "@/components/common/TouchableItemRouter";
+import { Loader } from "@/components/Loader";
+import { TVPosterCard } from "@/components/tv/TVPosterCard";
+import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
+import { useScaledTVSizes } from "@/constants/TVSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
+import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
+import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
+
+const { width: SCREEN_WIDTH } = Dimensions.get("window");
+
+const HORIZONTAL_PADDING = 80;
+const TOP_PADDING = 140;
+const ACTOR_IMAGE_SIZE = 250;
+const SCALE_PADDING = 20;
+
+interface TVActorPageProps {
+ personId: string;
+}
+
+export const TVActorPage: React.FC = ({ personId }) => {
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+ const router = useRouter();
+ const { showItemActions } = useTVItemActionModal();
+ const segments = useSegments();
+ const from = (segments as string[])[2] || "(home)";
+ const posterSizes = useScaledTVPosterSizes();
+ const typography = useScaledTVTypography();
+ const sizes = useScaledTVSizes();
+ const ITEM_GAP = sizes.gaps.item;
+
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+
+ // Track which filmography item is currently focused for dynamic backdrop
+ const [focusedItem, setFocusedItem] = useState(null);
+
+ // Fetch actor details
+ const { data: item, isLoading: isLoadingActor } = useQuery({
+ queryKey: ["item", personId],
+ queryFn: async () =>
+ await getUserItemData({
+ api,
+ userId: user?.Id,
+ itemId: personId,
+ }),
+ enabled: !!personId && !!api,
+ staleTime: 60,
+ });
+
+ // Fetch movies
+ const { data: movies = [], isLoading: isLoadingMovies } = useQuery({
+ queryKey: ["actor", "movies", personId],
+ queryFn: async () => {
+ if (!api || !user?.Id) return [];
+
+ const response = await getItemsApi(api).getItems({
+ userId: user.Id,
+ personIds: [personId],
+ startIndex: 0,
+ limit: 20,
+ sortOrder: ["Descending", "Descending", "Ascending"],
+ includeItemTypes: ["Movie"],
+ recursive: true,
+ fields: ["ParentId", "PrimaryImageAspectRatio"],
+ sortBy: ["PremiereDate", "ProductionYear", "SortName"],
+ collapseBoxSetItems: false,
+ });
+
+ return response.data.Items || [];
+ },
+ enabled: !!personId && !!api && !!user?.Id,
+ staleTime: 60,
+ });
+
+ // Fetch series
+ const { data: series = [], isLoading: isLoadingSeries } = useQuery({
+ queryKey: ["actor", "series", personId],
+ queryFn: async () => {
+ if (!api || !user?.Id) return [];
+
+ const response = await getItemsApi(api).getItems({
+ userId: user.Id,
+ personIds: [personId],
+ startIndex: 0,
+ limit: 20,
+ sortOrder: ["Descending", "Descending", "Ascending"],
+ includeItemTypes: ["Series"],
+ recursive: true,
+ fields: ["ParentId", "PrimaryImageAspectRatio"],
+ sortBy: ["PremiereDate", "ProductionYear", "SortName"],
+ collapseBoxSetItems: false,
+ });
+
+ return response.data.Items || [];
+ },
+ enabled: !!personId && !!api && !!user?.Id,
+ staleTime: 60,
+ });
+
+ // Get backdrop URL from the currently focused filmography item
+ // Changes dynamically as user navigates through the list
+ const backdropUrl = useMemo(() => {
+ // Use focused item if available, otherwise fall back to first movie or series
+ const itemForBackdrop = focusedItem ?? movies[0] ?? series[0];
+ if (!itemForBackdrop) return null;
+ return getBackdropUrl({
+ api,
+ item: itemForBackdrop,
+ quality: 90,
+ width: 1920,
+ });
+ }, [api, focusedItem, movies, series]);
+
+ // Crossfade animation for backdrop transitions
+ // Use two alternating layers for smooth crossfade
+ const [activeLayer, setActiveLayer] = useState<0 | 1>(0);
+ const [layer0Url, setLayer0Url] = useState(null);
+ const [layer1Url, setLayer1Url] = useState(null);
+ const layer0Opacity = useRef(new Animated.Value(1)).current;
+ const layer1Opacity = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ if (!backdropUrl) return;
+
+ let isCancelled = false;
+
+ const performCrossfade = async () => {
+ // Disk-only prefetch to avoid pinning large backdrops in memory cache.
+ try {
+ await Image.prefetch(backdropUrl, "disk");
+ } catch {
+ // Continue even if prefetch fails
+ }
+
+ if (isCancelled) return;
+
+ // Determine which layer to fade in
+ const incomingLayer = activeLayer === 0 ? 1 : 0;
+ const incomingOpacity =
+ incomingLayer === 0 ? layer0Opacity : layer1Opacity;
+ const outgoingOpacity =
+ incomingLayer === 0 ? layer1Opacity : layer0Opacity;
+
+ // Set the new URL on the incoming layer
+ if (incomingLayer === 0) {
+ setLayer0Url(backdropUrl);
+ } else {
+ setLayer1Url(backdropUrl);
+ }
+
+ // Small delay to ensure image component has the new URL
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ if (isCancelled) return;
+
+ // Crossfade: fade in the incoming layer, fade out the outgoing
+ Animated.parallel([
+ Animated.timing(incomingOpacity, {
+ toValue: 1,
+ duration: 500,
+ easing: Easing.inOut(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(outgoingOpacity, {
+ toValue: 0,
+ duration: 500,
+ easing: Easing.inOut(Easing.quad),
+ useNativeDriver: true,
+ }),
+ ]).start(() => {
+ if (!isCancelled) {
+ // After animation completes, switch the active layer
+ setActiveLayer(incomingLayer);
+ }
+ });
+ };
+
+ performCrossfade();
+
+ return () => {
+ isCancelled = true;
+ };
+ }, [backdropUrl]);
+
+ // Get actor image URL
+ const actorImageUrl = useMemo(() => {
+ if (!item?.Id || !api?.basePath) return null;
+ return `${api.basePath}/Items/${item.Id}/Images/Primary?fillWidth=${ACTOR_IMAGE_SIZE * 2}&fillHeight=${ACTOR_IMAGE_SIZE * 2}&quality=90`;
+ }, [api?.basePath, item?.Id]);
+
+ // Handle filmography item press
+ const handleItemPress = useCallback(
+ (filmItem: BaseItemDto) => {
+ const navigation = getItemNavigation(filmItem, from);
+ router.push(navigation as any);
+ },
+ [from, router],
+ );
+
+ // List item layout
+ const getItemLayout = useCallback(
+ (_data: ArrayLike | null | undefined, index: number) => ({
+ length: posterSizes.poster + ITEM_GAP,
+ offset: (posterSizes.poster + ITEM_GAP) * index,
+ index,
+ }),
+ [],
+ );
+
+ // Render movie filmography item
+ const renderMovieItem = useCallback(
+ ({ item: filmItem, index }: { item: BaseItemDto; index: number }) => (
+
+ handleItemPress(filmItem)}
+ onLongPress={() => showItemActions(filmItem)}
+ onFocus={() => setFocusedItem(filmItem)}
+ hasTVPreferredFocus={index === 0}
+ width={posterSizes.poster}
+ />
+
+ ),
+ [handleItemPress, showItemActions, posterSizes.poster],
+ );
+
+ // Render series filmography item
+ const renderSeriesItem = useCallback(
+ ({ item: filmItem, index }: { item: BaseItemDto; index: number }) => (
+
+ handleItemPress(filmItem)}
+ onLongPress={() => showItemActions(filmItem)}
+ onFocus={() => setFocusedItem(filmItem)}
+ hasTVPreferredFocus={movies.length === 0 && index === 0}
+ width={posterSizes.poster}
+ />
+
+ ),
+ [handleItemPress, showItemActions, posterSizes.poster, movies.length],
+ );
+
+ if (isLoadingActor) {
+ return (
+
+
+
+ );
+ }
+
+ if (!item?.Id) return null;
+
+ return (
+
+ {/* Full-screen backdrop with crossfade - two alternating layers */}
+
+ {/* Layer 0 */}
+
+ {layer0Url ? (
+
+ ) : (
+
+ )}
+
+ {/* Layer 1 */}
+
+ {layer1Url ? (
+
+ ) : (
+
+ )}
+
+ {/* Gradient overlay for readability */}
+
+
+
+ {/* Main content area */}
+
+ {/* Top section - Actor image + Info */}
+
+ {/* Left side - Circular actor image */}
+
+ {actorImageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Right side - Info */}
+
+ {/* Actor name */}
+
+ {item.Name}
+
+
+ {/* Production year / Birth year */}
+ {item.ProductionYear && (
+
+ {item.ProductionYear}
+
+ )}
+
+ {/* Biography */}
+ {item.Overview && (
+
+ {item.Overview}
+
+ )}
+
+
+
+ {/* Filmography sections */}
+
+ {/* Movies Section */}
+ {isLoadingMovies ? (
+
+
+
+ ) : (
+ movies.length > 0 && (
+
+
+ {t("item_card.movies")}
+
+ filmItem.Id!}
+ renderItem={renderMovieItem}
+ showsHorizontalScrollIndicator={false}
+ initialNumToRender={6}
+ maxToRenderPerBatch={4}
+ windowSize={5}
+ removeClippedSubviews={false}
+ getItemLayout={getItemLayout}
+ style={{ overflow: "visible" }}
+ contentContainerStyle={{
+ paddingVertical: SCALE_PADDING,
+ paddingHorizontal: SCALE_PADDING,
+ }}
+ />
+
+ )
+ )}
+
+ {/* Series Section */}
+ {isLoadingSeries ? (
+
+
+
+ ) : (
+ series.length > 0 && (
+
+
+ {t("item_card.shows")}
+
+ filmItem.Id!}
+ renderItem={renderSeriesItem}
+ showsHorizontalScrollIndicator={false}
+ initialNumToRender={6}
+ maxToRenderPerBatch={4}
+ windowSize={5}
+ removeClippedSubviews={false}
+ getItemLayout={getItemLayout}
+ style={{ overflow: "visible" }}
+ contentContainerStyle={{
+ paddingVertical: SCALE_PADDING,
+ paddingHorizontal: SCALE_PADDING,
+ }}
+ />
+
+ )
+ )}
+
+ {/* Empty state - only show if both sections are empty and not loading */}
+ {!isLoadingMovies &&
+ !isLoadingSeries &&
+ movies.length === 0 &&
+ series.length === 0 && (
+
+ {t("common.no_results")}
+
+ )}
+
+
+
+ );
+};
diff --git a/components/posters/EpisodePoster.tsx b/components/posters/EpisodePoster.tsx
deleted file mode 100644
index af42989b9..000000000
--- a/components/posters/EpisodePoster.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { Image } from "expo-image";
-import { useAtom } from "jotai";
-import { useMemo, useState } from "react";
-import { View } from "react-native";
-import { WatchedIndicator } from "@/components/WatchedIndicator";
-import { apiAtom } from "@/providers/JellyfinProvider";
-
-type MoviePosterProps = {
- item: BaseItemDto;
- showProgress?: boolean;
-};
-
-export const EpisodePoster: React.FC = ({
- item,
- showProgress = false,
-}) => {
- const [api] = useAtom(apiAtom);
-
- const url = useMemo(() => {
- if (item.Type === "Episode") {
- return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
- }
- }, [item]);
-
- const [progress, _setProgress] = useState(
- item.UserData?.PlayedPercentage || 0,
- );
-
- const blurhash = useMemo(() => {
- const key = item.ImageTags?.Primary as string;
- return item.ImageBlurHashes?.Primary?.[key];
- }, [item]);
-
- return (
-
-
-
- {showProgress && progress > 0 && (
-
- )}
-
- );
-};
diff --git a/components/posters/ItemPoster.tsx b/components/posters/ItemPoster.tsx
index bcd857e79..9990727be 100644
--- a/components/posters/ItemPoster.tsx
+++ b/components/posters/ItemPoster.tsx
@@ -1,8 +1,8 @@
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useState } from "react";
import { View, type ViewProps } from "react-native";
+import { WatchedIndicator } from "@/components/WatchedIndicator";
import { ItemImage } from "../common/ItemImage";
-import { WatchedIndicator } from "../WatchedIndicator";
interface Props extends ViewProps {
item: BaseItemDto;
diff --git a/components/posters/ParentPoster.tsx b/components/posters/ParentPoster.tsx
deleted file mode 100644
index 47b62e4c3..000000000
--- a/components/posters/ParentPoster.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { Image } from "expo-image";
-import { useAtom } from "jotai";
-import { useMemo } from "react";
-import { View } from "react-native";
-import { apiAtom } from "@/providers/JellyfinProvider";
-
-type PosterProps = {
- id?: string;
- showProgress?: boolean;
-};
-
-const ParentPoster: React.FC = ({ id }) => {
- const [api] = useAtom(apiAtom);
-
- const url = useMemo(
- () => `${api?.basePath}/Items/${id}/Images/Primary`,
- [id],
- );
-
- if (!url || !id)
- return (
-
- );
-
- return (
-
-
-
- );
-};
-
-export default ParentPoster;
diff --git a/components/posters/SeriesPoster.tsx b/components/posters/SeriesPoster.tsx
index 07f212d7f..82cf146c7 100644
--- a/components/posters/SeriesPoster.tsx
+++ b/components/posters/SeriesPoster.tsx
@@ -3,6 +3,7 @@ import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
+import { WatchedIndicator } from "@/components/WatchedIndicator";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
@@ -52,6 +53,7 @@ const SeriesPoster: React.FC = ({ item }) => {
width: "100%",
}}
/>
+
);
};
diff --git a/components/search/DiscoverFilters.tsx b/components/search/DiscoverFilters.tsx
index 2e844c881..3f70c968d 100644
--- a/components/search/DiscoverFilters.tsx
+++ b/components/search/DiscoverFilters.tsx
@@ -1,8 +1,17 @@
-import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
import { Platform, View } from "react-native";
import { FilterButton } from "@/components/filters/FilterButton";
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
+// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
+// A static top-level import crashes the route tree on tvOS at module load.
+// Load it lazily and only off-TV; TV never renders this component.
+const { Button, Host, Menu } = Platform.isTV
+ ? ({} as typeof import("@expo/ui/swift-ui"))
+ : require("@expo/ui/swift-ui");
+const { buttonStyle } = Platform.isTV
+ ? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
+ : require("@expo/ui/swift-ui/modifiers");
+
interface DiscoverFiltersProps {
searchFilterId: string;
orderFilterId: string;
@@ -28,7 +37,7 @@ export const DiscoverFilters: React.FC = ({
setJellyseerrSortOrder,
t,
}) => {
- if (Platform.OS === "ios") {
+ if (Platform.OS === "ios" && !Platform.isTV) {
return (
= ({
marginLeft: "auto",
}}
>
-
-
+
-
-
-
- t(`home.settings.plugins.jellyseerr.order_by.${item}`),
- )}
- variant='menu'
- selectedIndex={sortOptions.indexOf(
- jellyseerrOrderBy as unknown as string,
- )}
- onOptionSelected={(event: any) => {
- const index = event.nativeEvent.index;
- setJellyseerrOrderBy(
- sortOptions[index] as unknown as JellyseerrSearchSort,
- );
- }}
/>
- t(`library.filters.${item}`))}
- variant='menu'
- selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)}
- onOptionSelected={(event: any) => {
- const index = event.nativeEvent.index;
- setJellyseerrSortOrder(orderOptions[index]);
- }}
- />
-
-
+ }
+ >
+
+ {sortOptions.map((item) => {
+ const isSelected =
+ jellyseerrOrderBy === (item as unknown as JellyseerrSearchSort);
+ return (
+
+
+ {orderOptions.map((item) => {
+ const isSelected = jellyseerrSortOrder === item;
+ return (
+
+
);
}
diff --git a/components/search/SearchTabButtons.tsx b/components/search/SearchTabButtons.tsx
index b312b82e4..a790ee529 100644
--- a/components/search/SearchTabButtons.tsx
+++ b/components/search/SearchTabButtons.tsx
@@ -1,7 +1,16 @@
-import { Button, Host } from "@expo/ui/swift-ui";
import { Platform, TouchableOpacity, View } from "react-native";
import { Tag } from "@/components/GenreTags";
+// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
+// A static top-level import crashes the route tree on tvOS at module load.
+// Load it lazily and only off-TV; TV never renders this component.
+const { Button, Host, HStack, Spacer } = Platform.isTV
+ ? ({} as typeof import("@expo/ui/swift-ui"))
+ : require("@expo/ui/swift-ui");
+const { buttonStyle } = Platform.isTV
+ ? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
+ : require("@expo/ui/swift-ui/modifiers");
+
type SearchType = "Library" | "Discover";
interface SearchTabButtonsProps {
@@ -15,42 +24,31 @@ export const SearchTabButtons: React.FC = ({
setSearchType,
t,
}) => {
- if (Platform.OS === "ios") {
+ if (Platform.OS === "ios" && !Platform.isTV) {
return (
- <>
-
+
+
-
-
+ label={t("search.library")}
+ />
-
- >
+ label={t("search.discover")}
+ />
+
+
+
);
}
diff --git a/components/search/TVJellyseerrSearchResults.tsx b/components/search/TVJellyseerrSearchResults.tsx
new file mode 100644
index 000000000..699c50401
--- /dev/null
+++ b/components/search/TVJellyseerrSearchResults.tsx
@@ -0,0 +1,454 @@
+import { Ionicons } from "@expo/vector-icons";
+import { Image } from "expo-image";
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { Animated, FlatList, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { useJellyseerr } from "@/hooks/useJellyseerr";
+import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
+import type {
+ MovieResult,
+ PersonResult,
+ TvResult,
+} from "@/utils/jellyseerr/server/models/Search";
+
+const SCALE_PADDING = 20;
+
+interface TVJellyseerrPosterProps {
+ item: MovieResult | TvResult;
+ onPress: () => void;
+ isFirstItem?: boolean;
+}
+
+const TVJellyseerrPoster: React.FC = ({
+ item,
+ onPress,
+ isFirstItem = false,
+}) => {
+ const typography = useScaledTVTypography();
+ const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.05 });
+
+ const posterUrl = item.posterPath
+ ? jellyseerrApi?.imageProxy(item.posterPath, "w342")
+ : null;
+
+ const title = getTitle(item);
+ const year = getYear(item);
+
+ const isInLibrary =
+ item.mediaInfo?.status === MediaStatus.AVAILABLE ||
+ item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
+
+ return (
+
+
+
+ {posterUrl ? (
+
+ ) : (
+
+
+
+ )}
+ {isInLibrary && (
+
+
+
+ )}
+
+
+ {title}
+
+ {year && (
+
+ {year}
+
+ )}
+
+
+ );
+};
+
+interface TVJellyseerrPersonPosterProps {
+ item: PersonResult;
+ onPress: () => void;
+}
+
+const TVJellyseerrPersonPoster: React.FC = ({
+ item,
+ onPress,
+}) => {
+ const typography = useScaledTVTypography();
+ const { jellyseerrApi } = useJellyseerr();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation();
+
+ const posterUrl = item.profilePath
+ ? jellyseerrApi?.imageProxy(item.profilePath, "w185")
+ : null;
+
+ return (
+
+
+
+ {posterUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {item.name}
+
+
+
+ );
+};
+
+interface TVJellyseerrMovieSectionProps {
+ title: string;
+ items: MovieResult[];
+ isFirstSection?: boolean;
+ onItemPress: (item: MovieResult) => void;
+}
+
+const TVJellyseerrMovieSection: React.FC = ({
+ title,
+ items,
+ isFirstSection = false,
+ onItemPress,
+}) => {
+ const typography = useScaledTVTypography();
+ if (!items || items.length === 0) return null;
+
+ return (
+
+
+ {title}
+
+ item.id.toString()}
+ showsHorizontalScrollIndicator={false}
+ contentContainerStyle={{
+ paddingHorizontal: SCALE_PADDING,
+ paddingVertical: SCALE_PADDING,
+ gap: 20,
+ }}
+ style={{ overflow: "visible" }}
+ renderItem={({ item, index }) => (
+ onItemPress(item)}
+ isFirstItem={isFirstSection && index === 0}
+ />
+ )}
+ />
+
+ );
+};
+
+interface TVJellyseerrTvSectionProps {
+ title: string;
+ items: TvResult[];
+ isFirstSection?: boolean;
+ onItemPress: (item: TvResult) => void;
+}
+
+const TVJellyseerrTvSection: React.FC = ({
+ title,
+ items,
+ isFirstSection = false,
+ onItemPress,
+}) => {
+ const typography = useScaledTVTypography();
+ if (!items || items.length === 0) return null;
+
+ return (
+
+
+ {title}
+
+ item.id.toString()}
+ showsHorizontalScrollIndicator={false}
+ contentContainerStyle={{
+ paddingHorizontal: SCALE_PADDING,
+ paddingVertical: SCALE_PADDING,
+ gap: 20,
+ }}
+ style={{ overflow: "visible" }}
+ renderItem={({ item, index }) => (
+ onItemPress(item)}
+ isFirstItem={isFirstSection && index === 0}
+ />
+ )}
+ />
+
+ );
+};
+
+interface TVJellyseerrPersonSectionProps {
+ title: string;
+ items: PersonResult[];
+ isFirstSection?: boolean;
+ onItemPress: (item: PersonResult) => void;
+}
+
+const TVJellyseerrPersonSection: React.FC = ({
+ title,
+ items,
+ isFirstSection: _isFirstSection = false,
+ onItemPress,
+}) => {
+ const typography = useScaledTVTypography();
+ if (!items || items.length === 0) return null;
+
+ return (
+
+
+ {title}
+
+ item.id.toString()}
+ showsHorizontalScrollIndicator={false}
+ contentContainerStyle={{
+ paddingHorizontal: SCALE_PADDING,
+ paddingVertical: SCALE_PADDING,
+ gap: 20,
+ }}
+ style={{ overflow: "visible" }}
+ renderItem={({ item }) => (
+ onItemPress(item)}
+ />
+ )}
+ />
+
+ );
+};
+
+export interface TVJellyseerrSearchResultsProps {
+ movieResults: MovieResult[];
+ tvResults: TvResult[];
+ personResults: PersonResult[];
+ loading: boolean;
+ noResults: boolean;
+ searchQuery: string;
+ onMoviePress: (item: MovieResult) => void;
+ onTvPress: (item: TvResult) => void;
+ onPersonPress: (item: PersonResult) => void;
+}
+
+export const TVJellyseerrSearchResults: React.FC<
+ TVJellyseerrSearchResultsProps
+> = ({
+ movieResults,
+ tvResults,
+ personResults,
+ loading,
+ noResults,
+ searchQuery,
+ onMoviePress,
+ onTvPress,
+ onPersonPress,
+}) => {
+ const { t } = useTranslation();
+
+ if (loading) {
+ return null;
+ }
+
+ if (noResults && searchQuery.length > 0) {
+ return (
+
+
+ {t("search.no_results_found_for")}
+
+
+ "{searchQuery}"
+
+
+ );
+ }
+
+ return (
+
+ {/* No section requests `hasTVPreferredFocus`: the native search field
+ keeps focus while typing, otherwise the first result would re-grab
+ focus on every keystroke as results re-render. The user navigates
+ down to the grid manually. */}
+
+
+
+
+ );
+};
diff --git a/components/search/TVSearchBadge.tsx b/components/search/TVSearchBadge.tsx
new file mode 100644
index 000000000..61b47d648
--- /dev/null
+++ b/components/search/TVSearchBadge.tsx
@@ -0,0 +1,56 @@
+import React from "react";
+import { Animated, Pressable } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+
+export interface TVSearchBadgeProps {
+ label: string;
+ onPress: () => void;
+ hasTVPreferredFocus?: boolean;
+}
+
+export const TVSearchBadge: React.FC = ({
+ label,
+ onPress,
+ hasTVPreferredFocus = false,
+}) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ duration: 150 });
+
+ return (
+
+
+
+ {label}
+
+
+
+ );
+};
diff --git a/components/search/TVSearchPage.tsx b/components/search/TVSearchPage.tsx
new file mode 100644
index 000000000..24e6120d1
--- /dev/null
+++ b/components/search/TVSearchPage.tsx
@@ -0,0 +1,381 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { useAtom } from "jotai";
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { Platform, ScrollView, TextInput, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
+import { useScaledTVSizes } from "@/constants/TVSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { TvSearchView } from "@/modules/tv-search";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
+import type {
+ MovieResult,
+ PersonResult,
+ TvResult,
+} from "@/utils/jellyseerr/server/models/Search";
+import { scaleSize } from "@/utils/scaleSize";
+import { TVJellyseerrSearchResults } from "./TVJellyseerrSearchResults";
+import { TVSearchSection } from "./TVSearchSection";
+import { TVSearchTabBadges } from "./TVSearchTabBadges";
+
+const HORIZONTAL_PADDING = 60;
+const TOP_PADDING = 100;
+// Height of the native search bar itself. The tvOS grid keyboard presents as
+// its own overlay when the field is focused, so we only reserve the bar height
+// here — not the whole keyboard. Tunable once seen on device.
+const SEARCH_AREA_HEIGHT = 250;
+const SECTION_GAP = 10;
+const SCALE_PADDING = 20;
+
+// Loading skeleton for TV.
+// Mirrors TVSearchSection's scaled layout (poster width, item gap, edge
+// padding, heading typography, poster radius) so the placeholder lines up with
+// the real content that replaces it.
+const TVLoadingSkeleton: React.FC = () => {
+ const typography = useScaledTVTypography();
+ const sizes = useScaledTVSizes();
+ const itemWidth = sizes.posters.poster;
+ return (
+
+ {/* Section header placeholder — matches the heading typography + margins */}
+
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+
+
+
+ Placeholder text here
+
+
+
+ ))}
+
+
+ );
+};
+
+type SearchType = "Library" | "Discover";
+
+interface TVSearchPageProps {
+ search: string;
+ setSearch: (text: string) => void;
+ debouncedSearch: string;
+ // Library search results
+ movies?: BaseItemDto[];
+ series?: BaseItemDto[];
+ episodes?: BaseItemDto[];
+ collections?: BaseItemDto[];
+ actors?: BaseItemDto[];
+ artists?: BaseItemDto[];
+ albums?: BaseItemDto[];
+ songs?: BaseItemDto[];
+ playlists?: BaseItemDto[];
+ loading: boolean;
+ noResults: boolean;
+ onItemPress: (item: BaseItemDto) => void;
+ onItemLongPress?: (item: BaseItemDto) => void;
+ // Jellyseerr/Discover props
+ searchType: SearchType;
+ setSearchType: (type: SearchType) => void;
+ showDiscover: boolean;
+ jellyseerrMovies?: MovieResult[];
+ jellyseerrTv?: TvResult[];
+ jellyseerrPersons?: PersonResult[];
+ jellyseerrLoading?: boolean;
+ jellyseerrNoResults?: boolean;
+ onJellyseerrMoviePress?: (item: MovieResult) => void;
+ onJellyseerrTvPress?: (item: TvResult) => void;
+ onJellyseerrPersonPress?: (item: PersonResult) => void;
+ // Discover sliders for empty state
+ discoverSliders?: DiscoverSlider[];
+}
+
+export const TVSearchPage: React.FC = ({
+ setSearch,
+ debouncedSearch,
+ movies,
+ series,
+ episodes,
+ collections,
+ actors,
+ artists,
+ albums,
+ songs,
+ playlists,
+ loading,
+ noResults,
+ onItemPress,
+ onItemLongPress,
+ searchType,
+ setSearchType,
+ showDiscover,
+ jellyseerrMovies = [],
+ jellyseerrTv = [],
+ jellyseerrPersons = [],
+ jellyseerrLoading = false,
+ jellyseerrNoResults = false,
+ onJellyseerrMoviePress,
+ onJellyseerrTvPress,
+ onJellyseerrPersonPress,
+ discoverSliders,
+}) => {
+ const typography = useScaledTVTypography();
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+ const [api] = useAtom(apiAtom);
+
+ // Image URL getter for music items
+ const getImageUrl = useMemo(() => {
+ return (item: BaseItemDto): string | undefined => {
+ if (!api) return undefined;
+ const url = getPrimaryImageUrl({ api, item });
+ return url ?? undefined;
+ };
+ }, [api]);
+
+ // Determine which section should have initial focus
+ const sections = useMemo(() => {
+ const allSections: {
+ key: string;
+ title: string;
+ items: BaseItemDto[] | undefined;
+ orientation?: "horizontal" | "vertical";
+ }[] = [
+ { key: "movies", title: t("search.movies"), items: movies },
+ { key: "series", title: t("search.series"), items: series },
+ {
+ key: "episodes",
+ title: t("search.episodes"),
+ items: episodes,
+ orientation: "horizontal" as const,
+ },
+ {
+ key: "collections",
+ title: t("search.collections"),
+ items: collections,
+ },
+ { key: "actors", title: t("search.actors"), items: actors },
+ { key: "artists", title: t("search.artists"), items: artists },
+ { key: "albums", title: t("search.albums"), items: albums },
+ { key: "songs", title: t("search.songs"), items: songs },
+ { key: "playlists", title: t("search.playlists"), items: playlists },
+ ];
+
+ return allSections.filter((s) => s.items && s.items.length > 0);
+ }, [
+ movies,
+ series,
+ episodes,
+ collections,
+ actors,
+ artists,
+ albums,
+ songs,
+ playlists,
+ t,
+ ]);
+
+ const isLibraryMode = searchType === "Library";
+ const isDiscoverMode = searchType === "Discover";
+ const currentLoading = isLibraryMode ? loading : jellyseerrLoading;
+ const currentNoResults = isLibraryMode ? noResults : jellyseerrNoResults;
+
+ return (
+
+ {/* Sticky header: search field stays pinned while results scroll below. */}
+
+ {/* Search bar: native tvOS SwiftUI `.searchable` on Apple TV, standard
+ TextInput fallback on Android TV (the native module is Apple-only). */}
+ {Platform.OS === "ios" ? (
+
+ {/* No horizontal margin here: the native tvOS search bar centers
+ itself and renders a trailing "Hold to Dictate" hint. */}
+ setSearch(e.nativeEvent.text)}
+ />
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {/* Search Type Tab Badges */}
+ {showDiscover && (
+
+
+
+ )}
+
+ {/* Loading State */}
+ {currentLoading && (
+
+
+
+
+ )}
+
+ {/* Library Search Results */}
+ {isLibraryMode && !loading && (
+
+ {sections.map((section) => (
+
+ ))}
+
+ )}
+
+ {/* Jellyseerr/Discover Search Results */}
+ {isDiscoverMode && !jellyseerrLoading && debouncedSearch.length > 0 && (
+ {})}
+ onTvPress={onJellyseerrTvPress || (() => {})}
+ onPersonPress={onJellyseerrPersonPress || (() => {})}
+ />
+ )}
+
+ {/* Discover Content (when no search query in Discover mode) */}
+ {isDiscoverMode &&
+ !jellyseerrLoading &&
+ debouncedSearch.length === 0 && (
+
+ )}
+
+ {/* No Results State */}
+ {!currentLoading && currentNoResults && debouncedSearch.length > 0 && (
+
+
+ {t("search.no_results_found_for")}
+
+
+ "{debouncedSearch}"
+
+
+ )}
+
+
+ );
+};
diff --git a/components/search/TVSearchSection.tsx b/components/search/TVSearchSection.tsx
new file mode 100644
index 000000000..ffaef2366
--- /dev/null
+++ b/components/search/TVSearchSection.tsx
@@ -0,0 +1,311 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { Image } from "expo-image";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { FlatList, View, type ViewProps } from "react-native";
+import { Text } from "@/components/common/Text";
+import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
+import { TVPosterCard } from "@/components/tv/TVPosterCard";
+import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
+import { useScaledTVSizes } from "@/constants/TVSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+
+const SCALE_PADDING = 20;
+
+interface TVSearchSectionProps extends ViewProps {
+ title: string;
+ items: BaseItemDto[];
+ orientation?: "horizontal" | "vertical";
+ disabled?: boolean;
+ isFirstSection?: boolean;
+ onItemPress: (item: BaseItemDto) => void;
+ onItemLongPress?: (item: BaseItemDto) => void;
+ imageUrlGetter?: (item: BaseItemDto) => string | undefined;
+ /** Override the horizontal edge padding (defaults to the scaled TV padding). */
+ horizontalPadding?: number;
+}
+
+export const TVSearchSection: React.FC = ({
+ title,
+ items,
+ orientation = "vertical",
+ disabled = false,
+ isFirstSection = false,
+ onItemPress,
+ onItemLongPress,
+ imageUrlGetter,
+ horizontalPadding,
+ ...props
+}) => {
+ const typography = useScaledTVTypography();
+ const posterSizes = useScaledTVPosterSizes();
+ const sizes = useScaledTVSizes();
+ const ITEM_GAP = sizes.gaps.item;
+ const edgePadding = horizontalPadding ?? sizes.padding.horizontal;
+ const flatListRef = useRef>(null);
+ const [focusedCount, setFocusedCount] = useState(0);
+ const prevFocusedCount = useRef(0);
+
+ // Track focus count for section
+ useEffect(() => {
+ prevFocusedCount.current = focusedCount;
+ }, [focusedCount]);
+
+ const handleItemFocus = useCallback(() => {
+ setFocusedCount((c) => c + 1);
+ }, []);
+
+ const handleItemBlur = useCallback(() => {
+ setFocusedCount((c) => Math.max(0, c - 1));
+ }, []);
+
+ const itemWidth =
+ orientation === "horizontal" ? posterSizes.landscape : posterSizes.poster;
+
+ const getItemLayout = useCallback(
+ (_data: ArrayLike | null | undefined, index: number) => ({
+ length: itemWidth + ITEM_GAP,
+ offset: (itemWidth + ITEM_GAP) * index,
+ index,
+ }),
+ [itemWidth, ITEM_GAP],
+ );
+
+ const renderItem = useCallback(
+ ({ item, index }: { item: BaseItemDto; index: number }) => {
+ const isFirstItem = isFirstSection && index === 0;
+
+ // Special handling for MusicArtist (circular avatar)
+ if (item.Type === "MusicArtist") {
+ const imageUrl = imageUrlGetter?.(item);
+ return (
+
+ onItemPress(item)}
+ onLongPress={
+ onItemLongPress ? () => onItemLongPress(item) : undefined
+ }
+ hasTVPreferredFocus={isFirstItem && !disabled}
+ onFocus={handleItemFocus}
+ onBlur={handleItemBlur}
+ disabled={disabled}
+ >
+
+ {imageUrl ? (
+
+ ) : (
+
+ 👤
+
+ )}
+
+
+
+
+ {item.Name}
+
+
+
+ );
+ }
+
+ // Special handling for MusicAlbum, Audio, Playlist (square images)
+ if (
+ item.Type === "MusicAlbum" ||
+ item.Type === "Audio" ||
+ item.Type === "Playlist"
+ ) {
+ const imageUrl = imageUrlGetter?.(item);
+ const icon =
+ item.Type === "Playlist" ? "🎶" : item.Type === "Audio" ? "🎵" : "🎵";
+ return (
+
+ onItemPress(item)}
+ onLongPress={
+ onItemLongPress ? () => onItemLongPress(item) : undefined
+ }
+ hasTVPreferredFocus={isFirstItem && !disabled}
+ onFocus={handleItemFocus}
+ onBlur={handleItemBlur}
+ disabled={disabled}
+ >
+
+ {imageUrl ? (
+
+ ) : (
+
+ {icon}
+
+ )}
+
+
+
+
+ {item.Name}
+
+ {item.Type === "MusicAlbum" && (
+
+ {item.AlbumArtist || item.Artists?.join(", ")}
+
+ )}
+ {item.Type === "Audio" && (
+
+ {item.Artists?.join(", ") || item.AlbumArtist}
+
+ )}
+ {item.Type === "Playlist" && (
+
+ {item.ChildCount} tracks
+
+ )}
+
+
+ );
+ }
+
+ // Use TVPosterCard for all other item types
+ return (
+
+ onItemPress(item)}
+ onLongPress={
+ onItemLongPress ? () => onItemLongPress(item) : undefined
+ }
+ hasTVPreferredFocus={isFirstItem && !disabled}
+ onFocus={handleItemFocus}
+ onBlur={handleItemBlur}
+ disabled={disabled}
+ width={itemWidth}
+ />
+
+ );
+ },
+ [
+ orientation,
+ isFirstSection,
+ itemWidth,
+ onItemPress,
+ onItemLongPress,
+ handleItemFocus,
+ handleItemBlur,
+ disabled,
+ imageUrlGetter,
+ posterSizes.poster,
+ typography.callout,
+ ITEM_GAP,
+ ],
+ );
+
+ if (!items || items.length === 0) return null;
+
+ return (
+
+ {/* Section Header */}
+
+ {title}
+
+
+ item.Id!}
+ renderItem={renderItem}
+ showsHorizontalScrollIndicator={false}
+ initialNumToRender={5}
+ maxToRenderPerBatch={3}
+ windowSize={5}
+ removeClippedSubviews={false}
+ getItemLayout={getItemLayout}
+ style={{ overflow: "visible" }}
+ // Edge padding via contentContainerStyle, NOT contentInset+contentOffset.
+ // contentOffset only applies on initial mount; since this FlatList is
+ // reused across searches (stable key), a second search left the inset
+ // without the offset and the grid snapped flush to the left edge.
+ contentContainerStyle={{
+ paddingHorizontal: edgePadding,
+ paddingVertical: SCALE_PADDING,
+ }}
+ />
+
+ );
+};
diff --git a/components/search/TVSearchTabBadges.tsx b/components/search/TVSearchTabBadges.tsx
new file mode 100644
index 000000000..d15d43ce2
--- /dev/null
+++ b/components/search/TVSearchTabBadges.tsx
@@ -0,0 +1,117 @@
+import React from "react";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+
+type SearchType = "Library" | "Discover";
+
+interface TVSearchTabBadgeProps {
+ label: string;
+ isSelected: boolean;
+ onPress: () => void;
+ hasTVPreferredFocus?: boolean;
+ disabled?: boolean;
+}
+
+const TVSearchTabBadge: React.FC = ({
+ label,
+ isSelected,
+ onPress,
+ hasTVPreferredFocus = false,
+ disabled = false,
+}) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ duration: 150 });
+
+ // Design language: white for focused/selected, transparent white for unfocused
+ const getBackgroundColor = () => {
+ if (focused) return "#fff";
+ if (isSelected) return "rgba(255,255,255,0.25)";
+ return "rgba(255,255,255,0.1)";
+ };
+
+ const getTextColor = () => {
+ if (focused) return "#000";
+ return "#fff";
+ };
+
+ return (
+
+
+
+ {label}
+
+
+
+ );
+};
+
+export interface TVSearchTabBadgesProps {
+ searchType: SearchType;
+ setSearchType: (type: SearchType) => void;
+ showDiscover: boolean;
+ disabled?: boolean;
+}
+
+export const TVSearchTabBadges: React.FC = ({
+ searchType,
+ setSearchType,
+ showDiscover,
+ disabled = false,
+}) => {
+ if (!showDiscover) {
+ return null;
+ }
+
+ return (
+
+ setSearchType("Library")}
+ disabled={disabled}
+ />
+ setSearchType("Discover")}
+ disabled={disabled}
+ />
+
+ );
+};
diff --git a/components/series/CastAndCrew.tsx b/components/series/CastAndCrew.tsx
index 037206e9d..06bf47be0 100644
--- a/components/series/CastAndCrew.tsx
+++ b/components/series/CastAndCrew.tsx
@@ -2,12 +2,14 @@ import type {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
-import { router, useSegments } from "expo-router";
+import { useSegments } from "expo-router";
import { useAtom } from "jotai";
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
+import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
+import useRouter from "@/hooks/useAppRouter";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { HorizontalScroll } from "../common/HorizontalScroll";
@@ -23,6 +25,7 @@ export const CastAndCrew: React.FC = ({ item, loading, ...props }) => {
const [api] = useAtom(apiAtom);
const segments = useSegments();
const { t } = useTranslation();
+ const router = useRouter();
const from = (segments as string[])[2];
const destinctPeople = useMemo(() => {
@@ -50,7 +53,7 @@ export const CastAndCrew: React.FC = ({ item, loading, ...props }) => {
i.Id?.toString() || ""}
- height={247}
+ height={POSTER_CAROUSEL_HEIGHT}
data={destinctPeople}
renderItem={(i) => (
= ({ item, loading, ...props }) => {
className='flex flex-col w-28'
>
- {i.Name}
- {i.Role}
+
+ {i.Name}
+
+
+ {i.Role}
+
)}
/>
diff --git a/components/series/CurrentSeries.tsx b/components/series/CurrentSeries.tsx
index c3d0e0d92..d8c49e258 100644
--- a/components/series/CurrentSeries.tsx
+++ b/components/series/CurrentSeries.tsx
@@ -1,9 +1,10 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { router } from "expo-router";
import { useAtom } from "jotai";
import type React from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
+import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
+import useRouter from "@/hooks/useAppRouter";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { HorizontalScroll } from "../common/HorizontalScroll";
@@ -17,6 +18,7 @@ interface Props extends ViewProps {
export const CurrentSeries: React.FC = ({ item, ...props }) => {
const [api] = useAtom(apiAtom);
const { t } = useTranslation();
+ const router = useRouter();
return (
@@ -25,7 +27,7 @@ export const CurrentSeries: React.FC = ({ item, ...props }) => {
(
= ({ item, ...props }) => {
id={item?.Id}
url={getPrimaryImageUrlById({ api, id: item?.ParentId })}
/>
- {item?.SeriesName}
+ {item?.SeriesName}
)}
/>
diff --git a/components/series/EpisodeTitleHeader.tsx b/components/series/EpisodeTitleHeader.tsx
index e9f2b1aa4..fe473a923 100644
--- a/components/series/EpisodeTitleHeader.tsx
+++ b/components/series/EpisodeTitleHeader.tsx
@@ -1,7 +1,7 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { useRouter } from "expo-router";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
interface Props extends ViewProps {
item: BaseItemDto;
diff --git a/components/series/NextItemButton.tsx b/components/series/NextItemButton.tsx
index a2aae63fc..5d6497c36 100644
--- a/components/series/NextItemButton.tsx
+++ b/components/series/NextItemButton.tsx
@@ -2,9 +2,9 @@ import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
-import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useMemo } from "react";
+import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Button } from "../Button";
diff --git a/components/series/SeasonDropdown.tsx b/components/series/SeasonDropdown.tsx
index 5f6b64cd4..b59b74a32 100644
--- a/components/series/SeasonDropdown.tsx
+++ b/components/series/SeasonDropdown.tsx
@@ -54,29 +54,28 @@ export const SeasonDropdown: React.FC = ({
[state, item, keys],
);
+ // Always use IndexNumber for Season objects (not keys.index which is for the item)
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
- Number(a[keys.index]) - Number(b[keys.index]);
+ Number(a.IndexNumber) - Number(b.IndexNumber);
const optionGroups = useMemo(
() => [
{
options:
seasons?.sort(sortByIndex).map((season: any) => {
- const title =
- season[keys.title] ||
- season.Name ||
- `Season ${season.IndexNumber}`;
+ const title = season.Name || `Season ${season.IndexNumber}`;
return {
type: "radio" as const,
label: title,
value: season.Id || season.IndexNumber,
- selected: Number(season[keys.index]) === Number(seasonIndex),
+ // Compare season's IndexNumber with the selected seasonIndex
+ selected: Number(season.IndexNumber) === Number(seasonIndex),
onPress: () => onSelect(season),
};
}) || [],
},
],
- [seasons, keys, seasonIndex, onSelect],
+ [seasons, seasonIndex, onSelect],
);
useEffect(() => {
diff --git a/components/series/SeasonEpisodesCarousel.tsx b/components/series/SeasonEpisodesCarousel.tsx
index cf763b8d9..e3bba0a98 100644
--- a/components/series/SeasonEpisodesCarousel.tsx
+++ b/components/series/SeasonEpisodesCarousel.tsx
@@ -1,12 +1,14 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
-import { router } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, type ViewStyle } from "react-native";
+import useRouter from "@/hooks/useAppRouter";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useOfflineMode } from "@/providers/OfflineModeProvider";
+import { getDownloadedEpisodesBySeasonId } from "@/utils/downloads/offline-series";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import {
HorizontalScroll,
@@ -17,7 +19,6 @@ import { ItemCardText } from "../ItemCardText";
interface Props {
item?: BaseItemDto | null;
loading?: boolean;
- isOffline?: boolean;
style?: ViewStyle;
containerStyle?: ViewStyle;
}
@@ -25,22 +26,23 @@ interface Props {
export const SeasonEpisodesCarousel: React.FC = ({
item,
loading,
- isOffline,
style,
containerStyle,
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
+ const router = useRouter();
+ const isOffline = useOfflineMode();
+ // Read the live (cached) downloads DB inside the query rather than the
+ // provider's downloadedItems snapshot, so refetches after
+ // updateDownloadedItem() reflect the latest state instead of a stale
+ // refreshKey-gated snapshot. getAllDownloadedItems() is cached, so this stays cheap.
const { getDownloadedItems } = useDownload();
- const downloadedFiles = useMemo(
- () => getDownloadedItems(),
- [getDownloadedItems],
- );
const scrollRef = useRef(null);
const scrollToIndex = (index: number) => {
- scrollRef.current?.scrollToIndex(index, 16);
+ scrollRef.current?.scrollToIndex(index, -16);
};
const seasonId = useMemo(() => {
@@ -51,11 +53,7 @@ export const SeasonEpisodesCarousel: React.FC = ({
queryKey: ["episodes", seasonId, isOffline],
queryFn: async () => {
if (isOffline) {
- return downloadedFiles
- ?.filter(
- (f) => f.item.Type === "Episode" && f.item.SeasonId === seasonId,
- )
- .map((f) => f.item);
+ return getDownloadedEpisodesBySeasonId(getDownloadedItems(), seasonId!);
}
if (!api || !user?.Id || !item?.SeriesId) return [];
const response = await getTvShowsApi(api).getEpisodes({
@@ -73,7 +71,7 @@ export const SeasonEpisodesCarousel: React.FC = ({
});
return response.data.Items as BaseItemDto[];
},
- enabled: !!api && !!user?.Id && !!seasonId,
+ enabled: !!seasonId && (isOffline || (!!api && !!user?.Id)),
});
useEffect(() => {
@@ -87,6 +85,11 @@ export const SeasonEpisodesCarousel: React.FC = ({
}
}, [episodes, item]);
+ const snapOffsets = useMemo(() => {
+ const itemWidth = 184; // w-44 (176px) + mr-2 (8px)
+ return episodes?.map((_, index) => index * itemWidth) || [];
+ }, [episodes]);
+
return (
= ({
onPress={() => {
router.setParams({ id: _item.Id });
}}
- className={`flex flex-col w-44
+ className={`flex flex-col w-44
${item?.Id === _item.Id ? "" : "opacity-50"}
`}
>
@@ -109,6 +112,8 @@ export const SeasonEpisodesCarousel: React.FC = ({
)}
+ snapToOffsets={snapOffsets}
+ decelerationRate='fast'
/>
);
};
diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx
index f97f6c0ec..01a81545c 100644
--- a/components/series/SeasonPicker.tsx
+++ b/components/series/SeasonPicker.tsx
@@ -10,7 +10,13 @@ import {
SeasonDropdown,
type SeasonIndexState,
} from "@/components/series/SeasonDropdown";
+import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useOfflineMode } from "@/providers/OfflineModeProvider";
+import {
+ buildOfflineSeasons,
+ getDownloadedEpisodesForSeason,
+} from "@/utils/downloads/offline-series";
import { runtimeTicksToSeconds } from "@/utils/time";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { Text } from "../common/Text";
@@ -31,6 +37,8 @@ export const SeasonPicker: React.FC = ({ item }) => {
const [user] = useAtom(userAtom);
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
const { t } = useTranslation();
+ const isOffline = useOfflineMode();
+ const { getDownloadedItems, downloadedItems } = useDownload();
const seasonIndex = useMemo(
() => seasonIndexState[item.Id ?? ""],
@@ -38,8 +46,12 @@ export const SeasonPicker: React.FC = ({ item }) => {
);
const { data: seasons } = useQuery({
- queryKey: ["seasons", item.Id],
+ queryKey: ["seasons", item.Id, isOffline, downloadedItems.length],
queryFn: async () => {
+ if (isOffline) {
+ return buildOfflineSeasons(getDownloadedItems(), item.Id!);
+ }
+
if (!api || !user?.Id || !item.Id) return [];
const response = await api.axiosInstance.get(
`${api.basePath}/Shows/${item.Id}/Seasons`,
@@ -58,8 +70,8 @@ export const SeasonPicker: React.FC = ({ item }) => {
return response.data.Items;
},
- staleTime: 60,
- enabled: !!api && !!user?.Id && !!item.Id,
+ staleTime: isOffline ? Infinity : 60,
+ enabled: isOffline || (!!api && !!user?.Id && !!item.Id),
});
const selectedSeasonId: string | null = useMemo(() => {
@@ -73,9 +85,33 @@ export const SeasonPicker: React.FC = ({ item }) => {
return season.Id!;
}, [seasons, seasonIndex]);
+ // For offline mode, we use season index number instead of ID
+ const selectedSeasonNumber = useMemo(() => {
+ if (!isOffline) return null;
+ const season = seasons?.find(
+ (s: BaseItemDto) =>
+ s.IndexNumber === seasonIndex || s.Name === seasonIndex,
+ );
+ return season?.IndexNumber ?? null;
+ }, [isOffline, seasons, seasonIndex]);
+
const { data: episodes, isPending } = useQuery({
- queryKey: ["episodes", item.Id, selectedSeasonId],
+ queryKey: [
+ "episodes",
+ item.Id,
+ isOffline ? selectedSeasonNumber : selectedSeasonId,
+ isOffline,
+ downloadedItems.length,
+ ],
queryFn: async () => {
+ if (isOffline) {
+ return getDownloadedEpisodesForSeason(
+ getDownloadedItems(),
+ item.Id!,
+ selectedSeasonNumber!,
+ );
+ }
+
if (!api || !user?.Id || !item.Id || !selectedSeasonId) {
return [];
}
@@ -85,7 +121,6 @@ export const SeasonPicker: React.FC = ({ item }) => {
userId: user.Id,
seasonId: selectedSeasonId,
enableUserData: true,
- // Note: Including trick play is necessary to enable trick play downloads
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
});
@@ -97,7 +132,10 @@ export const SeasonPicker: React.FC = ({ item }) => {
return res.data.Items;
},
- enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
+ staleTime: isOffline ? Infinity : 0,
+ enabled: isOffline
+ ? !!item.Id && selectedSeasonNumber !== null
+ : !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
});
// Used for height calculation
@@ -127,7 +165,7 @@ export const SeasonPicker: React.FC = ({ item }) => {
}));
}}
/>
- {episodes?.length ? (
+ {episodes?.length && !isOffline ? (
= ({ item }) => {
{runtimeTicksToSeconds(e.RunTimeTicks)}
-
-
-
+ {!isOffline && (
+
+
+
+ )}
void;
+ /** Called when any episode is long-pressed */
+ onEpisodeLongPress?: (episode: BaseItemDto) => void;
+ /** Called when any episode gains focus */
+ onFocus?: () => void;
+ /** Called when any episode loses focus */
+ onBlur?: () => void;
+ /** Ref for programmatic scrolling */
+ scrollViewRef?: React.RefObject;
+ /** Setter for the first episode ref (for focus guide destinations) */
+ firstEpisodeRefSetter?: (ref: View | null) => void;
+ /** Text to show when episodes array is empty */
+ emptyText?: string;
+ /** Horizontal padding for the list content */
+ horizontalPadding?: number;
+}
+
+export const TVEpisodeList: React.FC = ({
+ episodes,
+ currentEpisodeId,
+ disabled = false,
+ onEpisodePress,
+ onEpisodeLongPress,
+ onFocus,
+ onBlur,
+ scrollViewRef,
+ firstEpisodeRefSetter,
+ emptyText,
+ horizontalPadding,
+}) => {
+ const renderItem = useCallback(
+ ({ item: episode, index }: { item: BaseItemDto; index: number }) => {
+ const isCurrent = currentEpisodeId
+ ? episode.Id === currentEpisodeId
+ : false;
+ return (
+ onEpisodePress(episode)}
+ onLongPress={
+ onEpisodeLongPress ? () => onEpisodeLongPress(episode) : undefined
+ }
+ onFocus={onFocus}
+ onBlur={onBlur}
+ disabled={isCurrent || disabled}
+ focusableWhenDisabled={isCurrent}
+ isCurrent={isCurrent}
+ refSetter={index === 0 ? firstEpisodeRefSetter : undefined}
+ />
+ );
+ },
+ [
+ currentEpisodeId,
+ disabled,
+ firstEpisodeRefSetter,
+ onBlur,
+ onEpisodeLongPress,
+ onEpisodePress,
+ onFocus,
+ ],
+ );
+
+ const keyExtractor = useCallback((episode: BaseItemDto) => episode.Id!, []);
+
+ return (
+
+ );
+};
diff --git a/components/series/TVSeriesHeader.tsx b/components/series/TVSeriesHeader.tsx
new file mode 100644
index 000000000..b76bcd68d
--- /dev/null
+++ b/components/series/TVSeriesHeader.tsx
@@ -0,0 +1,140 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { BlurView } from "expo-blur";
+import { Image } from "expo-image";
+import { useAtomValue } from "jotai";
+import React, { useMemo } from "react";
+import { Dimensions, View } from "react-native";
+import { Badge } from "@/components/Badge";
+import { Text } from "@/components/common/Text";
+import { GenreTags } from "@/components/GenreTags";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
+
+const { width: SCREEN_WIDTH } = Dimensions.get("window");
+
+interface TVSeriesHeaderProps {
+ item: BaseItemDto;
+}
+
+export const TVSeriesHeader: React.FC = ({ item }) => {
+ const typography = useScaledTVTypography();
+ const api = useAtomValue(apiAtom);
+
+ const logoUrl = useMemo(() => {
+ if (!api || !item) return null;
+ return getLogoImageUrlById({ api, item });
+ }, [api, item]);
+
+ const yearString = useMemo(() => {
+ const startYear = item.StartDate
+ ? new Date(item.StartDate).getFullYear()
+ : item.ProductionYear;
+
+ const endYear = item.EndDate ? new Date(item.EndDate).getFullYear() : null;
+
+ if (startYear && endYear) {
+ if (startYear === endYear) return String(startYear);
+ return `${startYear} - ${endYear}`;
+ }
+ if (startYear) return String(startYear);
+ return null;
+ }, [item.StartDate, item.EndDate, item.ProductionYear]);
+
+ return (
+
+ {/* Logo or Title */}
+ {logoUrl ? (
+
+ ) : (
+
+ {item.Name}
+
+ )}
+
+ {/* Metadata badges row */}
+
+ {yearString && (
+
+ {yearString}
+
+ )}
+ {item.OfficialRating && (
+
+ )}
+ {item.CommunityRating != null && (
+ }
+ />
+ )}
+
+
+ {/* Genres */}
+ {item.Genres && item.Genres.length > 0 && (
+
+
+
+ )}
+
+ {/* Overview */}
+ {item.Overview && (
+
+
+
+ {item.Overview}
+
+
+
+ )}
+
+ );
+};
diff --git a/components/series/TVSeriesPage.tsx b/components/series/TVSeriesPage.tsx
new file mode 100644
index 000000000..26f4447f0
--- /dev/null
+++ b/components/series/TVSeriesPage.tsx
@@ -0,0 +1,635 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import { LinearGradient } from "expo-linear-gradient";
+import { useSegments } from "expo-router";
+import { useAtom, useAtomValue } from "jotai";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Animated,
+ Dimensions,
+ Easing,
+ Pressable,
+ ScrollView,
+ TVFocusGuideView,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { ItemImage } from "@/components/common/ItemImage";
+import { Text } from "@/components/common/Text";
+import { getItemNavigation } from "@/components/common/TouchableItemRouter";
+import { seasonIndexAtom } from "@/components/series/SeasonPicker";
+import { TVEpisodeList } from "@/components/series/TVEpisodeList";
+import { TVSeriesHeader } from "@/components/series/TVSeriesHeader";
+import { TVFavoriteButton } from "@/components/tv/TVFavoriteButton";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
+import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
+import { useTVSeriesSeasonModal } from "@/hooks/useTVSeriesSeasonModal";
+import { useTVThemeMusic } from "@/hooks/useTVThemeMusic";
+import { useDownload } from "@/providers/DownloadProvider";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useOfflineMode } from "@/providers/OfflineModeProvider";
+import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal";
+import {
+ buildOfflineSeasons,
+ getDownloadedEpisodesForSeason,
+} from "@/utils/downloads/offline-series";
+
+const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
+
+const HORIZONTAL_PADDING = 80;
+const TOP_PADDING = 140;
+const POSTER_WIDTH_PERCENT = 0.22;
+const SCALE_PADDING = 20;
+
+interface TVSeriesPageProps {
+ item: BaseItemDto;
+ allEpisodes?: BaseItemDto[];
+ isLoading?: boolean;
+}
+
+// Focusable button component for TV
+const TVFocusableButton: React.FC<{
+ onPress: () => void;
+ children: React.ReactNode;
+ hasTVPreferredFocus?: boolean;
+ disabled?: boolean;
+ variant?: "primary" | "secondary";
+ refSetter?: (ref: View | null) => void;
+}> = ({
+ onPress,
+ children,
+ hasTVPreferredFocus,
+ disabled = false,
+ variant = "primary",
+ refSetter,
+}) => {
+ const [focused, setFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+
+ const animateTo = (v: number) =>
+ Animated.timing(scale, {
+ toValue: v,
+ duration: 150,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start();
+
+ const isPrimary = variant === "primary";
+
+ return (
+ {
+ setFocused(true);
+ animateTo(1.05);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
+ hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
+ disabled={disabled}
+ focusable={!disabled}
+ >
+
+
+ {children}
+
+
+
+ );
+};
+
+// Season selector button
+const TVSeasonButton: React.FC<{
+ seasonName: string;
+ onPress: () => void;
+ disabled?: boolean;
+}> = ({ seasonName, onPress, disabled = false }) => {
+ const typography = useScaledTVTypography();
+ const [focused, setFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+
+ const animateTo = (v: number) =>
+ Animated.timing(scale, {
+ toValue: v,
+ duration: 150,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start();
+
+ return (
+ {
+ setFocused(true);
+ animateTo(1.05);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
+ disabled={disabled}
+ focusable={!disabled}
+ >
+
+
+
+ {seasonName}
+
+
+
+
+
+ );
+};
+
+export const TVSeriesPage: React.FC = ({
+ item,
+ allEpisodes = [],
+ isLoading: _isLoading,
+}) => {
+ const typography = useScaledTVTypography();
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+ const router = useRouter();
+ const segments = useSegments();
+ const from = (segments as string[])[2] || "(home)";
+ const isOffline = useOfflineMode();
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const { getDownloadedItems, downloadedItems } = useDownload();
+ const { showSeasonModal } = useTVSeriesSeasonModal();
+ const { showItemActions } = useTVItemActionModal();
+ const seasonModalState = useAtomValue(tvSeriesSeasonModalAtom);
+ const isSeasonModalVisible = seasonModalState !== null;
+
+ // Auto-play theme music (handles fade in/out and cleanup)
+ useTVThemeMusic(item.Id);
+
+ // Season state
+ const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
+ const selectedSeasonIndex = useMemo(
+ () => seasonIndexState[item.Id ?? ""] ?? 1,
+ [item.Id, seasonIndexState],
+ );
+
+ // Focus guide refs (using useState to trigger re-renders when refs are set)
+ const [playButtonRef, setPlayButtonRef] = useState(null);
+ const [firstEpisodeRef, setFirstEpisodeRef] = useState(null);
+
+ // ScrollView ref for page scrolling
+ const mainScrollRef = useRef(null);
+ // ScrollView ref for scrolling back
+ const episodeListRef = useRef(null);
+ const [focusedCount, setFocusedCount] = useState(0);
+ const prevFocusedCount = useRef(0);
+
+ // Track focus count for episode list
+ useEffect(() => {
+ prevFocusedCount.current = focusedCount;
+ }, [focusedCount]);
+
+ const handleEpisodeFocus = useCallback(() => {
+ setFocusedCount((c) => c + 1);
+ }, []);
+
+ const handleEpisodeBlur = useCallback(() => {
+ setFocusedCount((c) => Math.max(0, c - 1));
+ }, []);
+
+ // Fetch seasons
+ const { data: seasons = [] } = useQuery({
+ queryKey: ["seasons", item.Id, isOffline, downloadedItems.length],
+ queryFn: async () => {
+ if (isOffline) {
+ return buildOfflineSeasons(getDownloadedItems(), item.Id!);
+ }
+ if (!api || !user?.Id || !item.Id) return [];
+
+ const response = await api.axiosInstance.get(
+ `${api.basePath}/Shows/${item.Id}/Seasons`,
+ {
+ params: {
+ userId: user.Id,
+ itemId: item.Id,
+ Fields: "ItemCounts,PrimaryImageAspectRatio",
+ },
+ headers: {
+ Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
+ },
+ },
+ );
+ return response.data.Items || [];
+ },
+ staleTime: isOffline ? Infinity : 60 * 1000,
+ refetchInterval: !isOffline ? 60 * 1000 : undefined,
+ enabled: isOffline || (!!api && !!user?.Id && !!item.Id),
+ });
+
+ // Get selected season ID
+ const selectedSeasonId = useMemo(() => {
+ const season = seasons.find(
+ (s: BaseItemDto) =>
+ s.IndexNumber === selectedSeasonIndex ||
+ s.Name === String(selectedSeasonIndex),
+ );
+ return season?.Id ?? null;
+ }, [seasons, selectedSeasonIndex]);
+
+ // Get selected season number for offline mode
+ const selectedSeasonNumber = useMemo(() => {
+ if (!isOffline) return null;
+ const season = seasons.find(
+ (s: BaseItemDto) =>
+ s.IndexNumber === selectedSeasonIndex ||
+ s.Name === String(selectedSeasonIndex),
+ );
+ return season?.IndexNumber ?? null;
+ }, [isOffline, seasons, selectedSeasonIndex]);
+
+ // Fetch episodes for selected season
+ const { data: episodesForSeason = [] } = useQuery({
+ queryKey: [
+ "episodes",
+ item.Id,
+ isOffline ? selectedSeasonNumber : selectedSeasonId,
+ isOffline,
+ downloadedItems.length,
+ ],
+ queryFn: async () => {
+ if (isOffline) {
+ return getDownloadedEpisodesForSeason(
+ getDownloadedItems(),
+ item.Id!,
+ selectedSeasonNumber!,
+ );
+ }
+ if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
+
+ const res = await getTvShowsApi(api).getEpisodes({
+ seriesId: item.Id,
+ userId: user.Id,
+ seasonId: selectedSeasonId,
+ enableUserData: true,
+ fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
+ });
+ return res.data.Items || [];
+ },
+ staleTime: isOffline ? Infinity : 60 * 1000,
+ refetchInterval: !isOffline ? 60 * 1000 : undefined,
+ enabled: isOffline
+ ? !!item.Id && selectedSeasonNumber !== null
+ : !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
+ });
+
+ // Find next unwatched episode
+ const nextUnwatchedEpisode = useMemo(() => {
+ // First check all episodes for a "next up" candidate
+ for (const ep of allEpisodes) {
+ if (!ep.UserData?.Played) {
+ // Check if it has progress (continue watching)
+ if ((ep.UserData?.PlaybackPositionTicks ?? 0) > 0) {
+ return ep;
+ }
+ }
+ }
+
+ // Find first unwatched
+ return allEpisodes.find((ep) => !ep.UserData?.Played) || allEpisodes[0];
+ }, [allEpisodes]);
+
+ // Get season name for button
+ const selectedSeasonName = useMemo(() => {
+ const season = seasons.find(
+ (s: BaseItemDto) =>
+ s.IndexNumber === selectedSeasonIndex ||
+ s.Name === String(selectedSeasonIndex),
+ );
+ return season?.Name || `Season ${selectedSeasonIndex}`;
+ }, [seasons, selectedSeasonIndex]);
+
+ // Handle episode press
+ const handleEpisodePress = useCallback(
+ (episode: BaseItemDto) => {
+ const navigation = getItemNavigation(episode, from);
+ router.push(navigation as any);
+ },
+ [from, router],
+ );
+
+ // Handle play next episode
+ const handlePlayNextEpisode = useCallback(() => {
+ if (nextUnwatchedEpisode) {
+ const navigation = getItemNavigation(nextUnwatchedEpisode, from);
+ router.push(navigation as any);
+ }
+ }, [nextUnwatchedEpisode, from, router]);
+
+ // Handle season selection
+ const handleSeasonSelect = useCallback(
+ (seasonIdx: number) => {
+ if (!item.Id) return;
+ setSeasonIndexState((prev) => ({
+ ...prev,
+ [item.Id!]: seasonIdx,
+ }));
+ },
+ [item.Id, setSeasonIndexState],
+ );
+
+ // Season options for the modal
+ const seasonOptions = useMemo(() => {
+ return seasons.map((season: BaseItemDto) => ({
+ label: season.Name || `Season ${season.IndexNumber}`,
+ value: season.IndexNumber ?? 0,
+ selected:
+ season.IndexNumber === selectedSeasonIndex ||
+ season.Name === String(selectedSeasonIndex),
+ }));
+ }, [seasons, selectedSeasonIndex]);
+
+ // Open season modal
+ const handleOpenSeasonModal = useCallback(() => {
+ if (!item.Id) return;
+ showSeasonModal({
+ seasons: seasonOptions,
+ selectedSeasonIndex,
+ itemId: item.Id,
+ onSeasonSelect: handleSeasonSelect,
+ });
+ }, [
+ item.Id,
+ seasonOptions,
+ selectedSeasonIndex,
+ handleSeasonSelect,
+ showSeasonModal,
+ ]);
+
+ // Get play button text
+ const playButtonText = useMemo(() => {
+ if (!nextUnwatchedEpisode) return t("common.play");
+
+ const season = nextUnwatchedEpisode.ParentIndexNumber;
+ const episode = nextUnwatchedEpisode.IndexNumber;
+ const hasProgress =
+ (nextUnwatchedEpisode.UserData?.PlaybackPositionTicks ?? 0) > 0;
+
+ if (hasProgress) {
+ return `${t("home.continue")} S${season}:E${episode}`;
+ }
+ return `${t("common.play")} S${season}:E${episode}`;
+ }, [nextUnwatchedEpisode, t]);
+
+ if (!item) return null;
+
+ return (
+
+ {/* Full-screen backdrop */}
+
+
+ {/* Gradient overlays for readability */}
+
+
+
+
+ {/* Main content */}
+
+ {/* Top section - Content + Poster */}
+
+ {/* Left side - Content */}
+
+
+
+ {/* Action buttons */}
+
+
+
+
+ {playButtonText}
+
+
+
+ {seasons.length > 1 && (
+
+ )}
+
+
+
+
+
+ {/* Right side - Poster */}
+
+
+
+
+
+
+
+ {/* Episodes section */}
+
+
+ {selectedSeasonName}
+
+
+ {/* Bidirectional focus guides - stacked together above the list */}
+ {/* Downward: Play button → first episode */}
+ {firstEpisodeRef && (
+
+ )}
+ {/* Upward: episodes → Play button */}
+ {playButtonRef && (
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/components/settings/AppearanceSettings.tsx b/components/settings/AppearanceSettings.tsx
index 3953c73f4..844096177 100644
--- a/components/settings/AppearanceSettings.tsx
+++ b/components/settings/AppearanceSettings.tsx
@@ -1,9 +1,9 @@
-import { useRouter } from "expo-router";
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Linking, Switch } from "react-native";
import DisabledSetting from "@/components/settings/DisabledSetting";
+import useRouter from "@/hooks/useAppRouter";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
@@ -42,11 +42,13 @@ export const AppearanceSettings: React.FC = () => {
}
/>
-
+
- updateSettings({ showLargeHomeCarousel: value })
+ updateSettings({ mergeNextUpAndContinueWatching: value })
}
/>
@@ -57,6 +59,16 @@ export const AppearanceSettings: React.FC = () => {
title={t("home.settings.other.hide_libraries")}
showArrow
/>
+
+
+ updateSettings({ hideRemoteSessionButton: value })
+ }
+ />
+
);
diff --git a/components/settings/AudioToggles.tsx b/components/settings/AudioToggles.tsx
index ef7b42f39..93a267bdb 100644
--- a/components/settings/AudioToggles.tsx
+++ b/components/settings/AudioToggles.tsx
@@ -3,7 +3,7 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { Switch } from "react-native-gesture-handler";
-import { useSettings } from "@/utils/atoms/settings";
+import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
@@ -54,6 +54,70 @@ export const AudioToggles: React.FC = ({ ...props }) => {
];
}, [cultures, settings?.defaultAudioLanguage, t, updateSettings]);
+ const audioTranscodeModeLabels: Record = {
+ [AudioTranscodeMode.Auto]: t("home.settings.audio.transcode_mode.auto"),
+ [AudioTranscodeMode.ForceStereo]: t(
+ "home.settings.audio.transcode_mode.stereo",
+ ),
+ [AudioTranscodeMode.Allow51]: t("home.settings.audio.transcode_mode.5_1"),
+ [AudioTranscodeMode.AllowAll]: t(
+ "home.settings.audio.transcode_mode.passthrough",
+ ),
+ };
+
+ const audioTranscodeModeOptions = useMemo(
+ () => [
+ {
+ options: [
+ {
+ type: "radio" as const,
+ label: t("home.settings.audio.transcode_mode.auto"),
+ value: AudioTranscodeMode.Auto,
+ selected:
+ settings?.audioTranscodeMode === AudioTranscodeMode.Auto ||
+ !settings?.audioTranscodeMode,
+ onPress: () =>
+ updateSettings({ audioTranscodeMode: AudioTranscodeMode.Auto }),
+ },
+ {
+ type: "radio" as const,
+ label: t("home.settings.audio.transcode_mode.stereo"),
+ value: AudioTranscodeMode.ForceStereo,
+ selected:
+ settings?.audioTranscodeMode === AudioTranscodeMode.ForceStereo,
+ onPress: () =>
+ updateSettings({
+ audioTranscodeMode: AudioTranscodeMode.ForceStereo,
+ }),
+ },
+ {
+ type: "radio" as const,
+ label: t("home.settings.audio.transcode_mode.5_1"),
+ value: AudioTranscodeMode.Allow51,
+ selected:
+ settings?.audioTranscodeMode === AudioTranscodeMode.Allow51,
+ onPress: () =>
+ updateSettings({
+ audioTranscodeMode: AudioTranscodeMode.Allow51,
+ }),
+ },
+ {
+ type: "radio" as const,
+ label: t("home.settings.audio.transcode_mode.passthrough"),
+ value: AudioTranscodeMode.AllowAll,
+ selected:
+ settings?.audioTranscodeMode === AudioTranscodeMode.AllowAll,
+ onPress: () =>
+ updateSettings({
+ audioTranscodeMode: AudioTranscodeMode.AllowAll,
+ }),
+ },
+ ],
+ },
+ ],
+ [settings?.audioTranscodeMode, t, updateSettings],
+ );
+
if (isTv) return null;
if (!settings) return null;
@@ -98,6 +162,31 @@ export const AudioToggles: React.FC = ({ ...props }) => {
title={t("home.settings.audio.language")}
/>
+
+
+
+ {
+ audioTranscodeModeLabels[
+ settings?.audioTranscodeMode || AudioTranscodeMode.Auto
+ ]
+ }
+
+
+
+ }
+ title={t("home.settings.audio.transcode_mode.title")}
+ />
+
);
diff --git a/components/settings/Dashboard.tsx b/components/settings/Dashboard.tsx
deleted file mode 100644
index 768c6e0bc..000000000
--- a/components/settings/Dashboard.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { useRouter } from "expo-router";
-import { useTranslation } from "react-i18next";
-import { View } from "react-native";
-import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
-import { useSettings } from "@/utils/atoms/settings";
-import { ListGroup } from "../list/ListGroup";
-import { ListItem } from "../list/ListItem";
-
-export const Dashboard = () => {
- const { settings } = useSettings();
- const { sessions = [] } = useSessions({} as useSessionsProps);
- const router = useRouter();
-
- const { t } = useTranslation();
-
- if (!settings) return null;
- return (
-
-
- router.push("/settings/dashboard/sessions")}
- title={t("home.settings.dashboard.sessions_title")}
- showArrow
- />
-
-
- );
-};
diff --git a/components/settings/DisabledSetting.tsx b/components/settings/DisabledSetting.tsx
index 04e24f867..2775331be 100644
--- a/components/settings/DisabledSetting.tsx
+++ b/components/settings/DisabledSetting.tsx
@@ -11,12 +11,12 @@ const DisabledSetting: React.FC<
}}
>
+ {children}
{disabled && showText && (
-
- {text ?? "Currently disabled by admin."}
+
+ {text ?? "Disabled by admin"}
)}
- {children}
);
diff --git a/components/settings/DownloadSettings.tsx b/components/settings/DownloadSettings.tsx
deleted file mode 100644
index 3a0017ac5..000000000
--- a/components/settings/DownloadSettings.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function DownloadSettings() {
- return null;
-}
diff --git a/components/settings/DownloadSettings.tv.tsx b/components/settings/DownloadSettings.tv.tsx
deleted file mode 100644
index 3a0017ac5..000000000
--- a/components/settings/DownloadSettings.tv.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function DownloadSettings() {
- return null;
-}
diff --git a/components/settings/GestureControls.tsx b/components/settings/GestureControls.tsx
index 55aadaa17..b9c39ef47 100644
--- a/components/settings/GestureControls.tsx
+++ b/components/settings/GestureControls.tsx
@@ -19,7 +19,9 @@ export const GestureControls: React.FC = ({ ...props }) => {
() =>
pluginSettings?.enableHorizontalSwipeSkip?.locked === true &&
pluginSettings?.enableLeftSideBrightnessSwipe?.locked === true &&
- pluginSettings?.enableRightSideVolumeSwipe?.locked === true,
+ pluginSettings?.enableRightSideVolumeSwipe?.locked === true &&
+ pluginSettings?.hideVolumeSlider?.locked === true &&
+ pluginSettings?.hideBrightnessSlider?.locked === true,
[pluginSettings],
);
@@ -77,6 +79,38 @@ export const GestureControls: React.FC = ({ ...props }) => {
}
/>
+
+
+
+ updateSettings({ hideVolumeSlider })
+ }
+ />
+
+
+
+
+ updateSettings({ hideBrightnessSlider })
+ }
+ />
+
);
diff --git a/components/settings/Jellyseerr.tsx b/components/settings/Jellyseerr.tsx
index 470d40a2e..436b46c43 100644
--- a/components/settings/Jellyseerr.tsx
+++ b/components/settings/Jellyseerr.tsx
@@ -115,9 +115,6 @@ export const JellyseerrSettings = () => {
>
) : (
-
- {t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
-
{t("home.settings.plugins.jellyseerr.server_url")}
diff --git a/components/settings/KefinTweaks.tsx b/components/settings/KefinTweaks.tsx
new file mode 100644
index 000000000..228b46921
--- /dev/null
+++ b/components/settings/KefinTweaks.tsx
@@ -0,0 +1,33 @@
+import { useTranslation } from "react-i18next";
+import { Switch, Text, View } from "react-native";
+import { useSettings } from "@/utils/atoms/settings";
+
+export const KefinTweaksSettings = () => {
+ const { settings, updateSettings } = useSettings();
+ const { t } = useTranslation();
+
+ const isEnabled = settings?.useKefinTweaks ?? false;
+
+ return (
+
+
+
+ {t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
+
+
+
+
+ {isEnabled ? t("Watchlist On") : t("Watchlist Off")}
+
+
+ updateSettings({ useKefinTweaks: value })}
+ trackColor={{ false: "#555", true: "purple" }}
+ thumbColor={isEnabled ? "#fff" : "#ccc"}
+ />
+
+
+
+ );
+};
diff --git a/components/settings/LibraryOptionsSheet.tsx b/components/settings/LibraryOptionsSheet.tsx
index c84989b58..e02e22fa1 100644
--- a/components/settings/LibraryOptionsSheet.tsx
+++ b/components/settings/LibraryOptionsSheet.tsx
@@ -229,7 +229,7 @@ export const LibraryOptionsSheet: React.FC = ({
/>
-
+
string;
+}
+
+function StatusDisplay({
+ currentSSID,
+ isUsingLocalUrl,
+ t,
+}: StatusDisplayProps): React.ReactElement {
+ const wifiStatus = currentSSID ?? t("home.settings.network.not_connected");
+ const urlType = isUsingLocalUrl
+ ? t("home.settings.network.local")
+ : t("home.settings.network.remote");
+ const urlTypeColor = isUsingLocalUrl ? "text-green-500" : "text-blue-500";
+
+ return (
+
+
+
+ {t("home.settings.network.current_wifi")}
+
+ {wifiStatus}
+
+
+
+ {t("home.settings.network.using_url")}
+
+ {urlType}
+
+
+ );
+}
+
+export function LocalNetworkSettings(): React.ReactElement | null {
+ const { t } = useTranslation();
+ const { permissionStatus, requestPermission } = useWifiSSID();
+ const { isUsingLocalUrl, currentSSID, refreshUrlState } = useServerUrl();
+
+ const remoteUrl = storage.getString("serverUrl");
+ const [config, setConfig] = useState(DEFAULT_CONFIG);
+
+ useEffect(() => {
+ if (remoteUrl) {
+ const existingConfig = getServerLocalConfig(remoteUrl);
+ if (existingConfig) {
+ setConfig(existingConfig);
+ }
+ }
+ }, [remoteUrl]);
+
+ const saveConfig = useCallback(
+ (newConfig: LocalNetworkConfig) => {
+ if (!remoteUrl) return;
+ setConfig(newConfig);
+ updateServerLocalConfig(remoteUrl, newConfig);
+ // Trigger URL re-evaluation after config change
+ refreshUrlState();
+ },
+ [remoteUrl, refreshUrlState],
+ );
+
+ const handleToggleEnabled = useCallback(
+ async (enabled: boolean) => {
+ if (enabled && permissionStatus !== "granted") {
+ const granted = await requestPermission();
+ if (!granted) {
+ toast.error(t("home.settings.network.permission_denied"));
+ return;
+ }
+ }
+ saveConfig({ ...config, enabled });
+ },
+ [config, permissionStatus, requestPermission, saveConfig, t],
+ );
+
+ const handleLocalUrlChange = useCallback(
+ (localUrl: string) => {
+ saveConfig({ ...config, localUrl });
+ },
+ [config, saveConfig],
+ );
+
+ const handleAddCurrentNetwork = useCallback(() => {
+ if (!currentSSID) {
+ toast.error(t("home.settings.network.no_wifi_connected"));
+ return;
+ }
+ if (config.homeWifiSSIDs.includes(currentSSID)) {
+ toast.info(t("home.settings.network.network_already_added"));
+ return;
+ }
+ saveConfig({
+ ...config,
+ homeWifiSSIDs: [...config.homeWifiSSIDs, currentSSID],
+ });
+ toast.success(t("home.settings.network.network_added"));
+ }, [config, currentSSID, saveConfig, t]);
+
+ const handleRemoveNetwork = useCallback(
+ (ssidToRemove: string) => {
+ saveConfig({
+ ...config,
+ homeWifiSSIDs: config.homeWifiSSIDs.filter((s) => s !== ssidToRemove),
+ });
+ },
+ [config, saveConfig],
+ );
+
+ if (!remoteUrl) return null;
+
+ const addNetworkButtonText = currentSSID
+ ? t("home.settings.network.add_current_network", { ssid: currentSSID })
+ : t("home.settings.network.not_connected_to_wifi");
+
+ return (
+
+
+
+
+
+
+
+ {config.enabled && (
+
+
+ {t("home.settings.network.local_url_hint")}
+
+ }
+ >
+
+
+
+
+
+
+ {config.homeWifiSSIDs.map((wifiSSID) => (
+
+ handleRemoveNetwork(wifiSSID)}
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
+ >
+
+
+
+ ))}
+ {config.homeWifiSSIDs.length === 0 && (
+
+ )}
+
+
+
+
+
+
+
+
+ )}
+
+ {permissionStatus === "denied" && (
+
+
+ {t("home.settings.network.permission_denied_explanation")}
+
+
+ )}
+
+ );
+}
diff --git a/components/settings/MediaContext.tsx b/components/settings/MediaContext.tsx
index c6c7856e7..67bcf8ea0 100644
--- a/components/settings/MediaContext.tsx
+++ b/components/settings/MediaContext.tsx
@@ -4,9 +4,10 @@ import type {
UserDto,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getLocalizationApi, getUserApi } from "@jellyfin/sdk/lib/utils/api";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { createContext, type ReactNode, useContext, useEffect } from "react";
+import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { apiAtom } from "@/providers/JellyfinProvider";
import { type Settings, useSettings } from "@/utils/atoms/settings";
@@ -30,7 +31,7 @@ export const useMedia = () => {
export const MediaProvider = ({ children }: { children: ReactNode }) => {
const { settings, updateSettings } = useSettings();
const api = useAtomValue(apiAtom);
- const queryClient = useQueryClient();
+ const queryClient = useNetworkAwareQueryClient();
const updateSetingsWrapper = (update: Partial) => {
const updateUserConfiguration = async (
diff --git a/components/settings/MpvBufferSettings.tsx b/components/settings/MpvBufferSettings.tsx
new file mode 100644
index 000000000..6df374123
--- /dev/null
+++ b/components/settings/MpvBufferSettings.tsx
@@ -0,0 +1,100 @@
+import { Ionicons } from "@expo/vector-icons";
+import type React from "react";
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { View } from "react-native";
+import { Stepper } from "@/components/inputs/Stepper";
+import { PlatformDropdown } from "@/components/PlatformDropdown";
+import { type MpvCacheMode, useSettings } from "@/utils/atoms/settings";
+import { Text } from "../common/Text";
+import { ListGroup } from "../list/ListGroup";
+import { ListItem } from "../list/ListItem";
+
+const CACHE_MODE_OPTIONS: { key: string; value: MpvCacheMode }[] = [
+ { key: "home.settings.buffer.cache_auto", value: "auto" },
+ { key: "home.settings.buffer.cache_yes", value: "yes" },
+ { key: "home.settings.buffer.cache_no", value: "no" },
+];
+
+export const MpvBufferSettings: React.FC = () => {
+ const { settings, updateSettings } = useSettings();
+ const { t } = useTranslation();
+
+ const cacheModeOptions = useMemo(
+ () => [
+ {
+ options: CACHE_MODE_OPTIONS.map((option) => ({
+ type: "radio" as const,
+ label: t(option.key),
+ value: option.value,
+ selected: option.value === (settings?.mpvCacheEnabled ?? "auto"),
+ onPress: () => updateSettings({ mpvCacheEnabled: option.value }),
+ })),
+ },
+ ],
+ [settings?.mpvCacheEnabled, t, updateSettings],
+ );
+
+ const currentCacheModeLabel = useMemo(() => {
+ const option = CACHE_MODE_OPTIONS.find(
+ (o) => o.value === (settings?.mpvCacheEnabled ?? "auto"),
+ );
+ return option ? t(option.key) : t("home.settings.buffer.cache_auto");
+ }, [settings?.mpvCacheEnabled, t]);
+
+ if (!settings) return null;
+
+ return (
+
+
+
+
+ {currentCacheModeLabel}
+
+
+
+ }
+ title={t("home.settings.buffer.cache_mode")}
+ />
+
+
+
+ updateSettings({ mpvCacheSeconds: value })}
+ appendValue='s'
+ />
+
+
+
+ updateSettings({ mpvDemuxerMaxBytes: value })}
+ appendValue=' MB'
+ />
+
+
+
+
+ updateSettings({ mpvDemuxerMaxBackBytes: value })
+ }
+ appendValue=' MB'
+ />
+
+
+ );
+};
diff --git a/components/settings/MpvSubtitleSettings.tsx b/components/settings/MpvSubtitleSettings.tsx
new file mode 100644
index 000000000..4ef2a8001
--- /dev/null
+++ b/components/settings/MpvSubtitleSettings.tsx
@@ -0,0 +1,150 @@
+import { Ionicons } from "@expo/vector-icons";
+import { useMemo } from "react";
+import { Platform, Switch, View, type ViewProps } from "react-native";
+import { Stepper } from "@/components/inputs/Stepper";
+import { Text } from "../common/Text";
+import { ListGroup } from "../list/ListGroup";
+import { ListItem } from "../list/ListItem";
+import { PlatformDropdown } from "../PlatformDropdown";
+import { useMedia } from "./MediaContext";
+
+interface Props extends ViewProps {}
+
+type AlignX = "left" | "center" | "right";
+type AlignY = "top" | "center" | "bottom";
+
+export const MpvSubtitleSettings: React.FC = ({ ...props }) => {
+ const isTv = Platform.isTV;
+ const media = useMedia();
+ const { settings, updateSettings } = media;
+
+ const alignXOptions: AlignX[] = ["left", "center", "right"];
+ const alignYOptions: AlignY[] = ["top", "center", "bottom"];
+
+ const alignXLabels: Record = {
+ left: "Left",
+ center: "Center",
+ right: "Right",
+ };
+
+ const alignYLabels: Record = {
+ top: "Top",
+ center: "Center",
+ bottom: "Bottom",
+ };
+
+ const alignXOptionGroups = useMemo(() => {
+ const options = alignXOptions.map((align) => ({
+ type: "radio" as const,
+ label: alignXLabels[align],
+ value: align,
+ selected: align === (settings?.mpvSubtitleAlignX ?? "center"),
+ onPress: () => updateSettings({ mpvSubtitleAlignX: align }),
+ }));
+ return [{ options }];
+ }, [settings?.mpvSubtitleAlignX, updateSettings]);
+
+ const alignYOptionGroups = useMemo(() => {
+ const options = alignYOptions.map((align) => ({
+ type: "radio" as const,
+ label: alignYLabels[align],
+ value: align,
+ selected: align === (settings?.mpvSubtitleAlignY ?? "bottom"),
+ onPress: () => updateSettings({ mpvSubtitleAlignY: align }),
+ }));
+ return [{ options }];
+ }, [settings?.mpvSubtitleAlignY, updateSettings]);
+
+ if (!settings) return null;
+
+ return (
+
+
+ Advanced subtitle customization for MPV player
+
+ }
+ >
+ {!isTv && (
+ <>
+
+
+ updateSettings({ mpvSubtitleMarginY: value })
+ }
+ />
+
+
+
+
+
+ {alignXLabels[settings?.mpvSubtitleAlignX ?? "center"]}
+
+
+
+ }
+ title='Horizontal Alignment'
+ />
+
+
+
+
+
+ {alignYLabels[settings?.mpvSubtitleAlignY ?? "bottom"]}
+
+
+
+ }
+ title='Vertical Alignment'
+ />
+
+ >
+ )}
+
+
+
+ updateSettings({ mpvSubtitleBackgroundEnabled: value })
+ }
+ />
+
+
+ {settings.mpvSubtitleBackgroundEnabled && (
+
+
+ updateSettings({ mpvSubtitleBackgroundOpacity: value })
+ }
+ />
+
+ )}
+
+
+ );
+};
diff --git a/components/settings/MpvVoSettings.tsx b/components/settings/MpvVoSettings.tsx
new file mode 100644
index 000000000..164829c41
--- /dev/null
+++ b/components/settings/MpvVoSettings.tsx
@@ -0,0 +1,66 @@
+import { Ionicons } from "@expo/vector-icons";
+import type React from "react";
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { Platform, View } from "react-native";
+import { PlatformDropdown } from "@/components/PlatformDropdown";
+import { type MpvVoDriver, useSettings } from "@/utils/atoms/settings";
+import { Text } from "../common/Text";
+import { ListGroup } from "../list/ListGroup";
+import { ListItem } from "../list/ListItem";
+
+const VO_DRIVER_OPTIONS: { key: string; value: MpvVoDriver }[] = [
+ { key: "home.settings.vo_driver.gpu_next", value: "gpu-next" },
+ { key: "home.settings.vo_driver.gpu", value: "gpu" },
+];
+
+export const MpvVoSettings: React.FC = () => {
+ const { settings, updateSettings } = useSettings();
+ const { t } = useTranslation();
+
+ const voDriverOptions = useMemo(
+ () => [
+ {
+ options: VO_DRIVER_OPTIONS.map((option) => ({
+ type: "radio" as const,
+ label: t(option.key),
+ value: option.value,
+ selected: option.value === (settings?.mpvVoDriver ?? "gpu-next"),
+ onPress: () => updateSettings({ mpvVoDriver: option.value }),
+ })),
+ },
+ ],
+ [settings?.mpvVoDriver, t, updateSettings],
+ );
+
+ const currentVoDriverLabel = useMemo(() => {
+ const option = VO_DRIVER_OPTIONS.find(
+ (o) => o.value === (settings?.mpvVoDriver ?? "gpu-next"),
+ );
+ return option ? t(option.key) : t("home.settings.vo_driver.gpu_next");
+ }, [settings?.mpvVoDriver, t]);
+
+ // Only show on Android
+ if (Platform.OS !== "android") return null;
+
+ if (!settings) return null;
+
+ return (
+
+
+
+
+ {currentVoDriverLabel}
+
+
+
+ }
+ title={t("home.settings.vo_driver.vo_mode")}
+ />
+
+
+ );
+};
diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx
index 452edab0a..bd339c543 100644
--- a/components/settings/OtherSettings.tsx
+++ b/components/settings/OtherSettings.tsx
@@ -1,5 +1,4 @@
import { Ionicons } from "@expo/vector-icons";
-import { useRouter } from "expo-router";
import { TFunction } from "i18next";
import type React from "react";
import { useMemo } from "react";
@@ -8,6 +7,7 @@ import { Linking, Switch, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import DisabledSetting from "@/components/settings/DisabledSetting";
+import useRouter from "@/hooks/useAppRouter";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
@@ -141,36 +141,6 @@ export const OtherSettings: React.FC = () => {
/>
- {/* {(Platform.OS === "ios" || Platform.isTVOS)&& (
-
- t(`home.settings.other.video_players.${VideoPlayer[item]}`)}
- title={
-
-
- {t(`home.settings.other.video_players.${VideoPlayer[settings.defaultPlayer]}`)}
-
-
-
- }
- label={t("home.settings.other.orientation")}
- onSelected={(defaultPlayer) =>
- updateSettings({ defaultPlayer })
- }
- />
-
- )} */}
-
{
}
/>
-
-
- updateSettings({ showLargeHomeCarousel: value })
- }
- />
-
router.push("/settings/hide-libraries/page")}
title={t("home.settings.other.hide_libraries")}
@@ -234,7 +196,10 @@ export const OtherSettings: React.FC = () => {
}
/>
-
+
{
const orientations = [
ScreenOrientation.OrientationLock.DEFAULT,
ScreenOrientation.OrientationLock.PORTRAIT_UP,
+ ScreenOrientation.OrientationLock.LANDSCAPE,
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
];
@@ -38,6 +40,8 @@ export const PlaybackControlsSettings: React.FC = () => {
"home.settings.other.orientations.DEFAULT",
[ScreenOrientation.OrientationLock.PORTRAIT_UP]:
"home.settings.other.orientations.PORTRAIT_UP",
+ [ScreenOrientation.OrientationLock.LANDSCAPE]:
+ "home.settings.other.orientations.LANDSCAPE",
[ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]:
"home.settings.other.orientations.LANDSCAPE_LEFT",
[ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]:
@@ -92,6 +96,21 @@ export const PlaybackControlsSettings: React.FC = () => {
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
);
+ const playbackSpeedOptions = useMemo(
+ () => [
+ {
+ options: PLAYBACK_SPEEDS.map((speed) => ({
+ type: "radio" as const,
+ label: speed.label,
+ value: speed.value,
+ selected: speed.value === settings?.defaultPlaybackSpeed,
+ onPress: () => updateSettings({ defaultPlaybackSpeed: speed.value }),
+ })),
+ },
+ ],
+ [settings?.defaultPlaybackSpeed, updateSettings],
+ );
+
if (!settings) return null;
return (
@@ -158,6 +177,30 @@ export const PlaybackControlsSettings: React.FC = () => {
/>
+
+
+
+ {PLAYBACK_SPEEDS.find(
+ (s) => s.value === settings.defaultPlaybackSpeed,
+ )?.label ?? "1x"}
+
+
+
+ }
+ title={t("home.settings.other.default_playback_speed")}
+ />
+
+
{
/>
-
+
+
+ updateSettings({ autoPlayNextEpisode })
+ }
+ />
+
+
+
{
title='Marlin Search'
showArrow
/>
+ router.push("/settings/plugins/streamystats/page")}
+ title='Streamystats'
+ showArrow
+ />
+ router.push("/settings/plugins/kefinTweaks/page")}
+ title='KefinTweaks'
+ showArrow
+ />
);
};
diff --git a/components/settings/QuickConnect.tsx b/components/settings/QuickConnect.tsx
index d0cbf85e0..643a902d2 100644
--- a/components/settings/QuickConnect.tsx
+++ b/components/settings/QuickConnect.tsx
@@ -7,7 +7,7 @@ import {
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai";
import type React from "react";
-import { useCallback, useRef, useState } from "react";
+import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform, View, type ViewProps } from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
@@ -28,6 +28,11 @@ export const QuickConnect: React.FC = ({ ...props }) => {
const bottomSheetModalRef = useRef(null);
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
+ const snapPoints = useMemo(
+ () => (Platform.OS === "android" ? ["100%"] : ["40%"]),
+ [],
+ );
+ const isAndroid = Platform.OS === "android";
const { t } = useTranslation();
@@ -53,7 +58,7 @@ export const QuickConnect: React.FC = ({ ...props }) => {
successHapticFeedback();
Alert.alert(
t("home.settings.quick_connect.success"),
- t("home.settings.quick_connect.quick_connect_autorized"),
+ t("home.settings.quick_connect.quick_connect_authorized"),
);
setQuickConnectCode(undefined);
bottomSheetModalRef?.current?.close();
@@ -92,7 +97,7 @@ export const QuickConnect: React.FC = ({ ...props }) => {
= ({ ...props }) => {
backgroundColor: "#171717",
}}
backdropComponent={renderBackdrop}
- keyboardBehavior='interactive'
+ keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
keyboardBlurBehavior='restore'
android_keyboardInputMode='adjustResize'
+ topInset={isAndroid ? 0 : undefined}
>
diff --git a/components/settings/StorageSettings.tsx b/components/settings/StorageSettings.tsx
index da985da51..b7f5d8554 100644
--- a/components/settings/StorageSettings.tsx
+++ b/components/settings/StorageSettings.tsx
@@ -1,8 +1,8 @@
import { BottomSheetModal } from "@gorhom/bottom-sheet";
-import { useQuery } from "@tanstack/react-query";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
-import { Platform, View } from "react-native";
+import { Alert, Platform, View } from "react-native";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
@@ -18,6 +18,7 @@ export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload();
const { settings } = useSettings();
const { t } = useTranslation();
+ const queryClient = useQueryClient();
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const bottomSheetModalRef = useRef(null);
@@ -34,6 +35,8 @@ export const StorageSettings = () => {
used: (app.total - app.remaining) / app.total,
};
},
+ // Keep the bar moving while a download is writing to disk.
+ refetchInterval: 10 * 1000,
});
const { data: storageLabel } = useQuery({
@@ -42,14 +45,34 @@ export const StorageSettings = () => {
enabled: Platform.OS === "android",
});
- const onDeleteClicked = async () => {
- try {
- await deleteAllFiles();
- successHapticFeedback();
- } catch (_e) {
- errorHapticFeedback();
- toast.error(t("home.settings.toasts.error_deleting_files"));
- }
+ const onDeleteClicked = () => {
+ Alert.alert(
+ t("home.settings.storage.delete_all_downloaded_files_confirm"),
+ t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
+ [
+ {
+ text: t("common.cancel"),
+ style: "cancel",
+ },
+ {
+ text: t("common.ok"),
+ style: "destructive",
+ onPress: async () => {
+ try {
+ await deleteAllFiles();
+ successHapticFeedback();
+ } catch (_e) {
+ errorHapticFeedback();
+ toast.error(t("home.settings.toasts.error_deleting_files"));
+ } finally {
+ // Reflect the freed space immediately instead of waiting for
+ // the next poll.
+ queryClient.invalidateQueries({ queryKey: ["appSize"] });
+ }
+ },
+ },
+ ],
+ );
};
const calculatePercentage = (value: number, total: number) => {
diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx
index 5a6bfee33..77f5453e5 100644
--- a/components/settings/SubtitleToggles.tsx
+++ b/components/settings/SubtitleToggles.tsx
@@ -1,16 +1,11 @@
import { Ionicons } from "@expo/vector-icons";
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
-import { useMemo } from "react";
+import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { Switch } from "react-native-gesture-handler";
+import { Input } from "@/components/common/Input";
import { Stepper } from "@/components/inputs/Stepper";
-import {
- OUTLINE_THICKNESS,
- type OutlineThickness,
- VLC_COLORS,
- type VLCColor,
-} from "@/constants/SubtitleConstants";
import { useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
@@ -29,6 +24,11 @@ export const SubtitleToggles: React.FC = ({ ...props }) => {
const cultures = media.cultures;
const { t } = useTranslation();
+ // Local state for OpenSubtitles API key (only commit on blur)
+ const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
+ settings?.openSubtitlesApiKey || "",
+ );
+
const subtitleModes = [
SubtitlePlaybackMode.Default,
SubtitlePlaybackMode.Smart,
@@ -92,84 +92,6 @@ export const SubtitleToggles: React.FC = ({ ...props }) => {
];
}, [settings?.subtitleMode, t, updateSettings]);
- const textColorOptionGroups = useMemo(() => {
- const colors = Object.keys(VLC_COLORS) as VLCColor[];
- const options = colors.map((color) => ({
- type: "radio" as const,
- label: t(`home.settings.subtitles.colors.${color}`),
- value: color,
- selected: (settings?.vlcTextColor || "White") === color,
- onPress: () => updateSettings({ vlcTextColor: color }),
- }));
-
- return [{ options }];
- }, [settings?.vlcTextColor, t, updateSettings]);
-
- const backgroundColorOptionGroups = useMemo(() => {
- const colors = Object.keys(VLC_COLORS) as VLCColor[];
- const options = colors.map((color) => ({
- type: "radio" as const,
- label: t(`home.settings.subtitles.colors.${color}`),
- value: color,
- selected: (settings?.vlcBackgroundColor || "Black") === color,
- onPress: () => updateSettings({ vlcBackgroundColor: color }),
- }));
-
- return [{ options }];
- }, [settings?.vlcBackgroundColor, t, updateSettings]);
-
- const outlineColorOptionGroups = useMemo(() => {
- const colors = Object.keys(VLC_COLORS) as VLCColor[];
- const options = colors.map((color) => ({
- type: "radio" as const,
- label: t(`home.settings.subtitles.colors.${color}`),
- value: color,
- selected: (settings?.vlcOutlineColor || "Black") === color,
- onPress: () => updateSettings({ vlcOutlineColor: color }),
- }));
-
- return [{ options }];
- }, [settings?.vlcOutlineColor, t, updateSettings]);
-
- const outlineThicknessOptionGroups = useMemo(() => {
- const thicknesses = Object.keys(OUTLINE_THICKNESS) as OutlineThickness[];
- const options = thicknesses.map((thickness) => ({
- type: "radio" as const,
- label: t(`home.settings.subtitles.thickness.${thickness}`),
- value: thickness,
- selected: (settings?.vlcOutlineThickness || "Normal") === thickness,
- onPress: () => updateSettings({ vlcOutlineThickness: thickness }),
- }));
-
- return [{ options }];
- }, [settings?.vlcOutlineThickness, t, updateSettings]);
-
- const backgroundOpacityOptionGroups = useMemo(() => {
- const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
- const options = opacities.map((opacity) => ({
- type: "radio" as const,
- label: `${Math.round((opacity / 255) * 100)}%`,
- value: opacity,
- selected: (settings?.vlcBackgroundOpacity ?? 128) === opacity,
- onPress: () => updateSettings({ vlcBackgroundOpacity: opacity }),
- }));
-
- return [{ options }];
- }, [settings?.vlcBackgroundOpacity, updateSettings]);
-
- const outlineOpacityOptionGroups = useMemo(() => {
- const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
- const options = opacities.map((opacity) => ({
- type: "radio" as const,
- label: `${Math.round((opacity / 255) * 100)}%`,
- value: opacity,
- selected: (settings?.vlcOutlineOpacity ?? 255) === opacity,
- onPress: () => updateSettings({ vlcOutlineOpacity: opacity }),
- }));
-
- return [{ options }];
- }, [settings?.vlcOutlineOpacity, updateSettings]);
-
if (isTv) return null;
if (!settings) return null;
@@ -244,132 +166,54 @@ export const SubtitleToggles: React.FC = ({ ...props }) => {
disabled={pluginSettings?.subtitleSize?.locked}
>
updateSettings({ subtitleSize })}
- />
-
-
-
-
- {t(
- `home.settings.subtitles.colors.${settings?.vlcTextColor || "White"}`,
- )}
-
-
-
+ step={0.1}
+ min={0.1}
+ max={3.0}
+ onUpdate={(value) =>
+ updateSettings({ mpvSubtitleScale: Math.round(value * 10) / 10 })
}
- title={t("home.settings.subtitles.text_color")}
/>
-
-
-
- {t(
- `home.settings.subtitles.colors.${settings?.vlcBackgroundColor || "Black"}`,
- )}
-
-
-
+
+
+ {/* OpenSubtitles API Key for client-side subtitle fetching */}
+
+ {t("home.settings.subtitles.opensubtitles_hint") ||
+ "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured."}
+
+ }
+ >
+
+
+ {t("home.settings.subtitles.opensubtitles_api_key") || "API Key"}
+
+ {
+ updateSettings({ openSubtitlesApiKey });
+ }}
+ autoCapitalize='none'
+ autoCorrect={false}
+ secureTextEntry
/>
-
-
-
-
- {t(
- `home.settings.subtitles.colors.${settings?.vlcOutlineColor || "Black"}`,
- )}
-
-
-
- }
- title={t("home.settings.subtitles.outline_color")}
- />
-
-
-
-
- {t(
- `home.settings.subtitles.thickness.${settings?.vlcOutlineThickness || "Normal"}`,
- )}
-
-
-
- }
- title={t("home.settings.subtitles.outline_thickness")}
- />
-
-
-
- {`${Math.round(((settings?.vlcBackgroundOpacity ?? 128) / 255) * 100)}%`}
-
-
- }
- title={t("home.settings.subtitles.background_opacity")}
- />
-
-
-
- {`${Math.round(((settings?.vlcOutlineOpacity ?? 255) / 255) * 100)}%`}
-
-
- }
- title={t("home.settings.subtitles.outline_opacity")}
- />
-
-
- updateSettings({ vlcIsBold: value })}
- />
-
+
+ {t("home.settings.subtitles.opensubtitles_get_key") ||
+ "Get your free API key at opensubtitles.com/en/consumers"}
+
+
);
diff --git a/components/settings/UserInfo.tsx b/components/settings/UserInfo.tsx
index 56b6413d2..9c55293d8 100644
--- a/components/settings/UserInfo.tsx
+++ b/components/settings/UserInfo.tsx
@@ -1,8 +1,8 @@
-import * as Application from "expo-application";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { getVersionInfo } from "@/utils/version";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
@@ -13,10 +13,9 @@ export const UserInfo: React.FC = ({ ...props }) => {
const [user] = useAtom(userAtom);
const { t } = useTranslation();
- const version =
- Application?.nativeApplicationVersion ||
- Application?.nativeBuildVersion ||
- "N/A";
+ // Graduated build identifier — see utils/version.ts:
+ // dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit · #run", production → "0.54.1".
+ const { display: version } = getVersionInfo();
return (
diff --git a/components/stacks/NestedTabPageStack.tsx b/components/stacks/NestedTabPageStack.tsx
index 87f92db60..3c5fa57f1 100644
--- a/components/stacks/NestedTabPageStack.tsx
+++ b/components/stacks/NestedTabPageStack.tsx
@@ -1,25 +1,27 @@
-import type { ParamListBase, RouteProp } from "@react-navigation/native";
-import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
+import { Stack } from "expo-router";
+import type { ComponentProps } from "react";
import { Platform } from "react-native";
import { HeaderBackButton } from "../common/HeaderBackButton";
-type ICommonScreenOptions =
- | NativeStackNavigationOptions
- | ((prop: {
- route: RouteProp;
- navigation: any;
- }) => NativeStackNavigationOptions);
+type ICommonScreenOptions = ComponentProps["options"];
export const commonScreenOptions: ICommonScreenOptions = {
title: "",
- headerShown: true,
+ headerShown: !Platform.isTV,
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerBlurEffect: "none",
headerLeft: () => ,
};
-const routes = ["persons/[personId]", "items/page", "series/[id]"];
+const routes = [
+ "persons/[personId]",
+ "items/page",
+ "series/[id]",
+ "music/album/[albumId]",
+ "music/artist/[artistId]",
+ "music/playlist/[playlistId]",
+];
export const nestedTabPageScreenOptions: Record =
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));
diff --git a/components/tv/TVActorCard.tsx b/components/tv/TVActorCard.tsx
new file mode 100644
index 000000000..31e4be636
--- /dev/null
+++ b/components/tv/TVActorCard.tsx
@@ -0,0 +1,119 @@
+import { Ionicons } from "@expo/vector-icons";
+import { Image } from "expo-image";
+import React from "react";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVActorCardProps {
+ person: {
+ Id?: string | null;
+ Name?: string | null;
+ Role?: string | null;
+ };
+ apiBasePath?: string;
+ onPress: () => void;
+ hasTVPreferredFocus?: boolean;
+}
+
+export const TVActorCard = React.forwardRef(
+ ({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation();
+
+ const imageUrl = person.Id
+ ? `${apiBasePath}/Items/${person.Id}/Images/Primary?fillWidth=280&fillHeight=280&quality=90`
+ : null;
+
+ return (
+
+
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {person.Name}
+
+
+ {person.Role && (
+
+ {person.Role}
+
+ )}
+
+
+ );
+ },
+);
diff --git a/components/tv/TVBackdrop.tsx b/components/tv/TVBackdrop.tsx
new file mode 100644
index 000000000..315afe41a
--- /dev/null
+++ b/components/tv/TVBackdrop.tsx
@@ -0,0 +1,56 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { LinearGradient } from "expo-linear-gradient";
+import React from "react";
+import { View } from "react-native";
+import { ItemImage } from "@/components/common/ItemImage";
+
+export interface TVBackdropProps {
+ item: BaseItemDto;
+}
+
+export const TVBackdrop: React.FC = React.memo(({ item }) => {
+ return (
+
+
+ {/* Gradient overlays for readability */}
+
+
+
+ );
+});
diff --git a/components/tv/TVButton.tsx b/components/tv/TVButton.tsx
new file mode 100644
index 000000000..606b21c84
--- /dev/null
+++ b/components/tv/TVButton.tsx
@@ -0,0 +1,116 @@
+import React from "react";
+import { Animated, Pressable, View, type ViewStyle } from "react-native";
+import { scaleSize } from "@/utils/scaleSize";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVButtonProps {
+ onPress: () => void;
+ children: React.ReactNode;
+ variant?: "primary" | "secondary" | "glass";
+ hasTVPreferredFocus?: boolean;
+ disabled?: boolean;
+ style?: ViewStyle;
+ scaleAmount?: number;
+ square?: boolean;
+ refSetter?: (ref: View | null) => void;
+ nextFocusDown?: number;
+ nextFocusUp?: number;
+}
+
+const getButtonStyles = (
+ variant: "primary" | "secondary" | "glass",
+ focused: boolean,
+) => {
+ switch (variant) {
+ case "glass":
+ return {
+ backgroundColor: focused
+ ? "rgba(255, 255, 255, 0.25)"
+ : "rgba(255, 255, 255, 0.1)",
+ shadowColor: "#fff",
+ borderWidth: 1,
+ borderColor: focused
+ ? "rgba(255, 255, 255, 0.4)"
+ : "rgba(255, 255, 255, 0.15)",
+ };
+ case "secondary":
+ return {
+ backgroundColor: focused
+ ? "rgba(255, 255, 255, 0.3)"
+ : "rgba(255, 255, 255, 0.15)",
+ shadowColor: "#fff",
+ borderWidth: 2,
+ borderColor: focused ? "#fff" : "rgba(255, 255, 255, 0.2)",
+ };
+ default:
+ return {
+ backgroundColor: focused ? "#ffffff" : "rgba(255, 255, 255, 0.9)",
+ shadowColor: "#fff",
+ borderWidth: 1,
+ borderColor: "transparent",
+ };
+ }
+};
+
+export const TVButton: React.FC = ({
+ onPress,
+ children,
+ variant = "primary",
+ hasTVPreferredFocus = false,
+ disabled = false,
+ style,
+ scaleAmount = 1.04,
+ square = false,
+ refSetter,
+ nextFocusDown,
+ nextFocusUp,
+}) => {
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount });
+
+ const buttonStyles = getButtonStyles(variant, focused);
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+};
diff --git a/components/tv/TVCancelButton.tsx b/components/tv/TVCancelButton.tsx
new file mode 100644
index 000000000..46316b7a2
--- /dev/null
+++ b/components/tv/TVCancelButton.tsx
@@ -0,0 +1,63 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Animated, Pressable } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVCancelButtonProps {
+ onPress: () => void;
+ label?: string;
+ disabled?: boolean;
+}
+
+export const TVCancelButton: React.FC = ({
+ onPress,
+ label = "Cancel",
+ disabled = false,
+}) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
+
+ return (
+
+
+
+
+ {label}
+
+
+
+ );
+};
diff --git a/components/tv/TVCastCrewText.tsx b/components/tv/TVCastCrewText.tsx
new file mode 100644
index 000000000..2c07b497b
--- /dev/null
+++ b/components/tv/TVCastCrewText.tsx
@@ -0,0 +1,78 @@
+import type { BaseItemPerson } from "@jellyfin/sdk/lib/generated-client/models";
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+
+export interface TVCastCrewTextProps {
+ director?: BaseItemPerson | null;
+ cast?: BaseItemPerson[];
+ /** Hide the cast section (e.g., when visual cast section is shown) */
+ hideCast?: boolean;
+}
+
+export const TVCastCrewText: React.FC = React.memo(
+ ({ director, cast, hideCast = false }) => {
+ const typography = useScaledTVTypography();
+ const { t } = useTranslation();
+
+ if (!director && (!cast || cast.length === 0)) {
+ return null;
+ }
+
+ return (
+
+
+ {t("item_card.cast_and_crew")}
+
+
+ {director && (
+
+
+ {t("item_card.director")}
+
+
+ {director.Name}
+
+
+ )}
+ {!hideCast && cast && cast.length > 0 && (
+
+
+ {t("item_card.cast")}
+
+
+ {cast.map((c) => c.Name).join(", ")}
+
+
+ )}
+
+
+ );
+ },
+);
diff --git a/components/tv/TVCastSection.tsx b/components/tv/TVCastSection.tsx
new file mode 100644
index 000000000..fa84103b3
--- /dev/null
+++ b/components/tv/TVCastSection.tsx
@@ -0,0 +1,85 @@
+import type { BaseItemPerson } from "@jellyfin/sdk/lib/generated-client/models";
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { ScrollView, TVFocusGuideView, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVSizes } from "@/constants/TVSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { TVActorCard } from "./TVActorCard";
+
+export interface TVCastSectionProps {
+ cast: BaseItemPerson[];
+ apiBasePath?: string;
+ onActorPress: (personId: string) => void;
+ /** Setter function for the first actor card ref (for focus guide) */
+ firstActorRefSetter?: (ref: View | null) => void;
+ /** Ref to focus guide destination for upward navigation */
+ upwardFocusDestination?: View | null;
+ /** Custom horizontal padding (overrides default 80) */
+ horizontalPadding?: number;
+}
+
+export const TVCastSection: React.FC = React.memo(
+ ({
+ cast,
+ apiBasePath,
+ onActorPress,
+ firstActorRefSetter,
+ upwardFocusDestination,
+ horizontalPadding = 80,
+ }) => {
+ const typography = useScaledTVTypography();
+ const sizes = useScaledTVSizes();
+ const { t } = useTranslation();
+
+ if (cast.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {t("item_card.cast")}
+
+ {/* Focus guide to direct upward navigation from cast back to options */}
+ {upwardFocusDestination && (
+
+ )}
+
+ {cast.map((person, index) => (
+ {
+ if (person.Id) {
+ onActorPress(person.Id);
+ }
+ }}
+ />
+ ))}
+
+
+ );
+ },
+);
diff --git a/components/tv/TVControlButton.tsx b/components/tv/TVControlButton.tsx
new file mode 100644
index 000000000..9870a6eda
--- /dev/null
+++ b/components/tv/TVControlButton.tsx
@@ -0,0 +1,82 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { FC } from "react";
+import {
+ Pressable,
+ Animated as RNAnimated,
+ StyleSheet,
+ type View,
+} from "react-native";
+import { scaleSize } from "@/utils/scaleSize";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVControlButtonProps {
+ icon: keyof typeof Ionicons.glyphMap;
+ onPress: () => void;
+ onLongPress?: () => void;
+ onPressOut?: () => void;
+ disabled?: boolean;
+ hasTVPreferredFocus?: boolean;
+ size?: number;
+ delayLongPress?: number;
+ /** Callback ref setter for focus guide destination pattern */
+ refSetter?: (ref: View | null) => void;
+}
+
+export const TVControlButton: FC = ({
+ icon,
+ onPress,
+ onLongPress,
+ onPressOut,
+ disabled,
+ hasTVPreferredFocus,
+ size = 32,
+ delayLongPress = 300,
+ refSetter,
+}) => {
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.15, duration: 120 });
+
+ return (
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ button: {
+ width: scaleSize(64),
+ height: scaleSize(64),
+ borderRadius: scaleSize(32),
+ borderWidth: scaleSize(2),
+ justifyContent: "center",
+ alignItems: "center",
+ },
+});
diff --git a/components/tv/TVFavoriteButton.tsx b/components/tv/TVFavoriteButton.tsx
new file mode 100644
index 000000000..330934e04
--- /dev/null
+++ b/components/tv/TVFavoriteButton.tsx
@@ -0,0 +1,32 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import React from "react";
+import { useFavorite } from "@/hooks/useFavorite";
+import { TVButton } from "./TVButton";
+
+export interface TVFavoriteButtonProps {
+ item: BaseItemDto;
+ disabled?: boolean;
+}
+
+export const TVFavoriteButton: React.FC = ({
+ item,
+ disabled,
+}) => {
+ const { isFavorite, toggleFavorite } = useFavorite(item);
+
+ return (
+
+
+
+ );
+};
diff --git a/components/tv/TVFilterButton.tsx b/components/tv/TVFilterButton.tsx
new file mode 100644
index 000000000..2075495a8
--- /dev/null
+++ b/components/tv/TVFilterButton.tsx
@@ -0,0 +1,80 @@
+import React from "react";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVFilterButtonProps {
+ label: string;
+ value: string;
+ onPress: () => void;
+ hasTVPreferredFocus?: boolean;
+ disabled?: boolean;
+ hasActiveFilter?: boolean;
+}
+
+export const TVFilterButton: React.FC = ({
+ label,
+ value,
+ onPress,
+ hasTVPreferredFocus = false,
+ disabled = false,
+ hasActiveFilter = false,
+}) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.04, duration: 120 });
+
+ return (
+
+
+
+ {label ? (
+
+ {label}
+
+ ) : null}
+
+ {value}
+
+
+
+
+ );
+};
diff --git a/components/tv/TVFocusablePoster.tsx b/components/tv/TVFocusablePoster.tsx
new file mode 100644
index 000000000..337cbc2ab
--- /dev/null
+++ b/components/tv/TVFocusablePoster.tsx
@@ -0,0 +1,89 @@
+import React, { useRef, useState } from "react";
+import {
+ Animated,
+ Easing,
+ Pressable,
+ View,
+ type ViewStyle,
+} from "react-native";
+
+export interface TVFocusablePosterProps {
+ children: React.ReactNode;
+ onPress: () => void;
+ onLongPress?: () => void;
+ hasTVPreferredFocus?: boolean;
+ glowColor?: "white" | "purple";
+ scaleAmount?: number;
+ style?: ViewStyle;
+ onFocus?: () => void;
+ onBlur?: () => void;
+ disabled?: boolean;
+ /** When true, the item remains focusable even when disabled (for navigation purposes) */
+ focusableWhenDisabled?: boolean;
+ /** Setter function for the ref (for focus guide destinations) */
+ refSetter?: (ref: View | null) => void;
+}
+
+export const TVFocusablePoster: React.FC = ({
+ children,
+ onPress,
+ onLongPress,
+ hasTVPreferredFocus = false,
+ glowColor = "white",
+ scaleAmount = 1.05,
+ style,
+ onFocus: onFocusProp,
+ onBlur: onBlurProp,
+ disabled = false,
+ focusableWhenDisabled = false,
+ refSetter,
+}) => {
+ const [focused, setFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+
+ const animateTo = (value: number) =>
+ Animated.timing(scale, {
+ toValue: value,
+ duration: 150,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start();
+
+ const shadowColor = glowColor === "white" ? "#ffffff" : "#a855f7";
+
+ return (
+ {
+ setFocused(true);
+ animateTo(scaleAmount);
+ onFocusProp?.();
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ onBlurProp?.();
+ }}
+ hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
+ disabled={disabled}
+ focusable={!disabled || focusableWhenDisabled}
+ >
+
+ {children}
+
+
+ );
+};
diff --git a/components/tv/TVFocusableProgressBar.tsx b/components/tv/TVFocusableProgressBar.tsx
new file mode 100644
index 000000000..b93eb03d5
--- /dev/null
+++ b/components/tv/TVFocusableProgressBar.tsx
@@ -0,0 +1,190 @@
+import React from "react";
+import {
+ Animated,
+ Pressable,
+ StyleSheet,
+ View,
+ type ViewStyle,
+} from "react-native";
+import type { SharedValue } from "react-native-reanimated";
+import ReanimatedModule, { useAnimatedStyle } from "react-native-reanimated";
+import { scaleSize } from "@/utils/scaleSize";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+const ReanimatedView = ReanimatedModule.View;
+
+export interface TVFocusableProgressBarProps {
+ /** Progress value (SharedValue) in milliseconds */
+ progress: SharedValue;
+ /** Maximum value in milliseconds */
+ max: SharedValue;
+ /** Cache progress value (SharedValue) in milliseconds */
+ cacheProgress?: SharedValue;
+ /** Chapter positions as percentages (0-100) for tick marks */
+ chapterPositions?: number[];
+ /** Callback when the progress bar receives focus */
+ onFocus?: () => void;
+ /** Callback when the progress bar loses focus */
+ onBlur?: () => void;
+ /** Callback ref setter for focus guide destination pattern */
+ refSetter?: (ref: View | null) => void;
+ /** Whether this component is disabled */
+ disabled?: boolean;
+ /** Whether this component should receive initial focus */
+ hasTVPreferredFocus?: boolean;
+ /** Optional style overrides */
+ style?: ViewStyle;
+}
+
+const PROGRESS_BAR_HEIGHT = scaleSize(14);
+
+export const TVFocusableProgressBar: React.FC =
+ React.memo(
+ ({
+ progress,
+ max,
+ cacheProgress,
+ chapterPositions = [],
+ onFocus,
+ onBlur,
+ refSetter,
+ disabled = false,
+ hasTVPreferredFocus = false,
+ style,
+ }) => {
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({
+ scaleAmount: 1.02,
+ duration: 120,
+ onFocus,
+ onBlur,
+ });
+
+ const progressFillStyle = useAnimatedStyle(() => ({
+ width: `${max.value > 0 ? (progress.value / max.value) * 100 : 0}%`,
+ }));
+
+ const cacheProgressStyle = useAnimatedStyle(() => ({
+ width: `${max.value > 0 && cacheProgress ? (cacheProgress.value / max.value) * 100 : 0}%`,
+ }));
+
+ return (
+
+
+
+
+ {cacheProgress && (
+
+ )}
+
+
+ {/* Chapter markers - positioned outside track to extend above */}
+ {chapterPositions.length > 0 && (
+
+ {chapterPositions.map((position, index) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+ },
+ );
+
+const styles = StyleSheet.create({
+ pressableContainer: {
+ // Add padding for focus scale animation to not clip
+ paddingVertical: scaleSize(8),
+ paddingHorizontal: scaleSize(4),
+ },
+ animatedContainer: {
+ height: PROGRESS_BAR_HEIGHT + scaleSize(8),
+ justifyContent: "center",
+ borderRadius: scaleSize(12),
+ paddingHorizontal: scaleSize(4),
+ },
+ animatedContainerFocused: {
+ // Subtle glow effect when focused
+ shadowColor: "#fff",
+ shadowOffset: { width: 0, height: 0 },
+ shadowOpacity: 0.5,
+ shadowRadius: scaleSize(12),
+ },
+ progressTrackWrapper: {
+ position: "relative",
+ height: PROGRESS_BAR_HEIGHT,
+ },
+ progressTrack: {
+ height: PROGRESS_BAR_HEIGHT,
+ backgroundColor: "rgba(255,255,255,0.2)",
+ borderRadius: scaleSize(8),
+ overflow: "hidden",
+ },
+ progressTrackFocused: {
+ // Brighter track when focused
+ backgroundColor: "rgba(255,255,255,0.35)",
+ },
+ cacheProgress: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ height: "100%",
+ backgroundColor: "rgba(255,255,255,0.3)",
+ borderRadius: scaleSize(8),
+ },
+ progressFill: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ height: "100%",
+ backgroundColor: "#fff",
+ borderRadius: scaleSize(8),
+ },
+ chapterMarkersContainer: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ },
+ chapterMarker: {
+ position: "absolute",
+ width: scaleSize(2),
+ height: PROGRESS_BAR_HEIGHT + scaleSize(5),
+ bottom: 0,
+ backgroundColor: "rgba(255, 255, 255, 0.6)",
+ borderRadius: scaleSize(1),
+ transform: [{ translateX: -scaleSize(1) }],
+ },
+});
diff --git a/components/tv/TVHorizontalList.tsx b/components/tv/TVHorizontalList.tsx
new file mode 100644
index 000000000..4bb61168e
--- /dev/null
+++ b/components/tv/TVHorizontalList.tsx
@@ -0,0 +1,222 @@
+import React, { useCallback } from "react";
+import { FlatList, ScrollView, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVSizes } from "@/constants/TVSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+
+interface TVHorizontalListProps {
+ /** Data items to render */
+ data: T[];
+ /** Unique key extractor */
+ keyExtractor: (item: T, index: number) => string;
+ /** Render function for each item */
+ renderItem: (info: { item: T; index: number }) => React.ReactElement | null;
+ /** Optional section title */
+ title?: string;
+ /** Text to show when data array is empty */
+ emptyText?: string;
+ /** Whether to use FlatList (for large/infinite lists) or ScrollView (for small lists) */
+ useFlatList?: boolean;
+ /** Called when end is reached (only for FlatList) */
+ onEndReached?: () => void;
+ /** Ref for the scroll view */
+ scrollViewRef?: React.RefObject | null>;
+ /** Footer component (only for FlatList) */
+ ListFooterComponent?: React.ReactElement | null;
+ /** Whether this is the first section (for initial focus) */
+ isFirstSection?: boolean;
+ /** Loading state */
+ isLoading?: boolean;
+ /** Skeleton item count when loading */
+ skeletonCount?: number;
+ /** Skeleton render function */
+ renderSkeleton?: () => React.ReactElement;
+ /**
+ * Custom horizontal padding (overrides default sizes.padding.scale).
+ * Use this when the list needs to extend beyond its parent's padding.
+ * The list will use negative margin to extend beyond the parent,
+ * then add this padding inside to align content properly.
+ */
+ horizontalPadding?: number;
+}
+
+/**
+ * TVHorizontalList - A unified horizontal list component for TV.
+ *
+ * Provides consistent spacing and layout for horizontal lists:
+ * - Uses `sizes.gaps.item` (24px default) for gap between items
+ * - Uses `sizes.padding.scale` (20px default) for padding to accommodate focus scale
+ * - Supports both ScrollView (small lists) and FlatList (large/infinite lists)
+ */
+export function TVHorizontalList({
+ data,
+ keyExtractor,
+ renderItem,
+ title,
+ emptyText,
+ useFlatList = false,
+ onEndReached,
+ scrollViewRef,
+ ListFooterComponent,
+ isLoading = false,
+ skeletonCount = 5,
+ renderSkeleton,
+ horizontalPadding,
+}: TVHorizontalListProps) {
+ const sizes = useScaledTVSizes();
+ const typography = useScaledTVTypography();
+
+ // Use custom horizontal padding if provided, otherwise use default scale padding
+ const effectiveHorizontalPadding = horizontalPadding ?? sizes.padding.scale;
+ // Apply negative margin when using custom padding to extend beyond parent
+ const marginHorizontal = horizontalPadding ? -horizontalPadding : 0;
+
+ // Wrap renderItem to add consistent gap
+ const renderItemWithGap = useCallback(
+ ({ item, index }: { item: T; index: number }) => {
+ const isLast = index === data.length - 1;
+ return (
+
+ {renderItem({ item, index })}
+
+ );
+ },
+ [data.length, renderItem, sizes.gaps.item],
+ );
+
+ // Empty state
+ if (!isLoading && data.length === 0 && emptyText) {
+ return (
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {emptyText}
+
+
+ );
+ }
+
+ // Loading state
+ if (isLoading && renderSkeleton) {
+ return (
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {Array.from({ length: skeletonCount }).map((_, i) => (
+ {renderSkeleton()}
+ ))}
+
+
+ );
+ }
+
+ const contentContainerStyle = {
+ paddingHorizontal: effectiveHorizontalPadding,
+ paddingVertical: sizes.padding.scale,
+ };
+
+ const listStyle = {
+ overflow: "visible" as const,
+ marginHorizontal,
+ };
+
+ return (
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {useFlatList ? (
+ >}
+ horizontal
+ data={data}
+ keyExtractor={keyExtractor}
+ renderItem={renderItemWithGap}
+ showsHorizontalScrollIndicator={false}
+ removeClippedSubviews={false}
+ style={listStyle}
+ contentContainerStyle={contentContainerStyle}
+ onEndReached={onEndReached}
+ onEndReachedThreshold={0.5}
+ initialNumToRender={5}
+ maxToRenderPerBatch={3}
+ windowSize={5}
+ maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
+ ListFooterComponent={ListFooterComponent}
+ />
+ ) : (
+ }
+ horizontal
+ showsHorizontalScrollIndicator={false}
+ style={listStyle}
+ contentContainerStyle={contentContainerStyle}
+ >
+ {data.map((item, index) => (
+
+ {renderItem({ item, index })}
+
+ ))}
+ {ListFooterComponent}
+
+ )}
+
+ );
+}
diff --git a/components/tv/TVItemCardText.tsx b/components/tv/TVItemCardText.tsx
new file mode 100644
index 000000000..c55110b36
--- /dev/null
+++ b/components/tv/TVItemCardText.tsx
@@ -0,0 +1,34 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import React from "react";
+import { View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+
+export interface TVItemCardTextProps {
+ item: BaseItemDto;
+}
+
+export const TVItemCardText: React.FC = ({ item }) => {
+ const typography = useScaledTVTypography();
+
+ return (
+
+
+ {item.Name}
+
+
+ {item.ProductionYear}
+
+
+ );
+};
diff --git a/components/tv/TVLanguageCard.tsx b/components/tv/TVLanguageCard.tsx
new file mode 100644
index 000000000..027a1ada0
--- /dev/null
+++ b/components/tv/TVLanguageCard.tsx
@@ -0,0 +1,101 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Animated, Pressable, StyleSheet, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVLanguageCardProps {
+ code: string;
+ name: string;
+ selected: boolean;
+ hasTVPreferredFocus?: boolean;
+ onPress: () => void;
+}
+
+export const TVLanguageCard = React.forwardRef(
+ ({ code, name, selected, hasTVPreferredFocus, onPress }, ref) => {
+ const typography = useScaledTVTypography();
+ const styles = createStyles(typography);
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.05 });
+
+ return (
+
+
+
+ {name}
+
+
+ {code.toUpperCase()}
+
+ {selected && !focused && (
+
+
+
+ )}
+
+
+ );
+ },
+);
+
+const createStyles = (typography: ReturnType) =>
+ StyleSheet.create({
+ languageCard: {
+ width: scaleSize(120),
+ height: scaleSize(60),
+ borderRadius: scaleSize(12),
+ justifyContent: "center",
+ alignItems: "center",
+ paddingHorizontal: scaleSize(12),
+ },
+ languageCardText: {
+ fontSize: typography.callout,
+ fontWeight: "500",
+ },
+ languageCardCode: {
+ fontSize: typography.callout,
+ marginTop: scaleSize(2),
+ },
+ checkmark: {
+ position: "absolute",
+ top: scaleSize(8),
+ right: scaleSize(8),
+ },
+ });
diff --git a/components/tv/TVMetadataBadges.tsx b/components/tv/TVMetadataBadges.tsx
new file mode 100644
index 000000000..a3c889e7c
--- /dev/null
+++ b/components/tv/TVMetadataBadges.tsx
@@ -0,0 +1,51 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { View } from "react-native";
+import { Badge } from "@/components/Badge";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+
+export interface TVMetadataBadgesProps {
+ year?: number | null;
+ duration?: string | null;
+ officialRating?: string | null;
+ communityRating?: number | null;
+}
+
+export const TVMetadataBadges: React.FC = React.memo(
+ ({ year, duration, officialRating, communityRating }) => {
+ const typography = useScaledTVTypography();
+
+ return (
+
+ {year != null && (
+
+ {year}
+
+ )}
+ {duration && (
+
+ {duration}
+
+ )}
+ {officialRating && }
+ {communityRating != null && (
+ }
+ />
+ )}
+
+ );
+ },
+);
diff --git a/components/tv/TVNavBar.tsx b/components/tv/TVNavBar.tsx
new file mode 100644
index 000000000..759c283a2
--- /dev/null
+++ b/components/tv/TVNavBar.tsx
@@ -0,0 +1,155 @@
+import React from "react";
+import {
+ Animated,
+ Pressable,
+ ScrollView,
+ StyleProp,
+ View,
+ ViewStyle,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
+import { TVPadding } from "@/constants/TVSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+
+export interface TVNavBarTab {
+ key: string;
+ label: string;
+}
+
+export interface TVNavBarProps {
+ tabs: TVNavBarTab[];
+ activeTabKey: string;
+ onTabChange: (key: string) => void;
+ style?: StyleProp;
+}
+
+const TVNavBarTabItem: React.FC<{
+ label: string;
+ isActive: boolean;
+ onSelect: () => void;
+ onLayout: (e: {
+ nativeEvent: { layout: { x: number; width: number } };
+ }) => void;
+ hasTVPreferredFocus: boolean;
+}> = ({ label, isActive, onSelect, onLayout, hasTVPreferredFocus }) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({
+ scaleAmount: 1.05,
+ duration: 120,
+ });
+
+ const bg = focused
+ ? "rgba(255, 255, 255, 0.95)"
+ : isActive
+ ? "rgba(255, 255, 255, 0.15)"
+ : "transparent";
+
+ const textColor = focused
+ ? "#000"
+ : isActive
+ ? "#fff"
+ : "rgba(255, 255, 255, 0.7)";
+
+ return (
+
+
+
+ {label}
+
+
+
+ );
+};
+
+export const TVNavBar: React.FC = ({
+ tabs,
+ activeTabKey,
+ onTabChange,
+ style,
+}) => {
+ const scrollRef = React.useRef(null);
+ const tabLayouts = React.useRef>(
+ {},
+ );
+ const insets = useSafeAreaInsets();
+
+ const handleTabLayout = React.useCallback(
+ (key: string) =>
+ (e: { nativeEvent: { layout: { x: number; width: number } } }) => {
+ tabLayouts.current[key] = e.nativeEvent.layout;
+ },
+ [],
+ );
+
+ const handleTabChange = React.useCallback(
+ (key: string) => {
+ onTabChange(key);
+
+ const layout = tabLayouts.current[key];
+ if (layout && scrollRef.current) {
+ scrollRef.current.scrollTo({
+ x: Math.max(0, layout.x - TVPadding.horizontal / 2),
+ animated: true,
+ });
+ }
+ },
+ [onTabChange],
+ );
+
+ if (tabs.length === 0) return null;
+
+ return (
+
+
+ {tabs.map((tab) => (
+ handleTabChange(tab.key)}
+ onLayout={handleTabLayout(tab.key)}
+ hasTVPreferredFocus={tab.key === activeTabKey}
+ />
+ ))}
+
+
+ );
+};
diff --git a/components/tv/TVNextEpisodeCountdown.tsx b/components/tv/TVNextEpisodeCountdown.tsx
new file mode 100644
index 000000000..47193b0e1
--- /dev/null
+++ b/components/tv/TVNextEpisodeCountdown.tsx
@@ -0,0 +1,270 @@
+import type { Api } from "@jellyfin/sdk";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import { BlurView } from "expo-blur";
+import { type FC, useEffect, useMemo, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Image,
+ Pressable,
+ Animated as RNAnimated,
+ type View as RNView,
+ StyleSheet,
+ TVFocusGuideView,
+ View,
+} from "react-native";
+import Animated, {
+ cancelAnimation,
+ Easing,
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+import { scaleSize } from "@/utils/scaleSize";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVNextEpisodeCountdownProps {
+ nextItem: BaseItemDto;
+ api: Api | null;
+ show: boolean;
+ isPlaying: boolean;
+ onFinish: () => void;
+ /** Called when user presses the card to skip to next episode */
+ onPlayNext?: () => void;
+ /** Whether controls are visible - affects card position */
+ controlsVisible?: boolean;
+ /** Callback ref setter for focus guide destination pattern */
+ refSetter?: (ref: RNView | null) => void;
+ /** Whether this component should receive initial focus */
+ hasTVPreferredFocus?: boolean;
+ /** Destination used when moving down from this card */
+ playButtonRef?: RNView | null;
+}
+
+// Position constants
+const BOTTOM_WITH_CONTROLS = scaleSize(300);
+const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
+
+export const TVNextEpisodeCountdown: FC = ({
+ nextItem,
+ api,
+ show,
+ isPlaying,
+ onFinish,
+ onPlayNext,
+ controlsVisible = false,
+ refSetter,
+ hasTVPreferredFocus = true,
+ playButtonRef: downDestination,
+}) => {
+ const typography = useScaledTVTypography();
+ const { t } = useTranslation();
+ const progress = useSharedValue(0);
+ const cancelled = useSharedValue(false);
+ const onFinishRef = useRef(onFinish);
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({
+ scaleAmount: 1.05,
+ duration: 120,
+ });
+
+ onFinishRef.current = onFinish;
+
+ const imageUrl = getPrimaryImageUrl({
+ api,
+ item: nextItem,
+ width: scaleSize(360),
+ quality: 80,
+ });
+
+ // Animated position based on controls visibility
+ const bottomPosition = useSharedValue(
+ controlsVisible ? BOTTOM_WITH_CONTROLS : BOTTOM_WITHOUT_CONTROLS,
+ );
+
+ useEffect(() => {
+ const target = controlsVisible
+ ? BOTTOM_WITH_CONTROLS
+ : BOTTOM_WITHOUT_CONTROLS;
+ bottomPosition.value = withTiming(target, {
+ duration: 300,
+ easing: Easing.out(Easing.quad),
+ });
+ }, [controlsVisible, bottomPosition]);
+
+ const containerAnimatedStyle = useAnimatedStyle(() => ({
+ bottom: bottomPosition.value,
+ }));
+
+ // Progress animation - pause/resume without resetting
+ const prevShowRef = useRef(false);
+
+ useEffect(() => {
+ const justStartedShowing = show && !prevShowRef.current;
+ prevShowRef.current = show;
+
+ if (!show) {
+ cancelAnimation(progress);
+ progress.value = 0;
+ return;
+ }
+
+ if (justStartedShowing) {
+ progress.value = 0;
+ }
+
+ if (!isPlaying) {
+ cancelAnimation(progress);
+ return;
+ }
+
+ cancelled.value = false;
+
+ // Resume from current position
+ const remainingDuration = (1 - progress.value) * 8000;
+ progress.value = withTiming(
+ 1,
+ { duration: remainingDuration, easing: Easing.linear },
+ (finished) => {
+ if (finished && !cancelled.value) {
+ runOnJS(onFinishRef.current)();
+ }
+ },
+ );
+
+ // Cancel animation on unmount to prevent onFinish from firing after exit
+ return () => {
+ cancelled.value = true;
+ cancelAnimation(progress);
+ };
+ }, [show, isPlaying, progress, cancelled]);
+
+ const progressStyle = useAnimatedStyle(() => ({
+ width: `${progress.value * 100}%`,
+ }));
+
+ const styles = useMemo(() => createStyles(typography), [typography]);
+
+ if (!show) return null;
+
+ return (
+
+
+
+
+
+ {imageUrl && (
+
+ )}
+
+
+ {t("player.next_episode")}
+
+
+ {nextItem.SeriesName}
+
+
+
+ S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "}
+ {nextItem.Name}
+
+
+
+
+
+
+
+
+
+
+ {downDestination && (
+
+ )}
+
+ );
+};
+
+const createStyles = (typography: ReturnType) =>
+ StyleSheet.create({
+ container: {
+ position: "absolute",
+ right: scaleSize(80),
+ zIndex: 100,
+ },
+ focusedCard: {
+ shadowColor: "#fff",
+ shadowOffset: { width: 0, height: 0 },
+ shadowOpacity: 0.6,
+ shadowRadius: scaleSize(16),
+ },
+ blur: {
+ borderRadius: scaleSize(16),
+ overflow: "hidden",
+ },
+ innerContainer: {
+ flexDirection: "row",
+ alignItems: "stretch",
+ },
+ thumbnail: {
+ width: scaleSize(180),
+ backgroundColor: "rgba(0,0,0,0.3)",
+ },
+ content: {
+ padding: scaleSize(16),
+ justifyContent: "center",
+ width: scaleSize(280),
+ },
+ label: {
+ fontSize: typography.callout,
+ color: "rgba(255,255,255,0.5)",
+ textTransform: "uppercase",
+ letterSpacing: 1,
+ marginBottom: scaleSize(4),
+ },
+ seriesName: {
+ fontSize: typography.callout,
+ color: "rgba(255,255,255,0.7)",
+ marginBottom: scaleSize(2),
+ },
+ episodeInfo: {
+ fontSize: typography.body,
+ color: "#fff",
+ fontWeight: "600",
+ marginBottom: scaleSize(12),
+ },
+ progressContainer: {
+ height: scaleSize(4),
+ backgroundColor: "rgba(255,255,255,0.2)",
+ borderRadius: scaleSize(2),
+ overflow: "hidden",
+ },
+ progressBar: {
+ height: "100%",
+ backgroundColor: "#fff",
+ borderRadius: scaleSize(2),
+ },
+ returnFocusGuide: {
+ height: 1,
+ width: "100%",
+ },
+ });
diff --git a/components/tv/TVOptionButton.tsx b/components/tv/TVOptionButton.tsx
new file mode 100644
index 000000000..1a3ee51d0
--- /dev/null
+++ b/components/tv/TVOptionButton.tsx
@@ -0,0 +1,123 @@
+import { BlurView } from "expo-blur";
+import React from "react";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVOptionButtonProps {
+ label: string;
+ value: string;
+ onPress: () => void;
+ hasTVPreferredFocus?: boolean;
+ maxWidth?: number;
+}
+
+export const TVOptionButton = React.forwardRef(
+ ({ label, value, onPress, hasTVPreferredFocus, maxWidth }, ref) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 });
+
+ return (
+
+
+ {focused ? (
+
+
+ {label}
+
+
+ {value}
+
+
+ ) : (
+
+
+
+ {label}
+
+
+ {value}
+
+
+
+ )}
+
+
+ );
+ },
+);
diff --git a/components/tv/TVOptionCard.tsx b/components/tv/TVOptionCard.tsx
new file mode 100644
index 000000000..200f2a9f7
--- /dev/null
+++ b/components/tv/TVOptionCard.tsx
@@ -0,0 +1,107 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVOptionCardProps {
+ label: string;
+ sublabel?: string;
+ selected: boolean;
+ hasTVPreferredFocus?: boolean;
+ onPress: () => void;
+ width?: number;
+ height?: number;
+}
+
+export const TVOptionCard = React.forwardRef(
+ (
+ {
+ label,
+ sublabel,
+ selected,
+ hasTVPreferredFocus = false,
+ onPress,
+ width = 160,
+ height = 75,
+ },
+ ref,
+ ) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.05 });
+
+ return (
+
+
+
+ {label}
+
+ {sublabel && (
+
+ {sublabel}
+
+ )}
+ {selected && !focused && (
+
+
+
+ )}
+
+
+ );
+ },
+);
diff --git a/components/tv/TVOptionSelector.tsx b/components/tv/TVOptionSelector.tsx
new file mode 100644
index 000000000..ee9ba16cd
--- /dev/null
+++ b/components/tv/TVOptionSelector.tsx
@@ -0,0 +1,205 @@
+import { BlurView } from "expo-blur";
+import { useEffect, useMemo, useRef, useState } from "react";
+import {
+ Animated,
+ Easing,
+ ScrollView,
+ StyleSheet,
+ TVFocusGuideView,
+ View,
+} from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+import { TVCancelButton } from "./TVCancelButton";
+import { TVOptionCard } from "./TVOptionCard";
+
+export type TVOptionItem = {
+ label: string;
+ sublabel?: string;
+ value: T;
+ selected: boolean;
+};
+
+export interface TVOptionSelectorProps {
+ visible: boolean;
+ title: string;
+ options: TVOptionItem[];
+ onSelect: (value: T) => void;
+ onClose: () => void;
+ cancelLabel?: string;
+ cardWidth?: number;
+ cardHeight?: number;
+}
+
+export const TVOptionSelector = ({
+ visible,
+ title,
+ options,
+ onSelect,
+ onClose,
+ cancelLabel = "Cancel",
+ cardWidth = 160,
+ cardHeight = 75,
+}: TVOptionSelectorProps) => {
+ const typography = useScaledTVTypography();
+ const [isReady, setIsReady] = useState(false);
+ const firstCardRef = useRef(null);
+
+ const overlayOpacity = useRef(new Animated.Value(0)).current;
+ const sheetTranslateY = useRef(new Animated.Value(200)).current;
+
+ const initialSelectedIndex = useMemo(() => {
+ const idx = options.findIndex((o) => o.selected);
+ return idx >= 0 ? idx : 0;
+ }, [options]);
+
+ useEffect(() => {
+ if (visible) {
+ overlayOpacity.setValue(0);
+ sheetTranslateY.setValue(200);
+
+ Animated.parallel([
+ Animated.timing(overlayOpacity, {
+ toValue: 1,
+ duration: 250,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(sheetTranslateY, {
+ toValue: 0,
+ duration: 300,
+ easing: Easing.out(Easing.cubic),
+ useNativeDriver: true,
+ }),
+ ]).start();
+ }
+ }, [visible, overlayOpacity, sheetTranslateY]);
+
+ useEffect(() => {
+ if (visible) {
+ const timer = setTimeout(() => setIsReady(true), 100);
+ return () => clearTimeout(timer);
+ }
+ setIsReady(false);
+ }, [visible]);
+
+ useEffect(() => {
+ if (isReady && firstCardRef.current) {
+ const timer = setTimeout(() => {
+ (firstCardRef.current as any)?.requestTVFocus?.();
+ }, 50);
+ return () => clearTimeout(timer);
+ }
+ }, [isReady]);
+
+ const styles = useMemo(() => createStyles(typography), [typography]);
+
+ if (!visible) return null;
+
+ return (
+
+
+
+
+ {title}
+ {isReady && (
+
+ {options.map((option, index) => (
+ {
+ onSelect(option.value);
+ onClose();
+ }}
+ width={cardWidth}
+ height={cardHeight}
+ />
+ ))}
+
+ )}
+
+ {isReady && (
+
+
+
+ )}
+
+
+
+
+ );
+};
+
+const createStyles = (typography: ReturnType) =>
+ StyleSheet.create({
+ overlay: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ justifyContent: "flex-end",
+ zIndex: 1000,
+ },
+ sheetContainer: {
+ width: "100%",
+ },
+ blurContainer: {
+ borderTopLeftRadius: scaleSize(24),
+ borderTopRightRadius: scaleSize(24),
+ overflow: "hidden",
+ },
+ content: {
+ paddingTop: scaleSize(24),
+ paddingBottom: scaleSize(50),
+ overflow: "visible",
+ },
+ title: {
+ fontSize: typography.callout,
+ fontWeight: "500",
+ color: "rgba(255,255,255,0.6)",
+ marginBottom: scaleSize(16),
+ paddingHorizontal: scaleSize(48),
+ textTransform: "uppercase",
+ letterSpacing: 1,
+ },
+ scrollView: {
+ overflow: "visible",
+ },
+ scrollContent: {
+ paddingHorizontal: scaleSize(48),
+ paddingVertical: scaleSize(20),
+ gap: scaleSize(12),
+ },
+ cancelButtonContainer: {
+ marginTop: scaleSize(16),
+ paddingHorizontal: scaleSize(48),
+ alignItems: "flex-start",
+ },
+ });
diff --git a/components/tv/TVPlayedButton.tsx b/components/tv/TVPlayedButton.tsx
new file mode 100644
index 000000000..8ab8e4bb5
--- /dev/null
+++ b/components/tv/TVPlayedButton.tsx
@@ -0,0 +1,33 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import React from "react";
+import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
+import { TVButton } from "./TVButton";
+
+export interface TVPlayedButtonProps {
+ item: BaseItemDto;
+ disabled?: boolean;
+}
+
+export const TVPlayedButton: React.FC = ({
+ item,
+ disabled,
+}) => {
+ const isPlayed = item.UserData?.Played ?? false;
+ const toggle = useMarkAsPlayed([item]);
+
+ return (
+ toggle(!isPlayed)}
+ variant='glass'
+ square
+ disabled={disabled}
+ >
+
+
+ );
+};
diff --git a/components/tv/TVPosterCard.tsx b/components/tv/TVPosterCard.tsx
new file mode 100644
index 000000000..9a24a5034
--- /dev/null
+++ b/components/tv/TVPosterCard.tsx
@@ -0,0 +1,602 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { Image } from "expo-image";
+import { useAtomValue } from "jotai";
+import React, { useMemo, useRef, useState } from "react";
+import {
+ Animated,
+ Easing,
+ Pressable,
+ View,
+ type ViewStyle,
+} from "react-native";
+import { ProgressBar } from "@/components/common/ProgressBar";
+import { Text } from "@/components/common/Text";
+import {
+ UnplayedCountBadge,
+ WatchedIndicator,
+} from "@/components/WatchedIndicator";
+import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import {
+ GlassPosterView,
+ isGlassEffectAvailable,
+} from "@/modules/glass-poster";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+import { scaleSize } from "@/utils/scaleSize";
+import { runtimeTicksToMinutes } from "@/utils/time";
+
+export interface TVPosterCardProps {
+ item: BaseItemDto;
+ /** Poster orientation: vertical = 10:15 (portrait), horizontal = 16:9 (landscape) */
+ orientation?: "vertical" | "horizontal";
+ /** Show text below the poster (title, subtitle) - default: true */
+ showText?: boolean;
+ /** Show progress bar - default: true for items with progress */
+ showProgress?: boolean;
+ /** Show watched indicator - default: true */
+ showWatchedIndicator?: boolean;
+
+ // Focus props
+ hasTVPreferredFocus?: boolean;
+ disabled?: boolean;
+ /** When true, the item remains focusable even when disabled (for navigation purposes) */
+ focusableWhenDisabled?: boolean;
+
+ /** Shows a "Now Playing" badge on the card */
+ isCurrent?: boolean;
+ /** Show a play button overlay */
+ showPlayButton?: boolean;
+
+ // Handlers
+ onPress: () => void;
+ onLongPress?: () => void;
+ onFocus?: () => void;
+ onBlur?: () => void;
+
+ /** Setter function for the ref (for focus guide destinations) */
+ refSetter?: (ref: View | null) => void;
+
+ /** Custom width - overrides default based on orientation */
+ width?: number;
+
+ /** Custom style for the outer container */
+ style?: ViewStyle;
+
+ /** Glow color for focus state */
+ glowColor?: "white" | "purple";
+
+ /** Scale amount for focus animation */
+ scaleAmount?: number;
+
+ /** Custom image URL getter - if not provided, uses smart URL logic */
+ imageUrlGetter?: (item: BaseItemDto) => string | undefined;
+}
+
+/**
+ * TVPosterCard - Unified poster component for TV interface.
+ *
+ * Combines image rendering, focus handling, and text display into a single component.
+ * Supports both portrait (10:15) and landscape (16:9) orientations.
+ *
+ * Features:
+ * - Glass effect on tvOS 26+ with fallback
+ * - Focus handling with scale animation and glow
+ * - Progress bar and watched indicator
+ * - Smart subtitle text based on item type
+ * - "Now Playing" badge for current items
+ */
+export const TVPosterCard: React.FC = ({
+ item,
+ orientation = "vertical",
+ showText = true,
+ showProgress = true,
+ showWatchedIndicator = true,
+ hasTVPreferredFocus = false,
+ disabled = false,
+ focusableWhenDisabled = false,
+ isCurrent = false,
+ showPlayButton = false,
+ onPress,
+ onLongPress,
+ onFocus: onFocusProp,
+ onBlur: onBlurProp,
+ refSetter,
+ width: customWidth,
+ style,
+ glowColor = "white",
+ scaleAmount = 1.05,
+ imageUrlGetter,
+}) => {
+ const api = useAtomValue(apiAtom);
+ const posterSizes = useScaledTVPosterSizes();
+ const typography = useScaledTVTypography();
+
+ const [focused, setFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+
+ // Determine width based on orientation
+ const width = useMemo(() => {
+ if (customWidth) return customWidth;
+ return orientation === "horizontal"
+ ? posterSizes.episode
+ : posterSizes.poster;
+ }, [customWidth, orientation, posterSizes]);
+
+ const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15;
+
+ // Smart image URL selection
+ const imageUrl = useMemo(() => {
+ // Use custom getter if provided
+ if (imageUrlGetter) {
+ return imageUrlGetter(item) ?? null;
+ }
+
+ if (!api) return null;
+
+ // Horizontal orientation: prefer thumbs/backdrops for landscape images
+ if (orientation === "horizontal") {
+ // Episode: prefer series thumb image for consistent look (like hero section)
+ if (item.Type === "Episode") {
+ // First try parent/series thumb (horizontal series artwork)
+ if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
+ return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
+ }
+ // Fall back to episode's own primary image
+ if (item.ImageTags?.Primary) {
+ return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80&tag=${item.ImageTags.Primary}`;
+ }
+ // Last resort: try primary without tag
+ return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
+ }
+
+ // Movie/Series/Program: prefer thumb over primary
+ if (item.ImageTags?.Thumb) {
+ return `${api.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags.Thumb}`;
+ }
+ return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
+ }
+
+ // Vertical orientation: use primary image
+ // For episodes, get the series primary image
+ if (
+ item.Type === "Episode" &&
+ item.SeriesId &&
+ item.SeriesPrimaryImageTag
+ ) {
+ return `${api.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=${width * 3}&quality=80&tag=${item.SeriesPrimaryImageTag}`;
+ }
+
+ return getPrimaryImageUrl({
+ api,
+ item,
+ width: width * 2, // 2x for quality on large screens
+ });
+ }, [api, item, orientation, width, imageUrlGetter]);
+
+ // Progress calculation
+ const progress = useMemo(() => {
+ if (!showProgress) return 0;
+
+ if (item.Type === "Program") {
+ if (!item.StartDate || !item.EndDate) return 0;
+ const startDate = new Date(item.StartDate);
+ const endDate = new Date(item.EndDate);
+ const now = new Date();
+ const total = endDate.getTime() - startDate.getTime();
+ if (total <= 0) return 0;
+ const elapsed = now.getTime() - startDate.getTime();
+ return (elapsed / total) * 100;
+ }
+ return item.UserData?.PlayedPercentage || 0;
+ }, [item, showProgress]);
+
+ const isWatched = showWatchedIndicator && item.UserData?.Played === true;
+
+ // Blurhash for placeholder
+ const blurhash = useMemo(() => {
+ const key = item.ImageTags?.Primary as string;
+ return item.ImageBlurHashes?.Primary?.[key];
+ }, [item]);
+
+ // Glass effect availability
+ const useGlass = isGlassEffectAvailable();
+
+ // Focus animation
+ const animateTo = (value: number) =>
+ Animated.timing(scale, {
+ toValue: value,
+ duration: 150,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start();
+
+ const shadowColor = glowColor === "white" ? "#ffffff" : "#a855f7";
+
+ // Text rendering helpers
+ const renderSubtitle = () => {
+ if (!showText) return null;
+
+ // Episode: S#:E# • duration
+ if (item.Type === "Episode") {
+ const season = item.ParentIndexNumber;
+ const ep = item.IndexNumber;
+ const episodeLabel =
+ season !== undefined && ep !== undefined ? `S${season}:E${ep}` : null;
+ const duration = item.RunTimeTicks
+ ? runtimeTicksToMinutes(item.RunTimeTicks)
+ : null;
+
+ return (
+
+ {episodeLabel && (
+
+ {episodeLabel}
+
+ )}
+ {duration && (
+ <>
+
+ •
+
+
+ {duration}
+
+ >
+ )}
+
+ );
+ }
+
+ // Program: channel name
+ if (item.Type === "Program" && item.ChannelName) {
+ return (
+
+ {item.ChannelName}
+
+ );
+ }
+
+ // MusicAlbum: artist
+ if (item.Type === "MusicAlbum") {
+ const artist = item.AlbumArtist || item.Artists?.join(", ");
+ if (artist) {
+ return (
+
+ {artist}
+
+ );
+ }
+ }
+
+ // Audio: artist
+ if (item.Type === "Audio") {
+ const artist = item.Artists?.join(", ") || item.AlbumArtist;
+ if (artist) {
+ return (
+
+ {artist}
+
+ );
+ }
+ }
+
+ // Playlist: track count
+ if (item.Type === "Playlist" && item.ChildCount) {
+ return (
+
+ {item.ChildCount} tracks
+
+ );
+ }
+
+ // Default: production year
+ if (item.ProductionYear) {
+ return (
+
+ {item.ProductionYear}
+
+ );
+ }
+
+ return null;
+ };
+
+ // Now Playing badge component
+ const NowPlayingBadge = isCurrent ? (
+
+
+
+ Now Playing
+
+
+ ) : null;
+
+ // Play button overlay component
+ const PlayButtonOverlay = showPlayButton ? (
+
+
+
+ ) : null;
+
+ // Render poster image
+ const renderPosterImage = () => {
+ // Empty placeholder when no URL
+ if (!imageUrl) {
+ return (
+
+ );
+ }
+
+ // Glass effect rendering (tvOS 26+)
+ if (useGlass) {
+ return (
+
+
+ {PlayButtonOverlay}
+ {NowPlayingBadge}
+ {/*
+ The glass view draws the watched checkmark natively but cannot show
+ an unplayed-episode count, so render it as an RN overlay on top.
+ Returns null when not applicable (non-series / fully watched).
+ */}
+ {showWatchedIndicator && }
+
+ );
+ }
+
+ // Fallback rendering for older tvOS versions
+ return (
+
+
+ {PlayButtonOverlay}
+ {NowPlayingBadge}
+ {showWatchedIndicator && }
+
+
+ );
+ };
+
+ // Render title based on item type
+ const renderTitle = () => {
+ if (!showText) return null;
+
+ // Episode: show episode name as title
+ if (item.Type === "Episode") {
+ return (
+
+ {item.Name}
+
+ );
+ }
+
+ // MusicArtist: centered text
+ if (item.Type === "MusicArtist") {
+ return (
+
+ {item.Name}
+
+ );
+ }
+
+ // Default: show name
+ return (
+
+ {item.Name}
+
+ );
+ };
+
+ return (
+
+ {
+ setFocused(true);
+ // Only animate scale when not using glass effect (glass handles its own focus visual)
+ if (!useGlass) {
+ animateTo(scaleAmount);
+ }
+ onFocusProp?.();
+ }}
+ onBlur={() => {
+ setFocused(false);
+ if (!useGlass) {
+ animateTo(1);
+ }
+ onBlurProp?.();
+ }}
+ hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
+ disabled={disabled && !focusableWhenDisabled}
+ focusable={!disabled || focusableWhenDisabled}
+ >
+
+ {renderPosterImage()}
+
+
+
+ {/* Text below poster */}
+ {showText && (
+
+ {item.Type === "Episode" ? (
+ <>
+ {renderSubtitle()}
+ {renderTitle()}
+ >
+ ) : (
+ <>
+ {renderTitle()}
+ {renderSubtitle()}
+ >
+ )}
+
+ )}
+
+ );
+};
diff --git a/components/tv/TVProgressBar.tsx b/components/tv/TVProgressBar.tsx
new file mode 100644
index 000000000..e8ee60f86
--- /dev/null
+++ b/components/tv/TVProgressBar.tsx
@@ -0,0 +1,52 @@
+import React from "react";
+import { View } from "react-native";
+import { scaleSize } from "@/utils/scaleSize";
+
+export interface TVProgressBarProps {
+ /** Progress value between 0 and 1 */
+ progress: number;
+ /** Background color of the track */
+ trackColor?: string;
+ /** Color of the progress fill */
+ fillColor?: string;
+ /** Maximum width of the progress bar */
+ maxWidth?: number;
+ /** Height of the progress bar */
+ height?: number;
+}
+
+export const TVProgressBar: React.FC = React.memo(
+ ({
+ progress,
+ trackColor = "rgba(255,255,255,0.2)",
+ fillColor = "#ffffff",
+ maxWidth = 400,
+ height = 4,
+ }) => {
+ const clampedProgress = Math.max(0, Math.min(1, progress));
+ const scaledMaxWidth = scaleSize(maxWidth);
+ const scaledHeight = scaleSize(height);
+
+ return (
+
+
+
+
+
+ );
+ },
+);
diff --git a/components/tv/TVRefreshButton.tsx b/components/tv/TVRefreshButton.tsx
new file mode 100644
index 000000000..5e44dd943
--- /dev/null
+++ b/components/tv/TVRefreshButton.tsx
@@ -0,0 +1,70 @@
+import { Ionicons } from "@expo/vector-icons";
+import { type QueryClient, useQueryClient } from "@tanstack/react-query";
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import { Animated, Easing } from "react-native";
+import { TVButton } from "./TVButton";
+
+export interface TVRefreshButtonProps {
+ itemId: string | undefined;
+ queryClient?: QueryClient;
+}
+
+export const TVRefreshButton: React.FC = ({
+ itemId,
+ queryClient: externalQueryClient,
+}) => {
+ const defaultQueryClient = useQueryClient();
+ const queryClient = externalQueryClient ?? defaultQueryClient;
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const spinValue = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ if (isRefreshing) {
+ spinValue.setValue(0);
+ Animated.loop(
+ Animated.timing(spinValue, {
+ toValue: 1,
+ duration: 1000,
+ easing: Easing.linear,
+ useNativeDriver: true,
+ }),
+ ).start();
+ } else {
+ spinValue.stopAnimation();
+ spinValue.setValue(0);
+ }
+ }, [isRefreshing, spinValue]);
+
+ const spin = spinValue.interpolate({
+ inputRange: [0, 1],
+ outputRange: ["0deg", "360deg"],
+ });
+
+ const handleRefresh = useCallback(async () => {
+ if (!itemId || isRefreshing) return;
+
+ setIsRefreshing(true);
+ const minSpinTime = new Promise((resolve) => setTimeout(resolve, 1000));
+ try {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ["item", itemId] }),
+ minSpinTime,
+ ]);
+ } finally {
+ setIsRefreshing(false);
+ }
+ }, [itemId, queryClient, isRefreshing]);
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/components/tv/TVSeriesNavigation.tsx b/components/tv/TVSeriesNavigation.tsx
new file mode 100644
index 000000000..4f828a4e6
--- /dev/null
+++ b/components/tv/TVSeriesNavigation.tsx
@@ -0,0 +1,78 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { ScrollView, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVSizes } from "@/constants/TVSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+import { TVSeriesSeasonCard } from "./TVSeriesSeasonCard";
+
+export interface TVSeriesNavigationProps {
+ item: BaseItemDto;
+ seriesImageUrl?: string | null;
+ seasonImageUrl?: string | null;
+ onSeriesPress: () => void;
+ onSeasonPress: () => void;
+}
+
+export const TVSeriesNavigation: React.FC = React.memo(
+ ({ item, seriesImageUrl, seasonImageUrl, onSeriesPress, onSeasonPress }) => {
+ const typography = useScaledTVTypography();
+ const sizes = useScaledTVSizes();
+ const { t } = useTranslation();
+
+ // Only show for episodes with a series
+ if (item.Type !== "Episode" || !item.SeriesId) {
+ return null;
+ }
+
+ return (
+
+
+ {t("item_card.from_this_series") || "From this Series"}
+
+
+ {/* Series card */}
+
+
+ {/* Season card */}
+ {(item.SeasonId || item.ParentId) && (
+
+ )}
+
+
+ );
+ },
+);
diff --git a/components/tv/TVSeriesSeasonCard.tsx b/components/tv/TVSeriesSeasonCard.tsx
new file mode 100644
index 000000000..64535cc92
--- /dev/null
+++ b/components/tv/TVSeriesSeasonCard.tsx
@@ -0,0 +1,164 @@
+import { Ionicons } from "@expo/vector-icons";
+import { Image } from "expo-image";
+import React, { useRef, useState } from "react";
+import { Animated, Easing, Platform, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVSizes } from "@/constants/TVSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import {
+ GlassPosterView,
+ isGlassEffectAvailable,
+} from "@/modules/glass-poster";
+import { scaleSize } from "@/utils/scaleSize";
+
+export interface TVSeriesSeasonCardProps {
+ title: string;
+ subtitle?: string;
+ imageUrl: string | null;
+ onPress: () => void;
+ hasTVPreferredFocus?: boolean;
+ /** Setter function for the ref (for focus guide destinations) */
+ refSetter?: (ref: View | null) => void;
+}
+
+export const TVSeriesSeasonCard: React.FC = ({
+ title,
+ subtitle,
+ imageUrl,
+ onPress,
+ hasTVPreferredFocus,
+ refSetter,
+}) => {
+ const typography = useScaledTVTypography();
+ const sizes = useScaledTVSizes();
+ const [focused, setFocused] = useState(false);
+
+ // Check if glass effect is available (tvOS 26+)
+ const useGlass = Platform.OS === "ios" && isGlassEffectAvailable();
+
+ // Scale animation for focus (only used when NOT using glass effect)
+ const scale = useRef(new Animated.Value(1)).current;
+ const animateTo = (value: number) =>
+ Animated.timing(scale, {
+ toValue: value,
+ duration: 150,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start();
+
+ const renderPoster = () => {
+ if (useGlass) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+ );
+ };
+
+ return (
+
+ {
+ setFocused(true);
+ // Only animate scale when not using glass effect (glass handles its own focus visual)
+ if (!useGlass) {
+ animateTo(1.05);
+ }
+ }}
+ onBlur={() => {
+ setFocused(false);
+ if (!useGlass) {
+ animateTo(1);
+ }
+ }}
+ hasTVPreferredFocus={hasTVPreferredFocus}
+ >
+
+ {renderPoster()}
+
+
+
+
+
+ {title}
+
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+ );
+};
diff --git a/components/tv/TVSkipSegmentCard.tsx b/components/tv/TVSkipSegmentCard.tsx
new file mode 100644
index 000000000..140fa317f
--- /dev/null
+++ b/components/tv/TVSkipSegmentCard.tsx
@@ -0,0 +1,144 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { FC } from "react";
+import { useEffect } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Pressable,
+ Animated as RNAnimated,
+ StyleSheet,
+ TVFocusGuideView,
+ type View,
+} from "react-native";
+import Animated, {
+ Easing,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+import { Text } from "@/components/common/Text";
+import { scaleSize } from "@/utils/scaleSize";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVSkipSegmentCardProps {
+ show: boolean;
+ onPress: () => void;
+ type: "intro" | "credits";
+ /** Whether controls are visible - affects card position */
+ controlsVisible?: boolean;
+ /** Callback ref setter for focus guide destination pattern */
+ refSetter?: (ref: View | null) => void;
+ /** Whether this component should receive initial focus */
+ hasTVPreferredFocus?: boolean;
+ /** Destination used when moving down from this card */
+ playButtonRef?: View | null;
+}
+
+// Position constants - same as TVNextEpisodeCountdown (they're mutually exclusive)
+const BOTTOM_WITH_CONTROLS = 300;
+const BOTTOM_WITHOUT_CONTROLS = 120;
+
+export const TVSkipSegmentCard: FC = ({
+ show,
+ onPress,
+ type,
+ controlsVisible = false,
+ refSetter,
+ hasTVPreferredFocus = true,
+ playButtonRef: downDestination,
+}) => {
+ const { t } = useTranslation();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({
+ scaleAmount: 1.1,
+ duration: 120,
+ });
+
+ // Animated position based on controls visibility
+ const bottomPosition = useSharedValue(
+ controlsVisible ? BOTTOM_WITH_CONTROLS : BOTTOM_WITHOUT_CONTROLS,
+ );
+
+ useEffect(() => {
+ const target = controlsVisible
+ ? BOTTOM_WITH_CONTROLS
+ : BOTTOM_WITHOUT_CONTROLS;
+ bottomPosition.value = withTiming(target, {
+ duration: 300,
+ easing: Easing.out(Easing.quad),
+ });
+ }, [controlsVisible, bottomPosition]);
+
+ const containerAnimatedStyle = useAnimatedStyle(() => ({
+ bottom: bottomPosition.value,
+ }));
+
+ const labelText =
+ type === "intro" ? t("player.skip_intro") : t("player.skip_credits");
+
+ if (!show) return null;
+
+ return (
+
+
+
+
+ {labelText}
+
+
+ {downDestination && (
+
+ )}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ position: "absolute",
+ right: scaleSize(80),
+ zIndex: 100,
+ },
+ button: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingVertical: scaleSize(10),
+ paddingHorizontal: scaleSize(18),
+ borderRadius: scaleSize(12),
+ borderWidth: scaleSize(2),
+ gap: scaleSize(8),
+ },
+ label: {
+ fontSize: scaleSize(20),
+ color: "#fff",
+ fontWeight: "600",
+ },
+ returnFocusGuide: {
+ height: 1,
+ width: "100%",
+ },
+});
diff --git a/components/tv/TVSubtitleResultCard.tsx b/components/tv/TVSubtitleResultCard.tsx
new file mode 100644
index 000000000..f1f3ccf92
--- /dev/null
+++ b/components/tv/TVSubtitleResultCard.tsx
@@ -0,0 +1,272 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import {
+ ActivityIndicator,
+ Animated,
+ Pressable,
+ StyleSheet,
+ View,
+} from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import type { SubtitleSearchResult } from "@/hooks/useRemoteSubtitles";
+import { scaleSize } from "@/utils/scaleSize";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVSubtitleResultCardProps {
+ result: SubtitleSearchResult;
+ hasTVPreferredFocus?: boolean;
+ isDownloading?: boolean;
+ onPress: () => void;
+}
+
+export const TVSubtitleResultCard = React.forwardRef<
+ View,
+ TVSubtitleResultCardProps
+>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
+ const typography = useScaledTVTypography();
+ const styles = createStyles(typography);
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.03 });
+
+ return (
+
+
+ {/* Provider/Source badge */}
+
+
+ {result.providerName}
+
+
+
+ {/* Name */}
+
+ {result.name}
+
+
+ {/* Meta info row */}
+
+ {/* Format */}
+
+ {result.format?.toUpperCase()}
+
+
+ {/* Rating if available */}
+ {result.communityRating !== undefined &&
+ result.communityRating > 0 && (
+
+
+
+ {result.communityRating.toFixed(1)}
+
+
+ )}
+
+ {/* Download count if available */}
+ {result.downloadCount !== undefined && result.downloadCount > 0 && (
+
+
+
+ {result.downloadCount.toLocaleString()}
+
+
+ )}
+
+
+ {/* Flags */}
+
+ {result.isHashMatch && (
+
+ Hash Match
+
+ )}
+ {result.hearingImpaired && (
+
+
+
+ )}
+ {result.aiTranslated && (
+
+ AI
+
+ )}
+
+
+ {/* Loading indicator when downloading */}
+ {isDownloading && (
+
+
+
+ )}
+
+
+ );
+});
+
+const createStyles = (typography: ReturnType) =>
+ StyleSheet.create({
+ resultCard: {
+ width: scaleSize(220),
+ minHeight: scaleSize(120),
+ borderRadius: scaleSize(14),
+ padding: scaleSize(14),
+ borderWidth: scaleSize(1),
+ },
+ providerBadge: {
+ alignSelf: "flex-start",
+ paddingHorizontal: scaleSize(8),
+ paddingVertical: scaleSize(3),
+ borderRadius: scaleSize(6),
+ marginBottom: scaleSize(8),
+ },
+ providerText: {
+ fontSize: typography.callout,
+ fontWeight: "600",
+ textTransform: "uppercase",
+ letterSpacing: 0.5,
+ },
+ resultName: {
+ fontSize: typography.callout,
+ fontWeight: "500",
+ marginBottom: scaleSize(8),
+ lineHeight: scaleSize(18),
+ },
+ resultMeta: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: scaleSize(12),
+ marginBottom: scaleSize(8),
+ },
+ resultMetaText: {
+ fontSize: typography.callout,
+ },
+ ratingContainer: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: scaleSize(3),
+ },
+ downloadCountContainer: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: scaleSize(3),
+ },
+ flagsContainer: {
+ flexDirection: "row",
+ gap: scaleSize(6),
+ flexWrap: "wrap",
+ },
+ flag: {
+ paddingHorizontal: scaleSize(6),
+ paddingVertical: scaleSize(2),
+ borderRadius: scaleSize(4),
+ },
+ flagText: {
+ fontSize: typography.callout,
+ fontWeight: "600",
+ color: "#fff",
+ },
+ downloadingOverlay: {
+ ...StyleSheet.absoluteFill,
+ backgroundColor: "rgba(0,0,0,0.5)",
+ borderRadius: scaleSize(14),
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ });
diff --git a/components/tv/TVTabButton.tsx b/components/tv/TVTabButton.tsx
new file mode 100644
index 000000000..be8ea8c2d
--- /dev/null
+++ b/components/tv/TVTabButton.tsx
@@ -0,0 +1,71 @@
+import React from "react";
+import { Animated, Pressable } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVTabButtonProps {
+ label: string;
+ active: boolean;
+ onSelect: () => void;
+ hasTVPreferredFocus?: boolean;
+ switchOnFocus?: boolean;
+ disabled?: boolean;
+}
+
+export const TVTabButton: React.FC = ({
+ label,
+ active,
+ onSelect,
+ hasTVPreferredFocus = false,
+ switchOnFocus = false,
+ disabled = false,
+}) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({
+ scaleAmount: 1.05,
+ duration: 120,
+ onFocus: switchOnFocus ? onSelect : undefined,
+ });
+
+ return (
+
+
+
+ {label}
+
+
+
+ );
+};
diff --git a/components/tv/TVTechnicalDetails.tsx b/components/tv/TVTechnicalDetails.tsx
new file mode 100644
index 000000000..0a0fc970a
--- /dev/null
+++ b/components/tv/TVTechnicalDetails.tsx
@@ -0,0 +1,80 @@
+import type { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+
+export interface TVTechnicalDetailsProps {
+ mediaStreams: MediaStream[];
+}
+
+export const TVTechnicalDetails: React.FC = React.memo(
+ ({ mediaStreams }) => {
+ const typography = useScaledTVTypography();
+ const { t } = useTranslation();
+
+ const videoStream = mediaStreams.find((s) => s.Type === "Video");
+ const audioStream = mediaStreams.find((s) => s.Type === "Audio");
+
+ if (!videoStream && !audioStream) {
+ return null;
+ }
+
+ return (
+
+
+ {t("item_card.technical_details")}
+
+
+ {videoStream && (
+
+
+ {t("common.video")}
+
+
+ {videoStream.DisplayTitle ||
+ `${videoStream.Codec?.toUpperCase()} ${videoStream.Width}x${videoStream.Height}`}
+
+
+ )}
+ {audioStream && (
+
+
+ {t("common.audio")}
+
+
+ {audioStream.DisplayTitle ||
+ `${audioStream.Codec?.toUpperCase()} ${audioStream.Channels}ch`}
+
+
+ )}
+
+
+ );
+ },
+);
diff --git a/components/tv/TVThemeMusicIndicator.tsx b/components/tv/TVThemeMusicIndicator.tsx
new file mode 100644
index 000000000..88fd1e1a5
--- /dev/null
+++ b/components/tv/TVThemeMusicIndicator.tsx
@@ -0,0 +1,79 @@
+import { Ionicons } from "@expo/vector-icons";
+import React, { useRef, useState } from "react";
+import { Animated, Easing, Pressable, View } from "react-native";
+import { AnimatedEqualizer } from "@/components/music/AnimatedEqualizer";
+import { scaleSize } from "@/utils/scaleSize";
+
+interface TVThemeMusicIndicatorProps {
+ isPlaying: boolean;
+ isMuted: boolean;
+ hasThemeMusic: boolean;
+ onToggleMute: () => void;
+ disabled?: boolean;
+}
+
+export const TVThemeMusicIndicator: React.FC = ({
+ isPlaying,
+ isMuted,
+ hasThemeMusic,
+ onToggleMute,
+ disabled = false,
+}) => {
+ const [focused, setFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+
+ const animateTo = (v: number) =>
+ Animated.timing(scale, {
+ toValue: v,
+ duration: 150,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start();
+
+ if (!hasThemeMusic || !isPlaying) return null;
+
+ return (
+ {
+ setFocused(true);
+ animateTo(1.15);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
+ disabled={disabled}
+ focusable={!disabled}
+ >
+
+ {isMuted ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ );
+};
diff --git a/components/tv/TVTrackCard.tsx b/components/tv/TVTrackCard.tsx
new file mode 100644
index 000000000..b2b451625
--- /dev/null
+++ b/components/tv/TVTrackCard.tsx
@@ -0,0 +1,106 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Animated, Pressable, StyleSheet, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVTrackCardProps {
+ label: string;
+ sublabel?: string;
+ selected: boolean;
+ hasTVPreferredFocus?: boolean;
+ onPress: () => void;
+}
+
+export const TVTrackCard = React.forwardRef(
+ ({ label, sublabel, selected, hasTVPreferredFocus, onPress }, ref) => {
+ const typography = useScaledTVTypography();
+ const styles = createStyles(typography);
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.05 });
+
+ return (
+
+
+
+ {label}
+
+ {sublabel && (
+
+ {sublabel}
+
+ )}
+ {selected && !focused && (
+
+
+
+ )}
+
+
+ );
+ },
+);
+
+const createStyles = (typography: ReturnType) =>
+ StyleSheet.create({
+ trackCard: {
+ width: scaleSize(180),
+ height: scaleSize(80),
+ borderRadius: scaleSize(14),
+ justifyContent: "center",
+ alignItems: "center",
+ paddingHorizontal: scaleSize(12),
+ },
+ trackCardText: {
+ fontSize: typography.callout,
+ textAlign: "center",
+ },
+ trackCardSublabel: {
+ fontSize: typography.callout,
+ marginTop: scaleSize(2),
+ },
+ checkmark: {
+ position: "absolute",
+ top: scaleSize(8),
+ right: scaleSize(8),
+ },
+ });
diff --git a/components/tv/TVUserCard.tsx b/components/tv/TVUserCard.tsx
new file mode 100644
index 000000000..b687bf757
--- /dev/null
+++ b/components/tv/TVUserCard.tsx
@@ -0,0 +1,183 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+import type { AccountSecurityType } from "@/utils/secureCredentials";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVUserCardProps {
+ username: string;
+ securityType: AccountSecurityType;
+ hasTVPreferredFocus?: boolean;
+ isCurrent?: boolean;
+ onPress: () => void;
+}
+
+export const TVUserCard = React.forwardRef(
+ (
+ {
+ username,
+ securityType,
+ hasTVPreferredFocus = false,
+ isCurrent = false,
+ onPress,
+ },
+ ref,
+ ) => {
+ const { t } = useTranslation();
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: isCurrent ? 1.02 : 1.05 });
+
+ const getSecurityIcon = (): keyof typeof Ionicons.glyphMap => {
+ switch (securityType) {
+ case "pin":
+ return "keypad";
+ case "password":
+ return "lock-closed";
+ default:
+ return "key";
+ }
+ };
+
+ const getSecurityText = (): string => {
+ switch (securityType) {
+ case "pin":
+ return t("save_account.pin_code");
+ case "password":
+ return t("save_account.password");
+ default:
+ return t("save_account.no_protection");
+ }
+ };
+
+ const getBackgroundColor = () => {
+ if (isCurrent) {
+ return focused ? "rgba(255,255,255,0.15)" : "rgba(255,255,255,0.04)";
+ }
+ return focused ? "#fff" : "rgba(255,255,255,0.08)";
+ };
+
+ const getTextColor = () => {
+ if (isCurrent) {
+ return "rgba(255,255,255,0.4)";
+ }
+ return focused ? "#000" : "#fff";
+ };
+
+ const getSecondaryColor = () => {
+ if (isCurrent) {
+ return "rgba(255,255,255,0.25)";
+ }
+ return focused ? "rgba(0,0,0,0.5)" : "rgba(255,255,255,0.5)";
+ };
+
+ return (
+
+
+ {/* User Avatar */}
+
+
+
+
+ {/* Text column */}
+
+ {/* Username */}
+
+
+ {username}
+
+ {isCurrent && (
+
+ ({t("home.settings.switch_user.current")})
+
+ )}
+
+
+ {/* Security indicator */}
+
+
+
+ {getSecurityText()}
+
+
+
+
+
+ );
+ },
+);
diff --git a/components/tv/hooks/useTVFocusAnimation.ts b/components/tv/hooks/useTVFocusAnimation.ts
new file mode 100644
index 000000000..b3418c8cb
--- /dev/null
+++ b/components/tv/hooks/useTVFocusAnimation.ts
@@ -0,0 +1,64 @@
+import { useCallback, useRef, useState } from "react";
+import { Animated, Easing } from "react-native";
+import { useInactivity } from "@/providers/InactivityProvider";
+
+export interface UseTVFocusAnimationOptions {
+ scaleAmount?: number;
+ duration?: number;
+ onFocus?: () => void;
+ onBlur?: () => void;
+}
+
+export interface UseTVFocusAnimationReturn {
+ focused: boolean;
+ scale: Animated.Value;
+ handleFocus: () => void;
+ handleBlur: () => void;
+ animatedStyle: { transform: { scale: Animated.Value }[] };
+}
+
+export const useTVFocusAnimation = ({
+ scaleAmount = 1.05,
+ duration = 150,
+ onFocus,
+ onBlur,
+}: UseTVFocusAnimationOptions = {}): UseTVFocusAnimationReturn => {
+ const [focused, setFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+ const { resetInactivityTimer } = useInactivity();
+
+ const animateTo = useCallback(
+ (value: number) => {
+ Animated.timing(scale, {
+ toValue: value,
+ duration,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start();
+ },
+ [scale, duration],
+ );
+
+ const handleFocus = useCallback(() => {
+ setFocused(true);
+ animateTo(scaleAmount);
+ resetInactivityTimer();
+ onFocus?.();
+ }, [animateTo, scaleAmount, resetInactivityTimer, onFocus]);
+
+ const handleBlur = useCallback(() => {
+ setFocused(false);
+ animateTo(1);
+ onBlur?.();
+ }, [animateTo, onBlur]);
+
+ const animatedStyle = { transform: [{ scale }] };
+
+ return {
+ focused,
+ scale,
+ handleFocus,
+ handleBlur,
+ animatedStyle,
+ };
+};
diff --git a/components/tv/index.ts b/components/tv/index.ts
new file mode 100644
index 000000000..99f626a0f
--- /dev/null
+++ b/components/tv/index.ts
@@ -0,0 +1,72 @@
+// Hooks
+export type {
+ UseTVFocusAnimationOptions,
+ UseTVFocusAnimationReturn,
+} from "./hooks/useTVFocusAnimation";
+export { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+// Settings components (re-export from settings/)
+export * from "./settings";
+// Item content components
+export type { TVActorCardProps } from "./TVActorCard";
+export { TVActorCard } from "./TVActorCard";
+export type { TVBackdropProps } from "./TVBackdrop";
+export { TVBackdrop } from "./TVBackdrop";
+// Core components
+export type { TVButtonProps } from "./TVButton";
+export { TVButton } from "./TVButton";
+export type { TVCancelButtonProps } from "./TVCancelButton";
+export { TVCancelButton } from "./TVCancelButton";
+export type { TVCastCrewTextProps } from "./TVCastCrewText";
+export { TVCastCrewText } from "./TVCastCrewText";
+export type { TVCastSectionProps } from "./TVCastSection";
+export { TVCastSection } from "./TVCastSection";
+// Player control components
+export type { TVControlButtonProps } from "./TVControlButton";
+export { TVControlButton } from "./TVControlButton";
+export type { TVFavoriteButtonProps } from "./TVFavoriteButton";
+export { TVFavoriteButton } from "./TVFavoriteButton";
+export type { TVFilterButtonProps } from "./TVFilterButton";
+export { TVFilterButton } from "./TVFilterButton";
+export type { TVFocusablePosterProps } from "./TVFocusablePoster";
+export { TVFocusablePoster } from "./TVFocusablePoster";
+export type { TVItemCardTextProps } from "./TVItemCardText";
+export { TVItemCardText } from "./TVItemCardText";
+export type { TVLanguageCardProps } from "./TVLanguageCard";
+export { TVLanguageCard } from "./TVLanguageCard";
+export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
+export { TVMetadataBadges } from "./TVMetadataBadges";
+export type { TVNavBarProps, TVNavBarTab } from "./TVNavBar";
+export { TVNavBar } from "./TVNavBar";
+export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
+export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
+export type { TVOptionButtonProps } from "./TVOptionButton";
+export { TVOptionButton } from "./TVOptionButton";
+export type { TVOptionCardProps } from "./TVOptionCard";
+export { TVOptionCard } from "./TVOptionCard";
+export type { TVOptionItem, TVOptionSelectorProps } from "./TVOptionSelector";
+export { TVOptionSelector } from "./TVOptionSelector";
+export type { TVPlayedButtonProps } from "./TVPlayedButton";
+export { TVPlayedButton } from "./TVPlayedButton";
+export type { TVProgressBarProps } from "./TVProgressBar";
+export { TVProgressBar } from "./TVProgressBar";
+export type { TVRefreshButtonProps } from "./TVRefreshButton";
+export { TVRefreshButton } from "./TVRefreshButton";
+export type { TVSeriesNavigationProps } from "./TVSeriesNavigation";
+export { TVSeriesNavigation } from "./TVSeriesNavigation";
+export type { TVSeriesSeasonCardProps } from "./TVSeriesSeasonCard";
+export { TVSeriesSeasonCard } from "./TVSeriesSeasonCard";
+export type { TVSkipSegmentCardProps } from "./TVSkipSegmentCard";
+export { TVSkipSegmentCard } from "./TVSkipSegmentCard";
+export type { TVSubtitleResultCardProps } from "./TVSubtitleResultCard";
+export { TVSubtitleResultCard } from "./TVSubtitleResultCard";
+export type { TVTabButtonProps } from "./TVTabButton";
+export { TVTabButton } from "./TVTabButton";
+export type { TVTechnicalDetailsProps } from "./TVTechnicalDetails";
+export { TVTechnicalDetails } from "./TVTechnicalDetails";
+export { TVThemeMusicIndicator } from "./TVThemeMusicIndicator";
+// Subtitle sheet components
+export type { TVTrackCardProps } from "./TVTrackCard";
+export { TVTrackCard } from "./TVTrackCard";
+// User switching
+export type { TVUserCardProps } from "./TVUserCard";
+export { TVUserCard } from "./TVUserCard";
diff --git a/components/tv/settings/TVLogoutButton.tsx b/components/tv/settings/TVLogoutButton.tsx
new file mode 100644
index 000000000..b001e5071
--- /dev/null
+++ b/components/tv/settings/TVLogoutButton.tsx
@@ -0,0 +1,65 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
+
+export interface TVLogoutButtonProps {
+ onPress: () => void;
+ disabled?: boolean;
+}
+
+export const TVLogoutButton: React.FC = ({
+ onPress,
+ disabled,
+}) => {
+ const { t } = useTranslation();
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.05 });
+
+ return (
+
+
+
+
+ {t("home.settings.log_out_button")}
+
+
+
+
+ );
+};
diff --git a/components/tv/settings/TVSectionHeader.tsx b/components/tv/settings/TVSectionHeader.tsx
new file mode 100644
index 000000000..44e240e1a
--- /dev/null
+++ b/components/tv/settings/TVSectionHeader.tsx
@@ -0,0 +1,28 @@
+import React from "react";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+
+export interface TVSectionHeaderProps {
+ title: string;
+}
+
+export const TVSectionHeader: React.FC = ({ title }) => {
+ const typography = useScaledTVTypography();
+
+ return (
+
+ {title}
+
+ );
+};
diff --git a/components/tv/settings/TVSettingsOptionButton.tsx b/components/tv/settings/TVSettingsOptionButton.tsx
new file mode 100644
index 000000000..6248cf2ce
--- /dev/null
+++ b/components/tv/settings/TVSettingsOptionButton.tsx
@@ -0,0 +1,77 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
+
+export interface TVSettingsOptionButtonProps {
+ label: string;
+ value: string;
+ onPress: () => void;
+ isFirst?: boolean;
+ disabled?: boolean;
+}
+
+export const TVSettingsOptionButton: React.FC = ({
+ label,
+ value,
+ onPress,
+ isFirst,
+ disabled,
+}) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.02 });
+
+ return (
+
+
+
+ {label}
+
+
+
+ {value}
+
+
+
+
+
+ );
+};
diff --git a/components/tv/settings/TVSettingsRow.tsx b/components/tv/settings/TVSettingsRow.tsx
new file mode 100644
index 000000000..1b8422d1c
--- /dev/null
+++ b/components/tv/settings/TVSettingsRow.tsx
@@ -0,0 +1,80 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
+
+export interface TVSettingsRowProps {
+ label: string;
+ value: string;
+ onPress?: () => void;
+ isFirst?: boolean;
+ showChevron?: boolean;
+ disabled?: boolean;
+}
+
+export const TVSettingsRow: React.FC = ({
+ label,
+ value,
+ onPress,
+ isFirst,
+ showChevron = true,
+ disabled,
+}) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.02 });
+
+ return (
+
+
+
+ {label}
+
+
+
+ {value}
+
+ {showChevron && (
+
+ )}
+
+
+
+ );
+};
diff --git a/components/tv/settings/TVSettingsStepper.tsx b/components/tv/settings/TVSettingsStepper.tsx
new file mode 100644
index 000000000..d0ba7b2cb
--- /dev/null
+++ b/components/tv/settings/TVSettingsStepper.tsx
@@ -0,0 +1,134 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
+
+export interface TVSettingsStepperProps {
+ label: string;
+ value: number;
+ onDecrease: () => void;
+ onIncrease: () => void;
+ formatValue?: (value: number) => string;
+ isFirst?: boolean;
+ disabled?: boolean;
+}
+
+export const TVSettingsStepper: React.FC = ({
+ label,
+ value,
+ onDecrease,
+ onIncrease,
+ formatValue,
+ isFirst,
+ disabled,
+}) => {
+ const typography = useScaledTVTypography();
+ const labelAnim = useTVFocusAnimation({ scaleAmount: 1.02 });
+ const minusAnim = useTVFocusAnimation({ scaleAmount: 1.1 });
+ const plusAnim = useTVFocusAnimation({ scaleAmount: 1.1 });
+
+ const displayValue = formatValue ? formatValue(value) : String(value);
+
+ return (
+
+
+
+
+ {label}
+
+
+
+
+
+
+
+
+
+
+ {displayValue}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/components/tv/settings/TVSettingsTextInput.tsx b/components/tv/settings/TVSettingsTextInput.tsx
new file mode 100644
index 000000000..c5d89ecee
--- /dev/null
+++ b/components/tv/settings/TVSettingsTextInput.tsx
@@ -0,0 +1,92 @@
+import React, { useRef } from "react";
+import { Animated, Pressable, TextInput } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
+
+export interface TVSettingsTextInputProps {
+ label: string;
+ value: string;
+ placeholder?: string;
+ onChangeText: (text: string) => void;
+ onBlur?: () => void;
+ secureTextEntry?: boolean;
+ disabled?: boolean;
+}
+
+export const TVSettingsTextInput: React.FC = ({
+ label,
+ value,
+ placeholder,
+ onChangeText,
+ onBlur,
+ secureTextEntry,
+ disabled,
+}) => {
+ const typography = useScaledTVTypography();
+ const inputRef = useRef(null);
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.02 });
+
+ const handleInputBlur = () => {
+ handleBlur();
+ onBlur?.();
+ };
+
+ return (
+ inputRef.current?.focus()}
+ onFocus={handleFocus}
+ onBlur={handleInputBlur}
+ disabled={disabled}
+ focusable={!disabled}
+ >
+
+
+ {label}
+
+
+
+
+ );
+};
diff --git a/components/tv/settings/TVSettingsToggle.tsx b/components/tv/settings/TVSettingsToggle.tsx
new file mode 100644
index 000000000..57f59a489
--- /dev/null
+++ b/components/tv/settings/TVSettingsToggle.tsx
@@ -0,0 +1,79 @@
+import React from "react";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
+
+export interface TVSettingsToggleProps {
+ label: string;
+ value: boolean;
+ onToggle: (value: boolean) => void;
+ isFirst?: boolean;
+ disabled?: boolean;
+}
+
+export const TVSettingsToggle: React.FC = ({
+ label,
+ value,
+ onToggle,
+ isFirst,
+ disabled,
+}) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.02 });
+
+ return (
+ onToggle(!value)}
+ onFocus={handleFocus}
+ onBlur={handleBlur}
+ hasTVPreferredFocus={isFirst && !disabled}
+ disabled={disabled}
+ focusable={!disabled}
+ >
+
+
+ {label}
+
+
+
+
+
+
+ );
+};
diff --git a/components/tv/settings/index.ts b/components/tv/settings/index.ts
new file mode 100644
index 000000000..43ff2f63b
--- /dev/null
+++ b/components/tv/settings/index.ts
@@ -0,0 +1,14 @@
+export type { TVLogoutButtonProps } from "./TVLogoutButton";
+export { TVLogoutButton } from "./TVLogoutButton";
+export type { TVSectionHeaderProps } from "./TVSectionHeader";
+export { TVSectionHeader } from "./TVSectionHeader";
+export type { TVSettingsOptionButtonProps } from "./TVSettingsOptionButton";
+export { TVSettingsOptionButton } from "./TVSettingsOptionButton";
+export type { TVSettingsRowProps } from "./TVSettingsRow";
+export { TVSettingsRow } from "./TVSettingsRow";
+export type { TVSettingsStepperProps } from "./TVSettingsStepper";
+export { TVSettingsStepper } from "./TVSettingsStepper";
+export type { TVSettingsTextInputProps } from "./TVSettingsTextInput";
+export { TVSettingsTextInput } from "./TVSettingsTextInput";
+export type { TVSettingsToggleProps } from "./TVSettingsToggle";
+export { TVSettingsToggle } from "./TVSettingsToggle";
diff --git a/components/video-player/controls/AudioSlider.tsx b/components/video-player/controls/AudioSlider.tsx
index 9f70fbba9..31c90483c 100644
--- a/components/video-player/controls/AudioSlider.tsx
+++ b/components/video-player/controls/AudioSlider.tsx
@@ -105,14 +105,14 @@ const AudioSlider: React.FC = ({ setVisibility }) => {
maximumValue={max}
thumbWidth={0}
onValueChange={handleValueChange}
+ renderBubble={() => null}
+ renderThumb={() => null}
containerStyle={{
borderRadius: 50,
}}
theme={{
minimumTrackTintColor: "#FDFDFD",
maximumTrackTintColor: "#5A5A5A",
- bubbleBackgroundColor: "transparent", // Hide the value bubble
- bubbleTextColor: "transparent", // Hide the value text
}}
/>
void;
skipCredit: () => void;
nextItem?: BaseItemDto | null;
@@ -38,6 +54,8 @@ interface BottomControlsProps {
handleSliderChange: (value: number) => void;
handleTouchStart: () => void;
handleTouchEnd: () => void;
+ /** Programmatic seek (chapter list, hotkeys) — bypasses slide gesture state. */
+ seekTo: (value: number) => void;
// Trickplay props
trickPlayUrl: {
@@ -61,14 +79,16 @@ interface BottomControlsProps {
export const BottomControls: FC = ({
item,
+ chapters,
+ durationMs,
showControls,
isSliding,
showRemoteBubble,
currentTime,
remainingTime,
- isVlc,
showSkipButton,
showSkipCreditButton,
+ hasContentAfterCredits,
skipIntro,
skipCredit,
nextItem,
@@ -84,30 +104,54 @@ export const BottomControls: FC = ({
handleSliderChange,
handleTouchStart,
handleTouchEnd,
+ seekTo,
trickPlayUrl,
trickplayInfo,
time,
}) => {
const { settings } = useSettings();
- const insets = useSafeAreaInsets();
+ const { t } = useTranslation();
+ const insets = useControlsSafeAreaInsets();
+ const [chapterListVisible, setChapterListVisible] = useState(false);
+
+ // Only expose chapter UI when there are at least two real markers.
+ const chapterMarkerList = useMemo(
+ () => chapterMarkers(chapters, durationMs),
+ [chapters, durationMs],
+ );
+ const hasChapters = chapterMarkerList.length > 1;
+
+ // Current chapter name for the always-visible header label (live playback).
+ const currentChapterName = useMemo(
+ () => (hasChapters ? chapterNameAt(currentTime, chapters) : null),
+ [hasChapters, currentTime, chapters],
+ );
+
+ // Chapter name at the scrubbed position for the trickplay bubble. `time` is
+ // an {h,m,s} object derived from the slider's dragged value — convert back
+ // to ms for the lookup. Only useful while actively scrubbing.
+ const scrubChapterName = useMemo(() => {
+ if (!hasChapters) return null;
+ const scrubMs =
+ (time.hours * 3600 + time.minutes * 60 + time.seconds) * 1000;
+ return chapterNameAt(scrubMs, chapters);
+ }, [hasChapters, time.hours, time.minutes, time.seconds, chapters]);
return (
= ({
{item?.Type === "Audio" && (
{item?.Album}
)}
+ {currentChapterName ? (
+
+ {currentChapterName}
+
+ ) : null}
-
+
+ {/* Smart Skip Credits behavior:
+ - Show "Skip Credits" if there's content after credits OR no next episode
+ - Show "Next Episode" if credits extend to video end AND next episode exists */}
- {(settings.maxAutoPlayEpisodeCount.value === -1 ||
- settings.autoPlayEpisodeCount <
- settings.maxAutoPlayEpisodeCount.value) && (
-
+ {settings.autoPlayNextEpisode !== false &&
+ (settings.maxAutoPlayEpisodeCount.value === -1 ||
+ settings.autoPlayEpisodeCount <
+ settings.maxAutoPlayEpisodeCount.value) && (
+
+ )}
+ {hasChapters && (
+ setChapterListVisible(true)}
+ hitSlop={10}
+ className='justify-center ml-4'
+ accessibilityRole='button'
+ accessibilityLabel={t("chapters.open")}
+ >
+
+
)}
@@ -168,6 +234,9 @@ export const BottomControls: FC = ({
height: 10,
justifyContent: "center",
alignItems: "stretch",
+ // Allow chapter ticks taller than the 10px track to bleed out
+ // top/bottom (RN defaults to overflow: "hidden" on Android).
+ overflow: "visible",
}}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
@@ -195,6 +264,7 @@ export const BottomControls: FC = ({
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={time}
+ chapterName={scrubChapterName}
/>
)
}
@@ -204,14 +274,21 @@ export const BottomControls: FC = ({
minimumValue={min}
maximumValue={max}
/>
+
+ setChapterListVisible(false)}
+ />
);
};
diff --git a/components/video-player/controls/BrightnessSlider.tsx b/components/video-player/controls/BrightnessSlider.tsx
index db92bd10a..dadb38386 100644
--- a/components/video-player/controls/BrightnessSlider.tsx
+++ b/components/video-player/controls/BrightnessSlider.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef } from "react";
+import { useEffect, useRef, useState } from "react";
import { Platform, StyleSheet, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import { useSharedValue } from "react-native-reanimated";
@@ -16,10 +16,19 @@ const BrightnessSlider = () => {
const max = useSharedValue(100);
const isUserInteracting = useRef(false);
const lastKnownBrightness = useRef(50);
+ const brightnessSupportedRef = useRef(true);
+ const [brightnessSupported, setBrightnessSupported] = useState(true);
// Update brightness from device
const updateBrightnessFromDevice = async () => {
- if (isTv || !Brightness || isUserInteracting.current) return;
+ // Check ref (not state) to avoid stale closure in setInterval
+ if (
+ isTv ||
+ !Brightness ||
+ isUserInteracting.current ||
+ !brightnessSupportedRef.current
+ )
+ return;
try {
const currentBrightness = await Brightness.getBrightnessAsync();
@@ -31,7 +40,10 @@ const BrightnessSlider = () => {
lastKnownBrightness.current = brightnessPercent;
}
} catch (error) {
- console.error("Error fetching brightness:", error);
+ console.warn("Brightness not supported on this device:", error);
+ // Update both ref (stops interval) and state (triggers re-render to hide)
+ brightnessSupportedRef.current = false;
+ setBrightnessSupported(false);
}
};
@@ -66,7 +78,7 @@ const BrightnessSlider = () => {
}, 100);
};
- if (isTv) return null;
+ if (isTv || !brightnessSupported) return null;
return (
@@ -76,14 +88,14 @@ const BrightnessSlider = () => {
maximumValue={max}
thumbWidth={0}
onValueChange={handleValueChange}
+ renderBubble={() => null}
+ renderThumb={() => null}
containerStyle={{
borderRadius: 50,
}}
theme={{
minimumTrackTintColor: "#FDFDFD",
maximumTrackTintColor: "#5A5A5A",
- bubbleBackgroundColor: "transparent", // Hide the value bubble
- bubbleTextColor: "transparent", // Hide the value text
}}
/>
void;
handleSkipBackward: () => void;
handleSkipForward: () => void;
+ // Chapter navigation props
+ hasChapters?: boolean;
+ hasPreviousChapter?: boolean;
+ hasNextChapter?: boolean;
+ goToPreviousChapter?: () => void;
+ goToNextChapter?: () => void;
}
export const CenterControls: FC = ({
@@ -29,36 +35,43 @@ export const CenterControls: FC = ({
togglePlay,
handleSkipBackward,
handleSkipForward,
+ hasChapters = false,
+ hasPreviousChapter = false,
+ hasNextChapter = false,
+ goToPreviousChapter,
+ goToNextChapter,
}) => {
const { settings } = useSettings();
- const insets = useSafeAreaInsets();
+ const insets = useControlsSafeAreaInsets();
return (
-
-
-
+ {!settings?.hideBrightnessSlider && (
+
+
+
+ )}
{!Platform.isTV && (
@@ -92,6 +105,20 @@ export const CenterControls: FC = ({
)}
+ {!Platform.isTV && hasChapters && (
+
+
+
+ )}
+
{!isBuffering ? (
@@ -106,6 +133,20 @@ export const CenterControls: FC = ({
+ {!Platform.isTV && hasChapters && (
+
+
+
+ )}
+
{!Platform.isTV && (
= ({
)}
-
-
-
+ {!settings?.hideVolumeSlider && (
+
+
+
+ )}
);
};
diff --git a/components/video-player/controls/ChapterMarkers.tsx b/components/video-player/controls/ChapterMarkers.tsx
new file mode 100644
index 000000000..f6ed9628a
--- /dev/null
+++ b/components/video-player/controls/ChapterMarkers.tsx
@@ -0,0 +1,65 @@
+import React from "react";
+import { StyleSheet, View, type ViewStyle } from "react-native";
+
+export interface ChapterMarkersProps {
+ /** Array of chapter positions as percentages (0-100) */
+ chapterPositions: number[];
+ /** Optional style overrides for the container */
+ style?: ViewStyle;
+ /** Height of the marker lines (should be > track height to extend above) */
+ markerHeight?: number;
+ /** Color of the marker lines */
+ markerColor?: string;
+}
+
+/**
+ * Renders vertical tick marks on the progress bar at chapter positions
+ * Should be overlaid on the slider track
+ */
+export const ChapterMarkers: React.FC = React.memo(
+ ({
+ chapterPositions,
+ style,
+ markerHeight = 15,
+ markerColor = "rgba(255, 255, 255, 0.6)",
+ }) => {
+ if (!chapterPositions.length) {
+ return null;
+ }
+
+ return (
+
+ {chapterPositions.map((position, index) => (
+
+ ))}
+
+ );
+ },
+);
+
+const styles = StyleSheet.create({
+ container: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ },
+ marker: {
+ position: "absolute",
+ width: 2,
+ borderRadius: 1,
+ transform: [{ translateX: -1 }], // Center the marker on its position
+ },
+});
diff --git a/components/video-player/controls/ContinueWatchingOverlay.tsx b/components/video-player/controls/ContinueWatchingOverlay.tsx
index 4c353deea..26f82484d 100644
--- a/components/video-player/controls/ContinueWatchingOverlay.tsx
+++ b/components/video-player/controls/ContinueWatchingOverlay.tsx
@@ -1,9 +1,9 @@
-import { useRouter } from "expo-router";
import { t } from "i18next";
import React from "react";
import { View } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
import { useSettings } from "@/utils/atoms/settings";
export interface ContinueWatchingOverlayProps {
@@ -23,7 +23,7 @@ const ContinueWatchingOverlay: React.FC = ({
settings.maxAutoPlayEpisodeCount.value ? (
diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx
index fb62fcef7..3651876a4 100644
--- a/components/video-player/controls/Controls.tsx
+++ b/components/video-player/controls/Controls.tsx
@@ -3,17 +3,9 @@ import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
-import { useLocalSearchParams, useRouter } from "expo-router";
-import {
- type Dispatch,
- type FC,
- type MutableRefObject,
- type SetStateAction,
- useCallback,
- useEffect,
- useState,
-} from "react";
-import { useWindowDimensions } from "react-native";
+import { useLocalSearchParams } from "expo-router";
+import { type FC, useCallback, useEffect, useState } from "react";
+import { StyleSheet, useWindowDimensions, View } from "react-native";
import Animated, {
Easing,
type SharedValue,
@@ -23,65 +15,64 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
+import useRouter from "@/hooks/useAppRouter";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay";
-import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
+import type { TechnicalInfo } from "@/modules/mpv-player";
import { DownloadedItem } from "@/providers/Downloads/types";
+import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { ticksToMs } from "@/utils/time";
import { BottomControls } from "./BottomControls";
import { CenterControls } from "./CenterControls";
import { CONTROLS_CONSTANTS } from "./constants";
-import { ControlProvider } from "./contexts/ControlContext";
import { EpisodeList } from "./EpisodeList";
import { GestureOverlay } from "./GestureOverlay";
import { HeaderControls } from "./HeaderControls";
+import { useChapterNavigation } from "./hooks/useChapterNavigation";
import { useRemoteControl } from "./hooks/useRemoteControl";
import { useVideoNavigation } from "./hooks/useVideoNavigation";
import { useVideoSlider } from "./hooks/useVideoSlider";
import { useVideoTime } from "./hooks/useVideoTime";
-import { type ScaleFactor } from "./ScaleFactorSelector";
+import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
import { useControlsTimeout } from "./useControlsTimeout";
+import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
import { type AspectRatio } from "./VideoScalingModeSelector";
interface Props {
item: BaseItemDto;
- videoRef: MutableRefObject;
isPlaying: boolean;
isSeeking: SharedValue;
cacheProgress: SharedValue;
progress: SharedValue;
isBuffering: boolean;
showControls: boolean;
-
enableTrickplay?: boolean;
togglePlay: () => void;
setShowControls: (shown: boolean) => void;
- offline?: boolean;
- isVideoLoaded?: boolean;
mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void;
startPictureInPicture?: () => Promise;
play: () => void;
pause: () => void;
- getAudioTracks?: (() => Promise) | (() => TrackInfo[]);
- getSubtitleTracks?: (() => Promise) | (() => TrackInfo[]);
- setSubtitleURL?: (url: string, customName: string) => void;
- setSubtitleTrack?: (index: number) => void;
- setAudioTrack?: (index: number) => void;
- setVideoAspectRatio?: (aspectRatio: string | null) => Promise;
- setVideoScaleFactor?: (scaleFactor: number) => Promise;
aspectRatio?: AspectRatio;
- scaleFactor?: ScaleFactor;
- setAspectRatio?: Dispatch>;
- setScaleFactor?: Dispatch>;
- isVlc?: boolean;
+ isZoomedToFill?: boolean;
+ onZoomToggle?: () => void;
api?: Api | null;
downloadedFiles?: DownloadedItem[];
+ // Playback speed props
+ playbackSpeed?: number;
+ setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
+ // Technical info props
+ showTechnicalInfo?: boolean;
+ onToggleTechnicalInfo?: () => void;
+ getTechnicalInfo?: () => Promise;
+ playMethod?: "DirectPlay" | "DirectStream" | "Transcode";
+ transcodeReasons?: string[];
}
export const Controls: FC = ({
@@ -99,23 +90,20 @@ export const Controls: FC = ({
showControls,
setShowControls,
mediaSource,
- isVideoLoaded,
- getAudioTracks,
- getSubtitleTracks,
- setSubtitleURL,
- setSubtitleTrack,
- setAudioTrack,
- setVideoAspectRatio,
- setVideoScaleFactor,
aspectRatio = "default",
- scaleFactor = 1.0,
- setAspectRatio,
- setScaleFactor,
- offline = false,
- isVlc = false,
+ isZoomedToFill = false,
+ onZoomToggle,
api = null,
downloadedFiles = undefined,
+ playbackSpeed = 1.0,
+ setPlaybackSpeed,
+ showTechnicalInfo = false,
+ onToggleTechnicalInfo,
+ getTechnicalInfo,
+ playMethod,
+ transcodeReasons,
}) => {
+ const offline = useOfflineMode();
const { settings, updateSettings } = useSettings();
const router = useRouter();
const lightHapticFeedback = useHaptic("light");
@@ -137,7 +125,9 @@ export const Controls: FC = ({
} = useTrickplay(item);
const min = useSharedValue(0);
- const max = useSharedValue(item.RunTimeTicks || 0);
+ // Regular value for use during render (avoids Reanimated warning)
+ const maxMs = ticksToMs(item.RunTimeTicks || 0);
+ const max = useSharedValue(maxMs);
// Animation values for controls
const controlsOpacity = useSharedValue(showControls ? 1 : 0);
@@ -194,17 +184,13 @@ export const Controls: FC = ({
zIndex: 10,
}));
- // Initialize progress values
+ // Initialize progress values - MPV uses milliseconds
useEffect(() => {
if (item) {
- progress.value = isVlc
- ? ticksToMs(item?.UserData?.PlaybackPositionTicks)
- : item?.UserData?.PlaybackPositionTicks || 0;
- max.value = isVlc
- ? ticksToMs(item.RunTimeTicks || 0)
- : item.RunTimeTicks || 0;
+ progress.value = ticksToMs(item?.UserData?.PlaybackPositionTicks);
+ max.value = ticksToMs(item.RunTimeTicks || 0);
}
- }, [item, isVlc, progress, max]);
+ }, [item, progress, max]);
// Navigation hooks
const {
@@ -215,7 +201,6 @@ export const Controls: FC = ({
} = useVideoNavigation({
progress,
isPlaying,
- isVlc,
seek,
play,
});
@@ -225,7 +210,20 @@ export const Controls: FC = ({
progress,
max,
isSeeking,
- isVlc,
+ });
+
+ // Chapter navigation hook
+ const {
+ hasChapters,
+ hasPreviousChapter,
+ hasNextChapter,
+ goToPreviousChapter,
+ goToNextChapter,
+ } = useChapterNavigation({
+ chapters: item.Chapters,
+ progress,
+ maxMs,
+ seek,
});
const toggleControls = useCallback(() => {
@@ -248,7 +246,6 @@ export const Controls: FC = ({
progress,
min,
max,
- isVlc,
showControls,
isPlaying,
seek,
@@ -269,11 +266,11 @@ export const Controls: FC = ({
handleTouchEnd,
handleSliderComplete,
handleSliderChange,
+ seekTo,
} = useVideoSlider({
progress,
isSeeking,
isPlaying,
- isVlc,
seek,
play,
pause,
@@ -302,9 +299,8 @@ export const Controls: FC = ({
: current.actual;
} else {
// When not scrubbing, only update if progress changed significantly (1 second)
- const progressUnit = isVlc
- ? CONTROLS_CONSTANTS.PROGRESS_UNIT_MS
- : CONTROLS_CONSTANTS.PROGRESS_UNIT_TICKS;
+ // MPV uses milliseconds
+ const progressUnit = CONTROLS_CONSTANTS.PROGRESS_UNIT_MS;
const progressDiff = Math.abs(current.actual - effectiveProgress.value);
if (progressDiff >= progressUnit) {
effectiveProgress.value = current.actual;
@@ -325,22 +321,22 @@ export const Controls: FC = ({
currentTime,
seek,
play,
- isVlc,
offline,
api,
downloadedFiles,
);
- const { showSkipCreditButton, skipCredit } = useCreditSkipper(
- item.Id!,
- currentTime,
- seek,
- play,
- isVlc,
- offline,
- api,
- downloadedFiles,
- );
+ const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
+ useCreditSkipper(
+ item.Id!,
+ currentTime,
+ seek,
+ play,
+ offline,
+ api,
+ downloadedFiles,
+ maxMs,
+ );
const goToItemCommon = useCallback(
(item: BaseItemDto) => {
@@ -362,11 +358,16 @@ export const Controls: FC = ({
} = getDefaultPlaySettings(
item,
settings,
- previousIndexes,
- mediaSource ?? undefined,
+ {
+ indexes: previousIndexes,
+ source: mediaSource ?? undefined,
+ },
+ { applyLanguagePreferences: true },
);
- const queryParams = new URLSearchParams({
+ // Use setParams instead of replace to avoid unmounting/remounting the player,
+ // which would create a new MPV native view and crash with "mp_initialize already initialized".
+ router.setParams({
...(offline && { offline: "true" }),
itemId: item.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
@@ -375,13 +376,17 @@ export const Controls: FC = ({
bitrateValue: bitrateValue?.toString(),
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
- }).toString();
-
- console.log("queryParams", queryParams);
-
- router.replace(`player/direct-player?${queryParams}` as any);
+ });
},
- [settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
+ [
+ settings,
+ subtitleIndex,
+ audioIndex,
+ mediaSource,
+ bitrateValue,
+ router,
+ offline,
+ ],
);
const goToPreviousItem = useCallback(() => {
@@ -469,6 +474,7 @@ export const Controls: FC = ({
episodeView,
onHideControls: hideControls,
timeout: CONTROLS_CONSTANTS.TIMEOUT,
+ disabled: true,
});
const switchOnEpisodeMode = useCallback(() => {
@@ -479,11 +485,7 @@ export const Controls: FC = ({
}, [isPlaying, togglePlay]);
return (
-
+
{episodeView ? (
= ({
onSkipForward={handleSkipForward}
onSkipBackward={handleSkipBackward}
/>
+ {/* Technical Info Overlay - rendered outside animated views to stay visible */}
+ {getTechnicalInfo && (
+
+ )}
= ({
goToNextItem={goToNextItem}
previousItem={previousItem}
nextItem={nextItem}
- getAudioTracks={getAudioTracks}
- getSubtitleTracks={getSubtitleTracks}
- setAudioTrack={setAudioTrack}
- setSubtitleTrack={setSubtitleTrack}
- setSubtitleURL={setSubtitleURL}
aspectRatio={aspectRatio}
- scaleFactor={scaleFactor}
- setAspectRatio={setAspectRatio}
- setScaleFactor={setScaleFactor}
- setVideoAspectRatio={setVideoAspectRatio}
- setVideoScaleFactor={setVideoScaleFactor}
+ isZoomedToFill={isZoomedToFill}
+ onZoomToggle={onZoomToggle}
+ playbackSpeed={playbackSpeed}
+ setPlaybackSpeed={setPlaybackSpeed}
+ showTechnicalInfo={showTechnicalInfo}
+ onToggleTechnicalInfo={onToggleTechnicalInfo}
/>
= ({
togglePlay={togglePlay}
handleSkipBackward={handleSkipBackward}
handleSkipForward={handleSkipForward}
+ hasChapters={hasChapters}
+ hasPreviousChapter={hasPreviousChapter}
+ hasNextChapter={hasNextChapter}
+ goToPreviousChapter={goToPreviousChapter}
+ goToNextChapter={goToNextChapter}
/>
= ({
>
= ({
handleSliderChange={handleSliderChange}
handleTouchStart={handleTouchStart}
handleTouchEnd={handleTouchEnd}
+ seekTo={seekTo}
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={isSliding || showRemoteBubble ? time : remoteTime}
@@ -582,6 +599,16 @@ export const Controls: FC = ({
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
)}
-
+
);
};
+
+const styles = StyleSheet.create({
+ controlsContainer: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ },
+});
diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx
new file mode 100644
index 000000000..29e5d1f03
--- /dev/null
+++ b/components/video-player/controls/Controls.tv.tsx
@@ -0,0 +1,1626 @@
+import type {
+ BaseItemDto,
+ MediaSourceInfo,
+} from "@jellyfin/sdk/lib/generated-client";
+import { useLocalSearchParams } from "expo-router";
+import { useAtomValue } from "jotai";
+import {
+ type FC,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Pressable,
+ StyleSheet,
+ TVFocusGuideView,
+ useWindowDimensions,
+ View,
+} from "react-native";
+import Animated, {
+ Easing,
+ type SharedValue,
+ useAnimatedReaction,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import {
+ TVControlButton,
+ TVNextEpisodeCountdown,
+ TVSkipSegmentCard,
+} from "@/components/tv";
+import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import useRouter from "@/hooks/useAppRouter";
+import { useCreditSkipper } from "@/hooks/useCreditSkipper";
+import { useIntroSkipper } from "@/hooks/useIntroSkipper";
+import { usePlaybackManager } from "@/hooks/usePlaybackManager";
+import { useTrickplay } from "@/hooks/useTrickplay";
+import { useTVOptionModal } from "@/hooks/useTVOptionModal";
+import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
+import type { TechnicalInfo } from "@/modules/mpv-player";
+import type { DownloadedItem } from "@/providers/Downloads/types";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { useOfflineMode } from "@/providers/OfflineModeProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
+import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
+import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
+import { CONTROLS_CONSTANTS } from "./constants";
+import { useVideoContext } from "./contexts/VideoContext";
+import { useChapterNavigation } from "./hooks/useChapterNavigation";
+import { useRemoteControl } from "./hooks/useRemoteControl";
+import { useVideoTime } from "./hooks/useVideoTime";
+import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
+import { TrickplayBubble } from "./TrickplayBubble";
+import type { Track } from "./types";
+import { useControlsTimeout } from "./useControlsTimeout";
+
+interface Props {
+ item: BaseItemDto;
+ isPlaying: boolean;
+ isSeeking: SharedValue;
+ cacheProgress: SharedValue;
+ progress: SharedValue;
+ isBuffering?: boolean;
+ showControls: boolean;
+ togglePlay: () => void;
+ setShowControls: (shown: boolean) => void;
+ mediaSource?: MediaSourceInfo | null;
+ seek: (ticks: number) => void;
+ play: () => void;
+ pause: () => void;
+ audioIndex?: number;
+ subtitleIndex?: number;
+ onAudioIndexChange?: (index: number) => void;
+ onSubtitleIndexChange?: (index: number) => void;
+ previousItem?: BaseItemDto | null;
+ nextItem?: BaseItemDto | null;
+ goToPreviousItem?: () => void;
+ goToNextItem?: () => void;
+ onRefreshSubtitleTracks?: () => Promise<
+ import("@jellyfin/sdk/lib/generated-client").MediaStream[]
+ >;
+ addSubtitleFile?: (path: string) => void;
+ showTechnicalInfo?: boolean;
+ onToggleTechnicalInfo?: () => void;
+ getTechnicalInfo?: () => Promise;
+ playMethod?: "DirectPlay" | "DirectStream" | "Transcode";
+ transcodeReasons?: string[];
+ downloadedFiles?: DownloadedItem[];
+}
+
+const TV_SEEKBAR_HEIGHT = 14;
+const TV_AUTO_HIDE_TIMEOUT = 5000;
+
+// Trickplay bubble positioning constants
+const TV_TRICKPLAY_SCALE = 2;
+const TV_TRICKPLAY_BUBBLE_BASE_WIDTH = CONTROLS_CONSTANTS.TILE_WIDTH * 1.5;
+const TV_TRICKPLAY_BUBBLE_WIDTH =
+ TV_TRICKPLAY_BUBBLE_BASE_WIDTH * TV_TRICKPLAY_SCALE;
+const TV_TRICKPLAY_INTERNAL_OFFSET = 62 * TV_TRICKPLAY_SCALE;
+const TV_TRICKPLAY_CENTERING_OFFSET = 98 * TV_TRICKPLAY_SCALE;
+const TV_TRICKPLAY_RIGHT_PADDING = 150;
+const TV_TRICKPLAY_FADE_DURATION = 200;
+
+interface TVTrickplayBubbleProps {
+ trickPlayUrl: {
+ x: number;
+ y: number;
+ url: string;
+ } | null;
+ trickplayInfo: {
+ aspectRatio?: number;
+ data: {
+ TileWidth?: number;
+ TileHeight?: number;
+ };
+ } | null;
+ time: {
+ hours: number;
+ minutes: number;
+ seconds: number;
+ };
+ progress: SharedValue;
+ max: SharedValue;
+ progressBarWidth: number;
+ visible: boolean;
+}
+
+const TVTrickplayBubblePositioned: FC = ({
+ trickPlayUrl,
+ trickplayInfo,
+ time,
+ progress,
+ max,
+ progressBarWidth,
+ visible,
+}) => {
+ const opacity = useSharedValue(0);
+
+ useEffect(() => {
+ opacity.value = withTiming(visible ? 1 : 0, {
+ duration: TV_TRICKPLAY_FADE_DURATION,
+ easing: Easing.out(Easing.quad),
+ });
+ }, [visible, opacity]);
+
+ const minX = TV_TRICKPLAY_INTERNAL_OFFSET;
+ const maxX =
+ progressBarWidth -
+ TV_TRICKPLAY_BUBBLE_WIDTH +
+ TV_TRICKPLAY_INTERNAL_OFFSET +
+ TV_TRICKPLAY_RIGHT_PADDING;
+
+ const animatedStyle = useAnimatedStyle(() => {
+ const progressPercent = max.value > 0 ? progress.value / max.value : 0;
+
+ const xPosition = Math.max(
+ minX,
+ Math.min(
+ maxX,
+ progressPercent * progressBarWidth -
+ TV_TRICKPLAY_BUBBLE_WIDTH / 2 +
+ TV_TRICKPLAY_CENTERING_OFFSET,
+ ),
+ );
+
+ return {
+ transform: [{ translateX: xPosition }],
+ opacity: opacity.value,
+ };
+ });
+
+ return (
+
+
+
+ );
+};
+
+export const Controls: FC = ({
+ item,
+ seek,
+ play: _play,
+ pause: _pause,
+ togglePlay,
+ isPlaying,
+ isSeeking,
+ progress,
+ cacheProgress,
+ showControls,
+ setShowControls,
+ mediaSource,
+ audioIndex,
+ subtitleIndex,
+ onAudioIndexChange,
+ onSubtitleIndexChange,
+ previousItem,
+ nextItem: nextItemProp,
+ goToPreviousItem,
+ goToNextItem: goToNextItemProp,
+ onRefreshSubtitleTracks,
+ addSubtitleFile,
+ showTechnicalInfo,
+ onToggleTechnicalInfo,
+ getTechnicalInfo,
+ playMethod,
+ transcodeReasons,
+ downloadedFiles,
+}) => {
+ const typography = useScaledTVTypography();
+ const insets = useSafeAreaInsets();
+ const { width: screenWidth } = useWindowDimensions();
+ const { t } = useTranslation();
+
+ // Calculate progress bar width (matches the padding used in bottomInner)
+ const progressBarWidth = useMemo(() => {
+ const leftPadding = Math.max(insets.left, 48);
+ const rightPadding = Math.max(insets.right, 48);
+ return screenWidth - leftPadding - rightPadding;
+ }, [screenWidth, insets.left, insets.right]);
+ const api = useAtomValue(apiAtom);
+ const { settings } = useSettings();
+ const router = useRouter();
+ const { bitrateValue } = useLocalSearchParams<{
+ bitrateValue: string;
+ }>();
+
+ const { nextItem: internalNextItem } = usePlaybackManager({
+ item,
+ isOffline: false,
+ });
+
+ const nextItem = nextItemProp ?? internalNextItem;
+
+ // TV Option Modal hook for audio selector
+ const { showOptions } = useTVOptionModal();
+
+ // TV Subtitle Modal hook
+ const { showSubtitleModal } = useTVSubtitleModal();
+
+ // Get subtitle tracks from VideoContext (with proper MPV index mapping)
+ const { subtitleTracks: videoContextSubtitleTracks } = useVideoContext();
+
+ // Track which button should have preferred focus when controls show
+ type LastModalType = "audio" | "subtitle" | "techInfo" | null;
+ const [lastOpenedModal, setLastOpenedModal] = useState(null);
+
+ // Track if play button should have focus (when showing controls via up/down D-pad)
+ const [focusPlayButton, setFocusPlayButton] = useState(false);
+
+ // State for progress bar focus and focus guide refs
+ const [isProgressBarFocused, setIsProgressBarFocused] = useState(false);
+ const [playButtonRef, setPlayButtonRef] = useState(null);
+ const [progressBarRef, setProgressBarRef] = useState(null);
+ const [skipSegmentRef, setSkipSegmentRef] = useState(null);
+ const [nextEpisodeRef, setNextEpisodeRef] = useState(null);
+
+ // Minimal seek bar state (shows only progress bar when seeking while controls hidden)
+ const [showMinimalSeekBar, setShowMinimalSeekBar] = useState(false);
+ const minimalSeekBarOpacity = useSharedValue(0);
+ const minimalSeekBarTimeoutRef = useRef | null>(
+ null,
+ );
+
+ // Ref for the invisible focus-stealing overlay (prevents hidden buttons from receiving select events)
+ const focusOverlayRef = useRef(null);
+
+ const audioTracks = useMemo(() => {
+ return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? [];
+ }, [mediaSource]);
+
+ const _subtitleTracks = useMemo(() => {
+ return (
+ mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") ?? []
+ );
+ }, [mediaSource]);
+
+ const audioOptions: TVOptionItem[] = useMemo(() => {
+ return audioTracks.map((track) => ({
+ label:
+ track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
+ value: track.Index!,
+ selected: track.Index === audioIndex,
+ }));
+ }, [audioTracks, audioIndex]);
+
+ const handleAudioChange = useCallback(
+ (index: number) => {
+ onAudioIndexChange?.(index);
+ },
+ [onAudioIndexChange],
+ );
+
+ const _handleSubtitleChange = useCallback(
+ (index: number) => {
+ onSubtitleIndexChange?.(index);
+ },
+ [onSubtitleIndexChange],
+ );
+
+ // Re-fetch subtitle streams from the server (e.g. after a server-side
+ // download) and map them to the modal's Track shape. setTrack drives the
+ // player through the same handler used for manual subtitle selection.
+ const refreshSubtitleTracks = useCallback(async (): Promise
);
};
diff --git a/components/video-player/controls/GestureOverlay.tsx b/components/video-player/controls/GestureOverlay.tsx
index e4ca20e6e..6f395af77 100644
--- a/components/video-player/controls/GestureOverlay.tsx
+++ b/components/video-player/controls/GestureOverlay.tsx
@@ -41,6 +41,7 @@ export const GestureOverlay = ({
});
const [fadeAnim] = useState(new Animated.Value(0));
const isDraggingRef = useRef(false);
+ const hideScheduledRef = useRef(false);
const hideTimeoutRef = useRef(null);
const lastUpdateTime = useRef(0);
@@ -51,18 +52,11 @@ export const GestureOverlay = ({
side?: "left" | "right",
isDuringDrag = false,
) => {
- // Clear any existing timeout
- if (hideTimeoutRef.current) {
- clearTimeout(hideTimeoutRef.current);
- hideTimeoutRef.current = null;
- }
-
- // Defer ALL state updates to avoid useInsertionEffect warning
requestAnimationFrame(() => {
setFeedback({ visible: true, icon, text, side });
if (!isDuringDrag) {
- // For discrete actions (like skip), show normal animation
+ hideScheduledRef.current = false;
Animated.sequence([
Animated.timing(fadeAnim, {
toValue: 1,
@@ -80,16 +74,17 @@ export const GestureOverlay = ({
setFeedback((prev) => ({ ...prev, visible: false }));
});
});
- } else if (!isDraggingRef.current) {
- // For drag start, just fade in and stay visible
+ } else if (!isDraggingRef.current && !hideScheduledRef.current) {
+ // Cancel any pending hide from a previous drag
+ if (hideTimeoutRef.current) {
+ clearTimeout(hideTimeoutRef.current);
+ hideTimeoutRef.current = null;
+ }
+ hideScheduledRef.current = false;
isDraggingRef.current = true;
- Animated.timing(fadeAnim, {
- toValue: 1,
- duration: 200,
- useNativeDriver: true,
- }).start();
+ fadeAnim.stopAnimation();
+ fadeAnim.setValue(1);
}
- // For drag updates, just update the state, don't restart animation
});
},
[fadeAnim],
@@ -97,9 +92,9 @@ export const GestureOverlay = ({
const hideDragFeedback = useCallback(() => {
isDraggingRef.current = false;
-
- // Delay hiding slightly to avoid flicker
+ hideScheduledRef.current = true;
hideTimeoutRef.current = setTimeout(() => {
+ fadeAnim.stopAnimation();
Animated.timing(fadeAnim, {
toValue: 0,
duration: 300,
@@ -107,6 +102,7 @@ export const GestureOverlay = ({
}).start(() => {
requestAnimationFrame(() => {
setFeedback((prev) => ({ ...prev, visible: false }));
+ hideScheduledRef.current = false;
});
});
}, 100) as unknown as number;
diff --git a/components/video-player/controls/HeaderControls.tsx b/components/video-player/controls/HeaderControls.tsx
index d8e4dcfe8..d3cd7a285 100644
--- a/components/video-player/controls/HeaderControls.tsx
+++ b/components/video-player/controls/HeaderControls.tsx
@@ -3,25 +3,18 @@ import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
-import { useRouter } from "expo-router";
-import { type Dispatch, type FC, type SetStateAction } from "react";
-import {
- Platform,
- TouchableOpacity,
- useWindowDimensions,
- View,
-} from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { type FC, useCallback, useState } from "react";
+import { Platform, TouchableOpacity, View } from "react-native";
+import useRouter from "@/hooks/useAppRouter";
+import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useHaptic } from "@/hooks/useHaptic";
-import { useSettings, VideoPlayer } from "@/utils/atoms/settings";
-import { ICON_SIZES } from "./constants";
-import { VideoProvider } from "./contexts/VideoContext";
+import { useOrientation } from "@/hooks/useOrientation";
+import { OrientationLock } from "@/packages/expo-screen-orientation";
+import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
import DropdownView from "./dropdown/DropdownView";
-import { type ScaleFactor, ScaleFactorSelector } from "./ScaleFactorSelector";
-import {
- type AspectRatio,
- AspectRatioSelector,
-} from "./VideoScalingModeSelector";
+import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
+import { type AspectRatio } from "./VideoScalingModeSelector";
+import { ZoomToggle } from "./ZoomToggle";
interface HeaderControlsProps {
item: BaseItemDto;
@@ -34,17 +27,15 @@ interface HeaderControlsProps {
goToNextItem: (options: { isAutoPlay?: boolean }) => void;
previousItem?: BaseItemDto | null;
nextItem?: BaseItemDto | null;
- getAudioTracks?: (() => Promise) | (() => any[]);
- getSubtitleTracks?: (() => Promise) | (() => any[]);
- setAudioTrack?: (index: number) => void;
- setSubtitleTrack?: (index: number) => void;
- setSubtitleURL?: (url: string, customName: string) => void;
aspectRatio?: AspectRatio;
- scaleFactor?: ScaleFactor;
- setAspectRatio?: Dispatch>;
- setScaleFactor?: Dispatch>;
- setVideoAspectRatio?: (aspectRatio: string | null) => Promise;
- setVideoScaleFactor?: (scaleFactor: number) => Promise;
+ isZoomedToFill?: boolean;
+ onZoomToggle?: () => void;
+ // Playback speed props
+ playbackSpeed?: number;
+ setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
+ // Technical info props
+ showTechnicalInfo?: boolean;
+ onToggleTechnicalInfo?: () => void;
}
export const HeaderControls: FC = ({
@@ -58,90 +49,107 @@ export const HeaderControls: FC = ({
goToNextItem,
previousItem,
nextItem,
- getAudioTracks,
- getSubtitleTracks,
- setAudioTrack,
- setSubtitleTrack,
- setSubtitleURL,
- aspectRatio = "default",
- scaleFactor = 1.0,
- setAspectRatio,
- setScaleFactor,
- setVideoAspectRatio,
- setVideoScaleFactor,
+ aspectRatio: _aspectRatio = "default",
+ isZoomedToFill = false,
+ onZoomToggle,
+ playbackSpeed = 1.0,
+ setPlaybackSpeed,
+ showTechnicalInfo = false,
+ onToggleTechnicalInfo,
}) => {
- const { settings } = useSettings();
const router = useRouter();
- const insets = useSafeAreaInsets();
- const { width: screenWidth } = useWindowDimensions();
+ const insets = useControlsSafeAreaInsets();
const lightHapticFeedback = useHaptic("light");
-
- const handleAspectRatioChange = async (newRatio: AspectRatio) => {
- if (!setAspectRatio || !setVideoAspectRatio) return;
-
- setAspectRatio(newRatio);
- const aspectRatioString = newRatio === "default" ? null : newRatio;
- await setVideoAspectRatio(aspectRatioString);
- };
-
- const handleScaleFactorChange = async (newScale: ScaleFactor) => {
- if (!setScaleFactor || !setVideoScaleFactor) return;
-
- setScaleFactor(newScale);
- await setVideoScaleFactor(newScale);
- };
+ const { orientation, lockOrientation } = useOrientation();
+ const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
const onClose = async () => {
lightHapticFeedback();
router.back();
};
+ const toggleOrientation = useCallback(async () => {
+ if (isTogglingOrientation) return;
+
+ setIsTogglingOrientation(true);
+ lightHapticFeedback();
+
+ try {
+ const isPortrait =
+ orientation === OrientationLock.PORTRAIT_UP ||
+ orientation === OrientationLock.PORTRAIT_DOWN;
+
+ await lockOrientation(
+ isPortrait ? OrientationLock.LANDSCAPE : OrientationLock.PORTRAIT_UP,
+ );
+ } finally {
+ setIsTogglingOrientation(false);
+ }
+ }, [
+ orientation,
+ lockOrientation,
+ isTogglingOrientation,
+ lightHapticFeedback,
+ ]);
+
return (
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
-
-
-
-
-
+
+
+
)}
- {!Platform.isTV &&
- (settings.defaultPlayer === VideoPlayer.VLC_4 ||
- Platform.OS === "android") && (
-
-
-
- )}
+ {/* Rotate toggle is Android-only: iOS does not reliably rotate the
+ player back to portrait programmatically. */}
+ {Platform.OS === "android" && (
+
+
+
+ )}
+ {!Platform.isTV && startPictureInPicture && (
+
+
+
+ )}
{item?.Type === "Episode" && (
= ({
/>
)}
-
- {})}
+ disabled={!onZoomToggle}
/>
= ({
}
},
);
+
+ // Cancel animation on unmount to prevent onFinish from firing after exit
+ return () => {
+ cancelAnimation(progress);
+ };
}
}, [show, onFinish]);
diff --git a/components/video-player/controls/ScaleFactorSelector.tsx b/components/video-player/controls/ScaleFactorSelector.tsx
deleted file mode 100644
index 0e5f2b100..000000000
--- a/components/video-player/controls/ScaleFactorSelector.tsx
+++ /dev/null
@@ -1,146 +0,0 @@
-import { Ionicons } from "@expo/vector-icons";
-import React, { useMemo } from "react";
-import { Platform, View } from "react-native";
-import {
- type OptionGroup,
- PlatformDropdown,
-} from "@/components/PlatformDropdown";
-import { useHaptic } from "@/hooks/useHaptic";
-
-export type ScaleFactor =
- | 1.0
- | 1.1
- | 1.2
- | 1.3
- | 1.4
- | 1.5
- | 1.6
- | 1.7
- | 1.8
- | 1.9
- | 2.0;
-
-interface ScaleFactorSelectorProps {
- currentScale: ScaleFactor;
- onScaleChange: (scale: ScaleFactor) => void;
- disabled?: boolean;
-}
-
-interface ScaleFactorOption {
- id: ScaleFactor;
- label: string;
- description: string;
-}
-
-const SCALE_FACTOR_OPTIONS: ScaleFactorOption[] = [
- {
- id: 1.0,
- label: "1.0x",
- description: "Original size",
- },
- {
- id: 1.1,
- label: "1.1x",
- description: "10% larger",
- },
- {
- id: 1.2,
- label: "1.2x",
- description: "20% larger",
- },
- {
- id: 1.3,
- label: "1.3x",
- description: "30% larger",
- },
- {
- id: 1.4,
- label: "1.4x",
- description: "40% larger",
- },
- {
- id: 1.5,
- label: "1.5x",
- description: "50% larger",
- },
- {
- id: 1.6,
- label: "1.6x",
- description: "60% larger",
- },
- {
- id: 1.7,
- label: "1.7x",
- description: "70% larger",
- },
- {
- id: 1.8,
- label: "1.8x",
- description: "80% larger",
- },
- {
- id: 1.9,
- label: "1.9x",
- description: "90% larger",
- },
- {
- id: 2.0,
- label: "2.0x",
- description: "Double size",
- },
-];
-
-export const ScaleFactorSelector: React.FC = ({
- currentScale,
- onScaleChange,
- disabled = false,
-}) => {
- const lightHapticFeedback = useHaptic("light");
-
- const handleScaleSelect = (scale: ScaleFactor) => {
- onScaleChange(scale);
- lightHapticFeedback();
- };
-
- const optionGroups = useMemo(() => {
- return [
- {
- options: SCALE_FACTOR_OPTIONS.map((option) => ({
- type: "radio" as const,
- label: option.label,
- value: option.id,
- selected: option.id === currentScale,
- onPress: () => handleScaleSelect(option.id),
- disabled,
- })),
- },
- ];
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [currentScale, disabled]);
-
- const trigger = useMemo(
- () => (
-
-
-
- ),
- [disabled],
- );
-
- // Hide on TV platforms
- if (Platform.isTV) return null;
-
- return (
-
- );
-};
diff --git a/components/video-player/controls/TechnicalInfoOverlay.tsx b/components/video-player/controls/TechnicalInfoOverlay.tsx
new file mode 100644
index 000000000..f5a2e7618
--- /dev/null
+++ b/components/video-player/controls/TechnicalInfoOverlay.tsx
@@ -0,0 +1,418 @@
+import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client";
+import {
+ type FC,
+ memo,
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+} from "react";
+import { Platform, StyleSheet, Text, View } from "react-native";
+import Animated, {
+ Easing,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
+import type { TechnicalInfo } from "@/modules/mpv-player";
+import { HEADER_LAYOUT } from "./constants";
+
+type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
+
+interface TechnicalInfoOverlayProps {
+ showControls: boolean;
+ visible: boolean;
+ getTechnicalInfo: () => Promise;
+ playMethod?: PlayMethod;
+ transcodeReasons?: string[];
+ mediaSource?: MediaSourceInfo | null;
+ currentSubtitleIndex?: number;
+ currentAudioIndex?: number;
+}
+
+const formatBitrate = (bitsPerSecond: number): string => {
+ const mbps = bitsPerSecond / 1_000_000;
+ if (mbps >= 1) {
+ return `${mbps.toFixed(1)} Mbps`;
+ }
+ const kbps = bitsPerSecond / 1_000;
+ return `${kbps.toFixed(0)} Kbps`;
+};
+
+const formatCodec = (codec: string): string => {
+ // Normalize common codec names
+ const codecMap: Record = {
+ h264: "H.264",
+ hevc: "HEVC",
+ h265: "HEVC",
+ vp9: "VP9",
+ vp8: "VP8",
+ av1: "AV1",
+ aac: "AAC",
+ ac3: "AC3",
+ eac3: "E-AC3",
+ dts: "DTS",
+ truehd: "TrueHD",
+ flac: "FLAC",
+ opus: "Opus",
+ mp3: "MP3",
+ // Subtitle codecs
+ srt: "SRT",
+ subrip: "SRT",
+ ass: "ASS",
+ ssa: "SSA",
+ webvtt: "WebVTT",
+ vtt: "WebVTT",
+ pgs: "PGS",
+ hdmv_pgs_subtitle: "PGS",
+ dvd_subtitle: "VobSub",
+ dvdsub: "VobSub",
+ mov_text: "MOV Text",
+ cc_dec: "CC",
+ eia_608: "CC",
+ };
+ return codecMap[codec.toLowerCase()] || codec.toUpperCase();
+};
+
+const formatAudioChannels = (channels: number): string => {
+ switch (channels) {
+ case 1:
+ return "Mono";
+ case 2:
+ return "Stereo";
+ case 6:
+ return "5.1";
+ case 8:
+ return "7.1";
+ default:
+ return `${channels}ch`;
+ }
+};
+
+const formatVideoRange = (range?: string | null): string | null => {
+ if (!range || range === "SDR") return null;
+ const rangeMap: Record = {
+ HDR10: "HDR10",
+ HDR10Plus: "HDR10+",
+ HLG: "HLG",
+ "Dolby Vision": "Dolby Vision",
+ DolbyVision: "Dolby Vision",
+ };
+ return rangeMap[range] || range;
+};
+
+const formatFps = (fps: number): string => {
+ // Common frame rates
+ if (Math.abs(fps - 23.976) < 0.01) return "23.976";
+ if (Math.abs(fps - 29.97) < 0.01) return "29.97";
+ if (Math.abs(fps - 59.94) < 0.01) return "59.94";
+ if (Number.isInteger(fps)) return fps.toString();
+ return fps.toFixed(2);
+};
+
+const getPlayMethodLabel = (method: PlayMethod): string => {
+ switch (method) {
+ case "DirectPlay":
+ return "Direct Play";
+ case "DirectStream":
+ return "Direct Stream";
+ case "Transcode":
+ return "Transcoding";
+ default:
+ return method;
+ }
+};
+
+const getPlayMethodColor = (method: PlayMethod): string => {
+ switch (method) {
+ case "DirectPlay":
+ return "#4ade80"; // green
+ case "DirectStream":
+ return "#60a5fa"; // blue
+ case "Transcode":
+ return "#fbbf24"; // yellow/amber
+ default:
+ return "white";
+ }
+};
+
+const formatTranscodeReason = (reason: string): string => {
+ // Convert camelCase/PascalCase to readable format
+ const reasonMap: Record = {
+ ContainerNotSupported: "Container not supported",
+ VideoCodecNotSupported: "Video codec not supported",
+ AudioCodecNotSupported: "Audio codec not supported",
+ SubtitleCodecNotSupported: "Subtitle codec not supported",
+ AudioIsExternal: "Audio is external",
+ SecondaryAudioNotSupported: "Secondary audio not supported",
+ VideoProfileNotSupported: "Video profile not supported",
+ VideoLevelNotSupported: "Video level not supported",
+ VideoResolutionNotSupported: "Resolution not supported",
+ VideoBitDepthNotSupported: "Bit depth not supported",
+ VideoFramerateNotSupported: "Framerate not supported",
+ RefFramesNotSupported: "Ref frames not supported",
+ AnamorphicVideoNotSupported: "Anamorphic video not supported",
+ InterlacedVideoNotSupported: "Interlaced video not supported",
+ AudioChannelsNotSupported: "Audio channels not supported",
+ AudioProfileNotSupported: "Audio profile not supported",
+ AudioSampleRateNotSupported: "Sample rate not supported",
+ AudioBitDepthNotSupported: "Audio bit depth not supported",
+ ContainerBitrateExceedsLimit: "Bitrate exceeds limit",
+ VideoBitrateNotSupported: "Video bitrate not supported",
+ AudioBitrateNotSupported: "Audio bitrate not supported",
+ UnknownVideoStreamInfo: "Unknown video stream",
+ UnknownAudioStreamInfo: "Unknown audio stream",
+ DirectPlayError: "Direct play error",
+ VideoRangeTypeNotSupported: "HDR not supported",
+ VideoCodecTagNotSupported: "Video codec tag not supported",
+ };
+ return reasonMap[reason] || reason;
+};
+
+export const TechnicalInfoOverlay: FC = memo(
+ ({
+ showControls: _showControls,
+ visible,
+ getTechnicalInfo,
+ playMethod,
+ transcodeReasons,
+ mediaSource,
+ currentSubtitleIndex,
+ currentAudioIndex,
+ }) => {
+ const typography = useScaledTVTypography();
+ const insets = useSafeAreaInsets();
+ const safeInsets = useControlsSafeAreaInsets();
+ const [info, setInfo] = useState(null);
+
+ const opacity = useSharedValue(0);
+
+ // Extract stream info from media source
+ const streamInfo = useMemo(() => {
+ if (!mediaSource?.MediaStreams) return null;
+
+ const videoStream = mediaSource.MediaStreams.find(
+ (s) => s.Type === "Video",
+ );
+ const audioStream = mediaSource.MediaStreams.find(
+ (s) =>
+ s.Type === "Audio" &&
+ (currentAudioIndex !== undefined
+ ? s.Index === currentAudioIndex
+ : s.IsDefault),
+ );
+ const subtitleStream = mediaSource.MediaStreams.find(
+ (s) =>
+ s.Type === "Subtitle" &&
+ currentSubtitleIndex !== undefined &&
+ currentSubtitleIndex >= 0 &&
+ s.Index === currentSubtitleIndex,
+ );
+
+ return {
+ container: mediaSource.Container,
+ videoRange: videoStream?.VideoRangeType,
+ bitDepth: videoStream?.BitDepth,
+ audioChannels: audioStream?.Channels,
+ audioCodecFromSource: audioStream?.Codec,
+ subtitleCodec: subtitleStream?.Codec,
+ subtitleTitle: subtitleStream?.DisplayTitle,
+ };
+ }, [mediaSource, currentAudioIndex, currentSubtitleIndex]);
+
+ // Animate visibility based on visible prop only (stays visible regardless of controls)
+ useEffect(() => {
+ opacity.value = withTiming(visible ? 1 : 0, {
+ duration: 300,
+ easing: Easing.out(Easing.quad),
+ });
+ }, [visible, opacity]);
+
+ // Fetch technical info periodically when visible
+ const fetchInfo = useCallback(async () => {
+ try {
+ const data = await getTechnicalInfo();
+ setInfo(data);
+ } catch (_error) {
+ // Silently fail - the info is optional
+ }
+ }, [getTechnicalInfo]);
+
+ useEffect(() => {
+ if (!visible) {
+ return;
+ }
+
+ // Fetch immediately
+ fetchInfo();
+
+ // Then fetch every 2 seconds
+ const interval = setInterval(fetchInfo, 2000);
+ return () => clearInterval(interval);
+ }, [visible, fetchInfo]);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ opacity: opacity.value,
+ }));
+
+ // Don't render if not visible
+ if (!visible) return null;
+
+ // TV-specific styles
+ const containerStyle = Platform.isTV
+ ? {
+ top: Math.max(insets.top, 48) + 20,
+ left: Math.max(insets.left, 48) + 20,
+ }
+ : {
+ top: safeInsets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4,
+ left: safeInsets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20,
+ };
+
+ const textStyle = Platform.isTV
+ ? [
+ styles.infoTextTV,
+ { fontSize: typography.body, lineHeight: typography.body * 1.5 },
+ ]
+ : styles.infoText;
+ const reasonStyle = Platform.isTV
+ ? [styles.reasonTextTV, { fontSize: typography.callout }]
+ : styles.reasonText;
+ const boxStyle = Platform.isTV ? styles.infoBoxTV : styles.infoBox;
+
+ return (
+
+
+ {playMethod && (
+
+ {getPlayMethodLabel(playMethod)}
+
+ )}
+ {transcodeReasons && transcodeReasons.length > 0 && (
+
+ {transcodeReasons.map(formatTranscodeReason).join(", ")}
+
+ )}
+ {info?.videoWidth && info?.videoHeight && (
+
+ {info.videoWidth}x{info.videoHeight}
+ {streamInfo?.bitDepth ? ` ${streamInfo.bitDepth}bit` : ""}
+ {formatVideoRange(streamInfo?.videoRange)
+ ? ` ${formatVideoRange(streamInfo?.videoRange)}`
+ : ""}
+
+ )}
+ {info?.videoCodec && (
+
+ Video: {formatCodec(info.videoCodec)}
+ {info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
+
+ )}
+ {info?.audioCodec && (
+
+ Audio: {formatCodec(info.audioCodec)}
+ {streamInfo?.audioChannels
+ ? ` ${formatAudioChannels(streamInfo.audioChannels)}`
+ : ""}
+
+ )}
+ {streamInfo?.subtitleCodec && (
+
+ Subtitle: {formatCodec(streamInfo.subtitleCodec)}
+
+ )}
+ {(info?.videoBitrate || info?.audioBitrate) && (
+
+ Bitrate:{" "}
+ {info.videoBitrate
+ ? formatBitrate(info.videoBitrate)
+ : info.audioBitrate
+ ? formatBitrate(info.audioBitrate)
+ : "N/A"}
+
+ )}
+ {info?.cacheSeconds !== undefined && (
+
+ Buffer: {info.cacheSeconds.toFixed(1)}s
+ {info?.demuxerMaxBytes !== undefined
+ ? ` (cap ${info.demuxerMaxBytes}MB` +
+ `${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
+ `${info?.cacheSecsLimit !== undefined && info.cacheSecsLimit < 3600 ? ` · ${info.cacheSecsLimit.toFixed(0)}s` : ""}` +
+ ")"
+ : ""}
+
+ )}
+ {info?.voDriver && (
+
+ VO: {info.voDriver}
+ {info.hwdec ? ` / ${info.hwdec}` : ""}
+
+ )}
+ {info?.estimatedVfFps !== undefined && (
+
+ Output FPS: {info.estimatedVfFps.toFixed(2)}
+ {info?.fps ? ` (container ${formatFps(info.fps)})` : ""}
+
+ )}
+ {info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
+
+ Dropped: {info.droppedFrames} frames
+
+ )}
+ {!info && !playMethod && Loading...}
+
+
+ );
+ },
+);
+
+TechnicalInfoOverlay.displayName = "TechnicalInfoOverlay";
+
+const styles = StyleSheet.create({
+ container: {
+ position: "absolute",
+ zIndex: 15,
+ },
+ infoBox: {
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ borderRadius: 8,
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ minWidth: 150,
+ },
+ infoBoxTV: {
+ backgroundColor: "rgba(0, 0, 0, 0.6)",
+ borderRadius: 12,
+ paddingHorizontal: 20,
+ paddingVertical: 16,
+ minWidth: 250,
+ },
+ infoText: {
+ color: "white",
+ fontSize: 12,
+ fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
+ lineHeight: 18,
+ },
+ infoTextTV: {
+ color: "white",
+ fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
+ },
+ warningText: {
+ color: "#ff9800",
+ },
+ reasonText: {
+ color: "#fbbf24",
+ fontSize: 10,
+ },
+ reasonTextTV: {
+ color: "#fbbf24",
+ },
+});
diff --git a/components/video-player/controls/TimeDisplay.tsx b/components/video-player/controls/TimeDisplay.tsx
index 37ba755b6..f85af6343 100644
--- a/components/video-player/controls/TimeDisplay.tsx
+++ b/components/video-player/controls/TimeDisplay.tsx
@@ -1,4 +1,5 @@
import type { FC } from "react";
+import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { formatTimeString } from "@/utils/time";
@@ -6,18 +7,22 @@ import { formatTimeString } from "@/utils/time";
interface TimeDisplayProps {
currentTime: number;
remainingTime: number;
- isVlc: boolean;
}
+/**
+ * Displays current time and remaining time.
+ * MPV player uses milliseconds for time values.
+ */
export const TimeDisplay: FC = ({
currentTime,
remainingTime,
- isVlc,
}) => {
+ const { t } = useTranslation();
+
const getFinishTime = () => {
const now = new Date();
- const remainingMs = isVlc ? remainingTime : remainingTime * 1000;
- const finishTime = new Date(now.getTime() + remainingMs);
+ // remainingTime is in ms
+ const finishTime = new Date(now.getTime() + remainingTime);
return finishTime.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
@@ -28,14 +33,14 @@ export const TimeDisplay: FC = ({
return (
- {formatTimeString(currentTime, isVlc ? "ms" : "s")}
+ {formatTimeString(currentTime, "ms")}
- -{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
+ -{formatTimeString(remainingTime, "ms")}
- ends at {getFinishTime()}
+ {t("player.ends_at", { time: getFinishTime() })}
diff --git a/components/video-player/controls/TrickplayBubble.tsx b/components/video-player/controls/TrickplayBubble.tsx
index 49645ed2e..e00a19e6a 100644
--- a/components/video-player/controls/TrickplayBubble.tsx
+++ b/components/video-player/controls/TrickplayBubble.tsx
@@ -4,6 +4,12 @@ import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { CONTROLS_CONSTANTS } from "./constants";
+// Slightly larger preview (scale 1.6 vs old 1.4) to give the overlay text
+// more room and feel closer to the Jellyfin web style.
+const BASE_IMAGE_SCALE = 1.6;
+const BUBBLE_LEFT_OFFSET = 62;
+const BUBBLE_WIDTH_MULTIPLIER = 1.5;
+
interface TrickplayBubbleProps {
trickPlayUrl: {
x: number;
@@ -22,12 +28,18 @@ interface TrickplayBubbleProps {
minutes: number;
seconds: number;
};
+ /** Scale factor for the image (default 1). Does not affect timestamp text. */
+ imageScale?: number;
+ /** Chapter name at the scrubbed position, if any. */
+ chapterName?: string | null;
}
export const TrickplayBubble: FC = ({
trickPlayUrl,
trickplayInfo,
time,
+ imageScale = 1,
+ chapterName,
}) => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
@@ -36,18 +48,28 @@ export const TrickplayBubble: FC = ({
const { x, y, url } = trickPlayUrl;
const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH;
const tileHeight = tileWidth / trickplayInfo.aspectRatio!;
+ const timeStr = `${time.hours > 0 ? `${time.hours}:` : ""}${
+ time.minutes < 10 ? `0${time.minutes}` : time.minutes
+ }:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`;
+
+ const finalScale = BASE_IMAGE_SCALE * imageScale;
return (
= ({
width: tileWidth,
height: tileHeight,
alignSelf: "center",
- transform: [{ scale: 1.4 }],
+ transform: [{ scale: finalScale }],
borderRadius: 5,
}}
className='bg-neutral-800 overflow-hidden'
>
= ({
source={{ uri: url }}
contentFit='cover'
/>
+ {/*
+ * Bottom-right overlay (Jellyfin web style) — chapter name (small,
+ * faded) above the timestamp (small, bold). Sits on top of the
+ * trickplay frame inside the same overflow:hidden container so it
+ * always stays within the bubble bounds.
+ */}
+
+ {chapterName ? (
+
+ {chapterName}
+
+ ) : null}
+
+ {timeStr}
+
+
-
- {`${time.hours > 0 ? `${time.hours}:` : ""}${
- time.minutes < 10 ? `0${time.minutes}` : time.minutes
- }:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
-
);
};
diff --git a/components/video-player/controls/ZoomToggle.tsx b/components/video-player/controls/ZoomToggle.tsx
new file mode 100644
index 000000000..ede704018
--- /dev/null
+++ b/components/video-player/controls/ZoomToggle.tsx
@@ -0,0 +1,44 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Platform, TouchableOpacity, View } from "react-native";
+import { useHaptic } from "@/hooks/useHaptic";
+import { ICON_SIZES } from "./constants";
+
+interface ZoomToggleProps {
+ isZoomedToFill: boolean;
+ onToggle: () => void;
+ disabled?: boolean;
+}
+
+export const ZoomToggle: React.FC = ({
+ isZoomedToFill,
+ onToggle,
+ disabled = false,
+}) => {
+ const lightHapticFeedback = useHaptic("light");
+
+ const handlePress = () => {
+ if (disabled) return;
+ lightHapticFeedback();
+ onToggle();
+ };
+
+ // Hide on TV platforms
+ if (Platform.isTV) return null;
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/components/video-player/controls/constants.ts b/components/video-player/controls/constants.ts
index afe57070e..cec24162a 100644
--- a/components/video-player/controls/constants.ts
+++ b/components/video-player/controls/constants.ts
@@ -1,12 +1,13 @@
export const CONTROLS_CONSTANTS = {
TIMEOUT: 4000,
- SCRUB_INTERVAL_MS: 10 * 1000, // 10 seconds in ms
+ SCRUB_INTERVAL_MS: 30 * 1000, // 30 seconds in ms
SCRUB_INTERVAL_TICKS: 10 * 10000000, // 10 seconds in ticks
TILE_WIDTH: 150,
PROGRESS_UNIT_MS: 1000, // 1 second in ms
PROGRESS_UNIT_TICKS: 10000000, // 1 second in ticks
- LONG_PRESS_INITIAL_SEEK: 10,
- LONG_PRESS_ACCELERATION: 1.1,
+ LONG_PRESS_INITIAL_SEEK: 30,
+ LONG_PRESS_ACCELERATION: 1.2,
+ LONG_PRESS_MAX_ACCELERATION: 4,
LONG_PRESS_INTERVAL: 300,
SLIDER_DEBOUNCE_MS: 3,
} as const;
@@ -15,3 +16,7 @@ export const ICON_SIZES = {
HEADER: 24,
CENTER: 50,
} as const;
+
+export const HEADER_LAYOUT = {
+ CONTAINER_PADDING: 8, // p-2 = 8px (matches HeaderControls)
+} as const;
diff --git a/components/video-player/controls/contexts/ControlContext.tsx b/components/video-player/controls/contexts/ControlContext.tsx
deleted file mode 100644
index c13211c97..000000000
--- a/components/video-player/controls/contexts/ControlContext.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import type {
- BaseItemDto,
- MediaSourceInfo,
-} from "@jellyfin/sdk/lib/generated-client";
-import type React from "react";
-import { createContext, type ReactNode, useContext } from "react";
-
-interface ControlContextProps {
- item: BaseItemDto;
- mediaSource: MediaSourceInfo | null | undefined;
- isVideoLoaded: boolean | undefined;
-}
-
-const ControlContext = createContext(
- undefined,
-);
-
-interface ControlProviderProps {
- children: ReactNode;
- item: BaseItemDto;
- mediaSource: MediaSourceInfo | null | undefined;
- isVideoLoaded: boolean | undefined;
-}
-
-export const ControlProvider: React.FC = ({
- children,
- item,
- mediaSource,
- isVideoLoaded,
-}) => {
- return (
-
- {children}
-
- );
-};
-
-export const useControlContext = () => {
- const context = useContext(ControlContext);
- if (context === undefined) {
- throw new Error("useControlContext must be used within a ControlProvider");
- }
- return context;
-};
diff --git a/components/video-player/controls/contexts/PlayerContext.tsx b/components/video-player/controls/contexts/PlayerContext.tsx
new file mode 100644
index 000000000..e3c87b789
--- /dev/null
+++ b/components/video-player/controls/contexts/PlayerContext.tsx
@@ -0,0 +1,118 @@
+import type {
+ BaseItemDto,
+ MediaSourceInfo,
+} from "@jellyfin/sdk/lib/generated-client";
+import React, {
+ createContext,
+ type MutableRefObject,
+ type ReactNode,
+ useContext,
+ useMemo,
+} from "react";
+import type { MpvPlayerViewRef } from "@/modules";
+import type { DownloadedItem } from "@/providers/Downloads/types";
+
+interface PlayerContextProps {
+ playerRef: MutableRefObject;
+ item: BaseItemDto;
+ mediaSource: MediaSourceInfo | null | undefined;
+ isVideoLoaded: boolean;
+ tracksReady: boolean;
+ downloadedItem: DownloadedItem | null;
+}
+
+const PlayerContext = createContext(undefined);
+
+interface PlayerProviderProps {
+ children: ReactNode;
+ playerRef: MutableRefObject;
+ item: BaseItemDto;
+ mediaSource: MediaSourceInfo | null | undefined;
+ isVideoLoaded: boolean;
+ tracksReady: boolean;
+ downloadedItem?: DownloadedItem | null;
+}
+
+export const PlayerProvider: React.FC = ({
+ children,
+ playerRef,
+ item,
+ mediaSource,
+ isVideoLoaded,
+ tracksReady,
+ downloadedItem = null,
+}) => {
+ const value = useMemo(
+ () => ({
+ playerRef,
+ item,
+ mediaSource,
+ isVideoLoaded,
+ tracksReady,
+ downloadedItem,
+ }),
+ [playerRef, item, mediaSource, isVideoLoaded, tracksReady, downloadedItem],
+ );
+
+ return (
+ {children}
+ );
+};
+
+// Core context hook
+export const usePlayerContext = () => {
+ const context = useContext(PlayerContext);
+ if (!context)
+ throw new Error("usePlayerContext must be used within PlayerProvider");
+ return context;
+};
+
+// Player controls hook - MPV player only
+export const usePlayerControls = () => {
+ const { playerRef } = usePlayerContext();
+
+ return {
+ // Subtitle controls
+ getSubtitleTracks: async () => {
+ return playerRef.current?.getSubtitleTracks?.() ?? null;
+ },
+ setSubtitleTrack: (trackId: number) => {
+ playerRef.current?.setSubtitleTrack?.(trackId);
+ },
+ disableSubtitles: () => {
+ playerRef.current?.disableSubtitles?.();
+ },
+ addSubtitleFile: (url: string, select = true) => {
+ playerRef.current?.addSubtitleFile?.(url, select);
+ },
+
+ // Audio controls
+ getAudioTracks: async () => {
+ return playerRef.current?.getAudioTracks?.() ?? null;
+ },
+ setAudioTrack: (trackId: number) => {
+ playerRef.current?.setAudioTrack?.(trackId);
+ },
+
+ // Playback controls
+ play: () => playerRef.current?.play?.(),
+ pause: () => playerRef.current?.pause?.(),
+ seekTo: (position: number) => playerRef.current?.seekTo?.(position),
+ seekBy: (offset: number) => playerRef.current?.seekBy?.(offset),
+ setSpeed: (speed: number) => playerRef.current?.setSpeed?.(speed),
+
+ // Subtitle positioning
+ setSubtitleScale: (scale: number) =>
+ playerRef.current?.setSubtitleScale?.(scale),
+ setSubtitlePosition: (position: number) =>
+ playerRef.current?.setSubtitlePosition?.(position),
+ setSubtitleMarginY: (margin: number) =>
+ playerRef.current?.setSubtitleMarginY?.(margin),
+ setSubtitleFontSize: (size: number) =>
+ playerRef.current?.setSubtitleFontSize?.(size),
+
+ // PiP
+ startPictureInPicture: () => playerRef.current?.startPictureInPicture?.(),
+ stopPictureInPicture: () => playerRef.current?.stopPictureInPicture?.(),
+ };
+};
diff --git a/components/video-player/controls/contexts/VideoContext.tsx b/components/video-player/controls/contexts/VideoContext.tsx
index ed7fa1e04..7c5750849 100644
--- a/components/video-player/controls/contexts/VideoContext.tsx
+++ b/components/video-player/controls/contexts/VideoContext.tsx
@@ -1,5 +1,54 @@
+/**
+ * VideoContext.tsx
+ *
+ * Manages subtitle and audio track state for the video player UI.
+ *
+ * ============================================================================
+ * ARCHITECTURE
+ * ============================================================================
+ *
+ * - Jellyfin is source of truth for subtitle list (embedded + external)
+ * - MPV only knows about:
+ * - Embedded subs it finds in the video stream
+ * - External subs we explicitly add via addSubtitleFile()
+ * - UI shows Jellyfin's complete list
+ * - On selection: either select embedded track or load external URL
+ *
+ * ============================================================================
+ * INDEX TYPES
+ * ============================================================================
+ *
+ * 1. SERVER INDEX (sub.Index / track.index)
+ * - Jellyfin's server-side stream index
+ * - Used to report playback state to Jellyfin server
+ * - Value of -1 means disabled/none
+ *
+ * 2. MPV INDEX (track.mpvIndex)
+ * - MPV's internal track ID
+ * - MPV orders tracks as: [all embedded, then all external]
+ * - IDs: 1..embeddedCount for embedded, embeddedCount+1.. for external
+ * - Value of -1 means track needs replacePlayer() (e.g., burned-in sub)
+ *
+ * ============================================================================
+ * SUBTITLE HANDLING
+ * ============================================================================
+ *
+ * Embedded (DeliveryMethod.Embed):
+ * - Already in MPV's track list
+ * - Select via setSubtitleTrack(mpvId)
+ *
+ * External (DeliveryMethod.External):
+ * - Loaded into MPV on video start
+ * - Select via setSubtitleTrack(embeddedCount + externalPosition + 1)
+ *
+ * Image-based during transcoding:
+ * - Burned into video by Jellyfin, not in MPV
+ * - Requires replacePlayer() to change
+ */
+
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
-import { router, useLocalSearchParams } from "expo-router";
+import { File } from "expo-file-system";
+import { useLocalSearchParams } from "expo-router";
import type React from "react";
import {
createContext,
@@ -9,52 +58,36 @@ import {
useMemo,
useState,
} from "react";
-import type { TrackInfo } from "@/modules/VlcPlayer.types";
+import { Platform } from "react-native";
+import useRouter from "@/hooks/useAppRouter";
+import type { MpvAudioTrack } from "@/modules";
+import { useOfflineMode } from "@/providers/OfflineModeProvider";
+import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
+import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
import type { Track } from "../types";
-import { useControlContext } from "./ControlContext";
+import { usePlayerContext, usePlayerControls } from "./PlayerContext";
+
+// Starting index for local (client-downloaded) subtitles
+// Uses negative indices to avoid collision with Jellyfin indices
+const LOCAL_SUBTITLE_INDEX_START = -100;
interface VideoContextProps {
- audioTracks: Track[] | null;
subtitleTracks: Track[] | null;
- setAudioTrack: ((index: number) => void) | undefined;
- setSubtitleTrack: ((index: number) => void) | undefined;
- setSubtitleURL: ((url: string, customName: string) => void) | undefined;
+ audioTracks: Track[] | null;
}
const VideoContext = createContext(undefined);
-interface VideoProviderProps {
- children: ReactNode;
- getAudioTracks:
- | (() => Promise)
- | (() => TrackInfo[])
- | undefined;
- getSubtitleTracks:
- | (() => Promise)
- | (() => TrackInfo[])
- | undefined;
- setAudioTrack: ((index: number) => void) | undefined;
- setSubtitleTrack: ((index: number) => void) | undefined;
- setSubtitleURL: ((url: string, customName: string) => void) | undefined;
-}
-
-export const VideoProvider: React.FC = ({
+export const VideoProvider: React.FC<{ children: ReactNode }> = ({
children,
- getSubtitleTracks,
- getAudioTracks,
- setSubtitleTrack,
- setSubtitleURL,
- setAudioTrack,
}) => {
- const [audioTracks, setAudioTracks] = useState