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 index 2ee234799..deedf8d4f 100644 --- a/.claude/commands/reflect.md +++ b/.claude/commands/reflect.md @@ -12,26 +12,59 @@ Analyze the current conversation to extract useful facts that should be remember ## Instructions -1. Read the existing facts file at `.claude/learned-facts.md` +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: - - Write it concisely (1-2 sentences max) - - Include context for why it matters - - Add today's date + - 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. Append new facts to `.claude/learned-facts.md` +5. If a new category is needed, add it to the index in `CLAUDE.md` -## Fact Format +## Fact File Template -Use this format for each fact: -``` -- **[Brief Topic]**: [Concise description of the fact] _(YYYY-MM-DD)_ +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] ``` -## Example Facts +## Index Entry Format -- **State management**: Use Jotai atoms for global state, NOT React Context - atoms are in `utils/atoms/` _(2025-01-09)_ -- **Package manager**: Always use `bun`, never npm or yarn - the project is configured for bun only _(2025-01-09)_ -- **TV platform**: Check `Platform.isTV` for TV-specific code paths, not just OS checks _(2025-01-09)_ +Append to the appropriate category in the Learned Facts Index section of `CLAUDE.md`: -After updating the file, summarize what facts you added (or note if nothing new was learned this session). +``` +- `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 index 0dba9d7eb..ab5d6eecc 100644 --- a/.claude/learned-facts.md +++ b/.claude/learned-facts.md @@ -1,8 +1,11 @@ -# Learned Facts +# Learned Facts (DEPRECATED) -This file contains facts about the codebase learned from past sessions. These are things Claude got wrong or needed clarification on, stored here to prevent the same mistakes in future sessions. +> **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 is auto-imported into CLAUDE.md and loaded at the start of each session. +This file previously contained facts about the codebase learned from past sessions. ## Facts @@ -24,4 +27,22 @@ This file is auto-imported into CLAUDE.md and loaded at the start of each sessio - **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)_ \ No newline at end of file +- **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/.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..fdbe3734d 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -25,6 +25,25 @@ "osvVulnerabilityAlerts": true, "configMigration": true, "separateMinorPatch": true, + "customManagers": [ + { + "customType": "regex", + "managerFilePatterns": ["/\\.ya?ml$/"], + "matchStrings": [ + "# renovate: datasource=(?\\S+) depName=(?\\S+)(?: versioning=(?\\S+))?\\s+xcode-version:\\s*[\"']?(?[^\"'\\s]+)" + ], + "versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}loose{{/if}}" + } + ], + "customDatasources": { + "xcode": { + "defaultRegistryUrlTemplate": "https://xcodereleases.com/data.json", + "format": "json", + "transformTemplates": [ + "{ \"releases\": [$[version.release.release=true].{\"version\": version.number}] }" + ] + } + }, "lockFileMaintenance": { "vulnerabilityAlerts": { "enabled": true, diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 8528a95bd..8c7d53313 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -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) @@ -188,6 +188,17 @@ jobs: if (latestAppsRun) { console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`); + // Map job names to our build targets. Declared outside the try so + // the catch fallback can reuse the same keys. + const jobMappings = { + 'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'], + 'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'], + 'iOS': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone'], + 'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)', 'build-ios-phone-unsigned'], + 'tvOS': ['🍎 Build tvOS IPA', 'build-ios-tv'], + 'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)', 'build-ios-tv-unsigned'] + }; + try { // Get all jobs for this workflow run const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({ @@ -216,13 +227,6 @@ 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 => @@ -236,7 +240,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 +253,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 @@ -353,10 +367,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: /ios.*phone.*ipa(?!.*unsigned)/i }, + { name: 'iOS Unsigned', platform: '🍎', device: '📱 Phone Unsigned', statusKey: 'iOS Unsigned', artifactPattern: /ios.*phone.*unsigned/i }, + { name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /ios.*tv.*ipa(?!.*unsigned)/i }, + { name: 'tvOS Unsigned', platform: '🍎', device: '📺 TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i } ]; for (const target of buildTargets) { @@ -371,16 +387,31 @@ jobs: let status = '⏳ Pending'; let downloadLink = '*Waiting for build...*'; - // Special case for iOS TV - show as disabled - if (target.name === 'iOS TV') { + // tvOS builds are temporarily disabled until feat/tv-interface + // is merged - show them as disabled instead of stuck pending. + if (target.name === 'tvOS' || target.name === 'tvOS Unsigned') { status = '💤 Disabled'; - downloadLink = '*Disabled for now*'; + downloadLink = '*Disabled until feat/tv-interface is merged*'; } 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); + const durationMin = Math.floor(durationMs / 60000); + const durationSec = Math.floor((durationMs % 60000) / 1000); + durationInfo = ` - ${durationMin}m ${durationSec}s`; + } + + downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`; } else if (matchingStatus.conclusion === 'failure') { status = `❌ [Failed](${matchingStatus.url})`; downloadLink = '*Build failed*'; @@ -408,7 +439,7 @@ 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`; diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml index b300c04d4..fd68e23a1 100644 --- a/.github/workflows/build-apps.yml +++ b/.github/workflows/build-apps.yml @@ -41,12 +41,12 @@ jobs: show-progress: false - name: 🍞 Setup Bun - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest - name: 💾 Cache Bun dependencies - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.bun/install/cache key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} @@ -60,7 +60,7 @@ jobs: bun run submodule-reload - name: 💾 Cache Gradle global - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ~/.gradle/caches @@ -73,7 +73,7 @@ jobs: run: bun run prebuild - name: 💾 Cache project Gradle (.gradle) - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: android/.gradle key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} @@ -88,7 +88,7 @@ jobs: run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV - name: 📤 Upload APK artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: streamyfin-android-phone-apk-${{ env.DATE_TAG }} path: | @@ -124,12 +124,12 @@ jobs: show-progress: false - name: 🍞 Setup Bun - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest - name: 💾 Cache Bun dependencies - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.bun/install/cache key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} @@ -143,7 +143,7 @@ jobs: bun run submodule-reload - name: 💾 Cache Gradle global - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ~/.gradle/caches @@ -156,7 +156,7 @@ jobs: run: bun run prebuild:tv - name: 💾 Cache project Gradle (.gradle) - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: android/.gradle key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} @@ -171,7 +171,7 @@ jobs: run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV - name: 📤 Upload APK artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: streamyfin-android-tv-apk-${{ env.DATE_TAG }} path: | @@ -195,12 +195,12 @@ jobs: show-progress: false - name: 🍞 Setup Bun - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest - name: 💾 Cache Bun dependencies - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} @@ -216,12 +216,13 @@ jobs: run: bun run prebuild - name: 🔧 Setup Xcode - uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 with: - xcode-version: "26.2" + # renovate: datasource=custom.xcode depName=xcode versioning=loose + xcode-version: "26.4" - name: 🏗️ Setup EAS - uses: expo/expo-github-action@main + uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main with: eas-version: latest token: ${{ secrets.EXPO_TOKEN }} @@ -236,7 +237,7 @@ jobs: run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV - name: 📤 Upload IPA artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }} path: build-*.ipa @@ -259,12 +260,12 @@ jobs: show-progress: false - name: 🍞 Setup Bun - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest - name: 💾 Cache Bun dependencies - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} @@ -280,9 +281,10 @@ jobs: run: bun run prebuild - name: 🔧 Setup Xcode - uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 with: - xcode-version: "26.2" + # renovate: datasource=custom.xcode depName=xcode versioning=loose + xcode-version: "26.4" - name: 🚀 Build iOS app env: @@ -293,73 +295,136 @@ jobs: run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV - name: 📤 Upload IPA artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }} path: build/*.ipa retention-days: 7 - # Disabled for now - uncomment when ready to build iOS TV - # 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: 🚀 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 + build-ios-tv: + # Temporarily disabled until feat/tv-interface is merged (TV UI not ready). + # Re-enable by removing the `false &&` prefix 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 + + steps: + - name: 📥 Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + 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: + bun-version: latest + + - name: 💾 Cache Bun dependencies + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + 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@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + # renovate: datasource=custom.xcode depName=xcode versioning=loose + xcode-version: "26.4" + + - name: 🏗️ Setup EAS + uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + eas-cache: true + + - 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@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 + + steps: + - name: 📥 Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + 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: + bun-version: latest + + - name: 💾 Cache Bun dependencies + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + 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@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + # renovate: datasource=custom.xcode depName=xcode versioning=loose + xcode-version: "26.4" + + - 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 diff --git a/.github/workflows/check-lockfile.yml b/.github/workflows/check-lockfile.yml index ad189f7f3..ae4c0fe02 100644 --- a/.github/workflows/check-lockfile.yml +++ b/.github/workflows/check-lockfile.yml @@ -27,12 +27,12 @@ jobs: fetch-depth: 0 - name: 🍞 Setup Bun - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest - name: 💾 Cache Bun dependencies - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ~/.bun/install/cache diff --git a/.github/workflows/ci-codeql.yml b/.github/workflows/ci-codeql.yml index 29330ac7d..ba1c08dc8 100644 --- a/.github/workflows/ci-codeql.yml +++ b/.github/workflows/ci-codeql.yml @@ -27,13 +27,13 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: 🏁 Initialize CodeQL - uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 + uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: languages: ${{ matrix.language }} queries: +security-extended,security-and-quality - name: 🛠️ Autobuild - uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 + uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 - name: 🧪 Perform CodeQL Analysis - uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 + uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index c73cad706..c6effebf1 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -28,7 +28,7 @@ jobs: fetch-depth: 0 - name: 🌐 Sync Translations with Crowdin - uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.0 + uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2 with: upload_sources: true upload_translations: true diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 08e0a8847..50013ba2b 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -25,7 +25,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 + - uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 if: always() && (steps.lint_pr_title.outputs.error_message != null) with: header: pr-title-lint-error @@ -39,7 +39,7 @@ jobs: ``` - if: ${{ steps.lint_pr_title.outputs.error_message == null }} - uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 + uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 with: header: pr-title-lint-error delete: true @@ -57,7 +57,7 @@ jobs: 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' }} @@ -76,7 +76,7 @@ jobs: fetch-depth: 0 - name: 🍞 Setup Bun - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest @@ -107,12 +107,12 @@ jobs: fetch-depth: 0 - name: "🟢 Setup Node.js" - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24.x' - name: "🍞 Setup Bun" - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest diff --git a/.github/workflows/update-issue-form.yml b/.github/workflows/update-issue-form.yml index 25fa33196..7cc321977 100644 --- a/.github/workflows/update-issue-form.yml +++ b/.github/workflows/update-issue-form.yml @@ -21,14 +21,14 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: "🟢 Setup Node.js" - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24.x' cache: 'npm' - name: 🔍 Extract minor version from app.json id: minor - uses: actions/github-script@main + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main with: result-encoding: string script: | @@ -54,7 +54,7 @@ jobs: dry_run: no-push - name: 📬 Commit and create pull request - uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: add-paths: .github/ISSUE_TEMPLATE/bug_report.yml branch: ci-update-bug-report diff --git a/.gitignore b/.gitignore index 2b3bd6ed7..c39e191b9 100644 --- a/.gitignore +++ b/.gitignore @@ -50,8 +50,6 @@ npm-debug.* .idea/ .ruby-lsp .cursor/ -.claude/ -CLAUDE.md # Environment and Configuration expo-env.d.ts @@ -63,6 +61,8 @@ 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-* @@ -72,4 +72,7 @@ modules/background-downloader/android/build/* /modules/mpv-player/android/build # ios:unsigned-build Artifacts -build/ \ No newline at end of file +build/ +.claude/ +.agents/skills/** +skills-lock.json diff --git a/CLAUDE.md b/CLAUDE.md index cc3b0a53d..eb2ae87e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,9 +1,39 @@ # CLAUDE.md -@.claude/learned-facts.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. @@ -65,6 +95,7 @@ bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build error **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` @@ -128,9 +159,132 @@ import { apiAtom } from "@/providers/JellyfinProvider"; - 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/README.md b/README.md index b5b418d21..258005ef7 100644 --- a/README.md +++ b/README.md @@ -126,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? diff --git a/app.config.js b/app.config.js index 2e37927bc..96bbd8ea0 100644 --- a/app.config.js +++ b/app.config.js @@ -6,6 +6,14 @@ module.exports = ({ config }) => { "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 diff --git a/app.json b/app.json index 288f3e3af..6dee6c85a 100644 --- a/app.json +++ b/app.json @@ -2,13 +2,11 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.52.0", + "version": "0.54.0", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", "userInterfaceStyle": "dark", - "jsEngine": "hermes", - "newArchEnabled": true, "assetBundlePatterns": ["**/*"], "ios": { "requireFullScreen": true, @@ -23,7 +21,8 @@ }, "UISupportsTrueScreenSizeOnMac": true, "UIFileSharingEnabled": true, - "LSSupportsOpeningDocumentsInPlace": true + "LSSupportsOpeningDocumentsInPlace": true, + "AVInitialRouteSharingPolicy": "LongFormAudio" }, "config": { "usesNonExemptEncryption": false @@ -37,8 +36,7 @@ "appleTeamId": "MWD5K362T8" }, "android": { - "jsEngine": "hermes", - "versionCode": 92, + "versionCode": 93, "adaptiveIcon": { "foregroundImage": "./assets/images/icon-android-plain.png", "monochromeImage": "./assets/images/icon-android-themed.png", @@ -55,23 +53,41 @@ "googleServicesFile": "./google-services.json" }, "plugins": [ - "@react-native-tvos/config-tv", + [ + "@react-native-tvos/config-tv", + { + "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.js", + "./plugins/withTVUserManagement.js", [ "expo-build-properties", { "ios": { - "deploymentTarget": "15.6", - "useFrameworks": "static" + "deploymentTarget": "16.4", + "useFrameworks": "static", + "forceStaticLinking": ["ExpoUI", "GlassEffectView", "GlassPoster"] }, "android": { - "buildArchs": ["arm64-v8a", "x86_64"], + "buildArchs": ["arm64-v8a", "x86_64", "armeabi-v7a"], "compileSdkVersion": 36, "targetSdkVersion": 35, "buildToolsVersion": "35.0.0", - "kotlinVersion": "2.0.21", + "kotlinVersion": "2.1.20", "minSdkVersion": 26, "usesCleartextTraffic": true, "packagingOptions": { @@ -118,14 +134,18 @@ "expo-web-browser", ["./plugins/with-runtime-framework-headers.js"], ["./plugins/withChangeNativeAndroidTextToWhite.js"], + ["./plugins/withAndroidAlertColors.js"], ["./plugins/withAndroidManifest.js"], ["./plugins/withTrustLocalCerts.js"], ["./plugins/withGradleProperties.js"], + ["./plugins/withTVOSAppIcon.js"], + ["./plugins/withTVOSTopShelf.js"], + ["./plugins/withTVXcodeEnv.js"], [ "./plugins/withGitPod.js", { - "podName": "MPVKit-GPL", - "podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec" + "podName": "MPVKit", + "podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec" } ] ], 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() { ([]); 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 ( ( - _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 }} - > - - - ), + 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' - 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 }} - > - - - ), + headerLeft: () => , }} /> {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( @@ -336,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, 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/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx index fb8ef0b9e..884b1fbb2 100644 --- a/app/(auth)/(tabs)/(home)/downloads/index.tsx +++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx @@ -20,7 +20,7 @@ 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); 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)/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 76675ae84..db223b2bf 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -14,7 +14,11 @@ 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); @@ -55,6 +59,18 @@ export default function settings() { + + + + router.push("/(auth)/(tabs)/(home)/companion-login") + } + title={t("pairing.pair_with_phone")} + textColor='blue' + /> + + + @@ -104,8 +120,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..8fb8dcef5 --- /dev/null +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -0,0 +1,949 @@ +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", "Clear All Cache?"), + t( + "home.settings.storage.clear_all_cache_confirm_desc", + "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.", + ), + [ + { + text: t("common.cancel", "Cancel"), + style: "cancel", + }, + { + text: t("common.ok", "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", "Error"), + t( + "home.settings.storage.clear_all_cache_error_desc", + "An error occurred while clearing the cache.", + ), + ); + } + }, + }, + ], + ); + }; + + 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..24a3011e3 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); diff --git a/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx b/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx index e1c8b56b6..e7a61bde3 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); 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/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 index 05056826e..e9af145fd 100644 --- a/app/(auth)/(tabs)/(home)/settings/plugins/kefinTweaks/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/plugins/kefinTweaks/page.tsx @@ -4,7 +4,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting"; import { KefinTweaksSettings } from "@/components/settings/KefinTweaks"; import { useSettings } from "@/utils/atoms/settings"; -export default function page() { +export default function KefinTweaksPage() { const { pluginSettings } = useSettings(); const insets = useSafeAreaInsets(); @@ -18,7 +18,7 @@ export default function page() { > 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 10be4af58..3ce2c81c3 100644 --- a/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx @@ -18,7 +18,7 @@ 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(); diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx index 697db6c4e..1c4dcd199 100644 --- a/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx @@ -17,7 +17,7 @@ import { ListItem } from "@/components/list/ListItem"; import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useSettings } from "@/utils/atoms/settings"; -export default function page() { +export default function StreamystatsPage() { const { t } = useTranslation(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); 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 index 1723fe4b2..5fd125c96 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx @@ -15,14 +15,24 @@ 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 { FlatList, Platform, 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 } 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 { @@ -36,19 +46,29 @@ import { 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); @@ -56,7 +76,7 @@ const page: React.FC = () => { const [sortBy, setSortBy] = useAtom(sortByAtom); const [sortOrder, setSortOrder] = useAtom(sortOrderAtom); - const { data: collection } = useQuery({ + const { data: collection, isLoading: isCollectionLoading } = useQuery({ queryKey: ["collection", collectionId], queryFn: async () => { if (!api) return null; @@ -71,6 +91,46 @@ const page: React.FC = () => { 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]); @@ -87,6 +147,18 @@ const page: React.FC = () => { 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, @@ -98,7 +170,7 @@ const page: React.FC = () => { const response = await getItemsApi(api).getItems({ userId: user?.Id, parentId: collectionId, - limit: 18, + 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]], @@ -114,7 +186,7 @@ const page: React.FC = () => { genres: selectedGenres, tags: selectedTags, years: selectedYears.map((year) => Number.parseInt(year, 10)), - includeItemTypes: ["Movie", "Series"], + includeItemTypes: ["Movie", "Series", "Season"], }); return response.data || null; @@ -123,6 +195,7 @@ const page: React.FC = () => { api, user?.Id, collection, + collectionId, selectedGenres, selectedYears, selectedTags, @@ -131,39 +204,40 @@ const page: React.FC = () => { ], ); - 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 - ) + 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; - - 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, - }); + }, + initialPageParam: 0, + enabled: !!api && !!user?.Id && !!collection, + }); const flatData = useMemo(() => { return ( @@ -195,7 +269,6 @@ const page: React.FC = () => { }} > - {/* */} @@ -203,9 +276,34 @@ const page: React.FC = () => { [orientation], ); - const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); + const renderTVItem = useCallback( + ({ item }: { item: BaseItemDto }) => { + const handlePress = () => { + const navTarget = getItemNavigation(item, "(home)"); + router.push(navTarget as any); + }; - const _insets = useSafeAreaInsets(); + return ( + + showItemActions(item)} + width={posterSizes.poster} + /> + + ); + }, + [router, showItemActions, posterSizes.poster], + ); + + const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); const ListHeaderComponent = useCallback( () => ( @@ -372,48 +470,315 @@ const page: React.FC = () => { ], ); + // 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; - 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(); + // Mobile return + if (!Platform.isTV) { + return ( + + + {t("search.no_results")} + + } - }} - onEndReachedThreshold={0.5} - ListHeaderComponent={ListHeaderComponent} - contentContainerStyle={{ paddingBottom: 24 }} - ItemSeparatorComponent={() => ( - { + 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={() => ( + + )} + /> + ); }; 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 index d61072177..54fe92126 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx @@ -3,9 +3,8 @@ 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 { Platform, View } from "react-native"; import Animated, { - runOnJS, useAnimatedStyle, useSharedValue, withTiming, @@ -15,6 +14,10 @@ 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(); @@ -24,14 +27,20 @@ const Page: React.FC = () => { // Exclude MediaSources/MediaStreams from initial fetch for faster loading // (especially important for plugins like Gelato) - const { data: item, isError } = useItemQuery(id, isOffline, undefined, [ + const { + data: item, + isError, + isLoading, + } = useItemQuery(id, isOffline, undefined, [ ItemFields.MediaSources, ItemFields.MediaSourceCount, ItemFields.MediaStreams, ]); - // Lazily preload item with full media sources in background - const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, []); + // 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(() => { @@ -40,33 +49,14 @@ const Page: React.FC = () => { }; }); - 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); - }; - + // Fast fade out when item loads (no setTimeout delay) useEffect(() => { if (item) { - fadeOut(() => {}); + opacity.value = withTiming(0, { duration: 150 }); } else { - fadeIn(() => {}); + opacity.value = withTiming(1, { duration: 150 }); } - }, [item]); + }, [item, opacity]); if (isError) return ( @@ -78,31 +68,46 @@ const Page: React.FC = () => { return ( - - - - - - - - - - - - - - - - {item && } + {/* Always render ItemContent - it handles loading state internally on TV */} + + + {/* Skeleton overlay - fades out when content loads */} + {!item && ( + + {Platform.isTV && ItemContentSkeletonTV ? ( + + ) : ( + + + + + + + + + + + + + + + + )} + + )} ); diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/company/[companyId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/company/[companyId].tsx index fdcd786c9..34d83446c 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/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(); diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/genre/[genreId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/genre/[genreId].tsx index 7ea008085..e8ac35dd7 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/genre/[genreId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/genre/[genreId].tsx @@ -9,7 +9,7 @@ import JellyseerrPoster from "@/components/posters/JellyseerrPoster"; import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; -export default function page() { +export default function JellyseerrGenrePage() { const local = useLocalSearchParams(); const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr(); diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx index 2f0af5949..519d5e5cc 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx @@ -21,6 +21,7 @@ import { GenreTags } from "@/components/GenreTags"; import Cast from "@/components/jellyseerr/Cast"; import DetailFacts from "@/components/jellyseerr/DetailFacts"; import RequestModal from "@/components/jellyseerr/RequestModal"; +import { TVJellyseerrPage } from "@/components/jellyseerr/tv"; import { OverviewText } from "@/components/OverviewText"; import { ParallaxScrollView } from "@/components/ParallaxPage"; import { PlatformDropdown } from "@/components/PlatformDropdown"; @@ -52,7 +53,8 @@ import type { } from "@/utils/jellyseerr/server/models/Search"; import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; -const Page: React.FC = () => { +// Mobile page component +const MobilePage: React.FC = () => { const insets = useSafeAreaInsets(); const params = useLocalSearchParams(); const { t } = useTranslation(); @@ -542,4 +544,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,watchlists)/jellyseerr/person/[personId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/person/[personId].tsx index a29e12809..c94a71bd4 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/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,watchlists)/livetv/_layout.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx index 072b2f931..f5bdc3e3b 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/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,watchlists)/livetv/channels.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/channels.tsx index 6c9790b59..98b712acc 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/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,watchlists)/livetv/guide.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/guide.tsx index 390e8eb60..a69318e68 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/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,watchlists)/livetv/programs.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/programs.tsx index 812d084d9..f1471e3a0 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/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,watchlists)/livetv/recordings.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/recordings.tsx index 9a390162a..cc482c557 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/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)/persons/[personId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/persons/[personId].tsx index f2f8dcafe..a6dde1e0a 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/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 index 9c2d3f486..cc4b21b98 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx @@ -14,6 +14,7 @@ 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"; @@ -61,6 +62,7 @@ const page: React.FC = () => { }); }, staleTime: isOffline ? Infinity : 60 * 1000, + refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined, enabled: isOffline || (!!api && !!user?.Id), }); @@ -116,7 +118,8 @@ const page: React.FC = () => { (a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) || (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0), ), - staleTime: isOffline ? Infinity : 60, + staleTime: isOffline ? Infinity : 60 * 1000, + refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined, enabled: isOffline || (!!api && !!user?.Id), }); @@ -159,6 +162,19 @@ const page: React.FC = () => { // For offline mode, we can show the page even without backdropUrl if (!item || (!isOffline && !backdropUrl)) return null; + // TV version + if (Platform.isTV) { + return ( + + + + ); + } + return ( { const searchParams = useLocalSearchParams() as { @@ -58,6 +83,8 @@ const Page = () => { }; const { libraryId } = searchParams; + const typography = useScaledTVTypography(); + const posterSizes = useScaledTVPosterSizes(); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const { width: screenWidth } = useWindowDimensions(); @@ -78,7 +105,54 @@ const Page = () => { 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(); + 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(() => { // Check for URL params first (from "See All" navigation) @@ -162,6 +236,10 @@ const Page = () => { ); 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; @@ -213,6 +291,8 @@ const Page = () => { itemType = "Video"; } else if (library.CollectionType === "musicvideos") { itemType = "MusicVideo"; + } else if (library.CollectionType === "playlists") { + itemType = "Playlist"; } const response = await getItemsApi(api).getItems({ @@ -232,6 +312,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; @@ -322,7 +405,88 @@ 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 || "", []); @@ -509,6 +673,188 @@ const Page = () => { ], ); + // 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) @@ -518,43 +864,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)/index.tsx b/app/(auth)/(tabs)/(libraries)/index.tsx index 37b89bbf2..481503cf8 100644 --- a/app/(auth)/(tabs)/(libraries)/index.tsx +++ b/app/(auth)/(tabs)/(libraries)/index.tsx @@ -1,109 +1,11 @@ -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"; +import { Platform } from "react-native"; +import { Libraries } from "@/components/library/Libraries"; +import { TVLibraries } from "@/components/library/TVLibraries"; -export default function index() { - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); - const queryClient = useQueryClient(); - const { settings } = useSettings(); +export default function LibrariesPage() { + if (Platform.isTV) { + return ; + } - 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]); - - 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 index 69daf9296..66f877d4a 100644 --- a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx +++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx @@ -1,13 +1,13 @@ +import { Stack, useLocalSearchParams, 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, useLocalSearchParams, withLayoutContext } from "expo-router"; +} from "expo-router/react-navigation"; import { useTranslation } from "react-i18next"; const { Navigator } = createMaterialTopTabNavigator(); diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx index 3fb5305b2..10fffbe71 100644 --- a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx +++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx @@ -1,8 +1,8 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; -import { useRoute } from "@react-navigation/native"; import { FlashList } from "@shopify/flash-list"; import { useInfiniteQuery } from "@tanstack/react-query"; import { useLocalSearchParams } from "expo-router"; +import { useRoute } from "expo-router/react-navigation"; import { useAtom } from "jotai"; import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx index e8191404c..f268e0b24 100644 --- a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx +++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx @@ -1,8 +1,8 @@ import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api"; -import { useRoute } from "@react-navigation/native"; import { FlashList } from "@shopify/flash-list"; import { useInfiniteQuery } from "@tanstack/react-query"; import { useLocalSearchParams } from "expo-router"; +import { useRoute } from "expo-router/react-navigation"; import { useAtom } from "jotai"; import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx index a03f9c3db..85b4be5fd 100644 --- a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx +++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx @@ -1,9 +1,9 @@ import { Ionicons } from "@expo/vector-icons"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; -import { useNavigation, useRoute } from "@react-navigation/native"; import { FlashList } from "@shopify/flash-list"; import { useInfiniteQuery } from "@tanstack/react-query"; import { useLocalSearchParams } from "expo-router"; +import { useNavigation, useRoute } from "expo-router/react-navigation"; import { useAtom } from "jotai"; import { useCallback, useLayoutEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx index fb762862d..81c2272f8 100644 --- a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx +++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx @@ -1,9 +1,9 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; -import { useRoute } from "@react-navigation/native"; import { FlashList } from "@shopify/flash-list"; import { useQuery } from "@tanstack/react-query"; import { useLocalSearchParams } from "expo-router"; +import { useRoute } from "expo-router/react-navigation"; import { useAtom } from "jotai"; import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index 751b1df10..29461b49a 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -7,8 +7,9 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer"; import { useQuery } from "@tanstack/react-query"; import axios from "axios"; import { Image } from "expo-image"; -import { useLocalSearchParams, useNavigation } from "expo-router"; +import { useLocalSearchParams, useNavigation, useSegments } from "expo-router"; import { useAtom } from "jotai"; +import { orderBy, uniqBy } from "lodash"; import { useCallback, useEffect, @@ -22,9 +23,11 @@ import { useTranslation } from "react-i18next"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; 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, @@ -36,12 +39,20 @@ 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"; @@ -55,10 +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); @@ -199,9 +213,7 @@ export default function search() { return []; } - const url = `${ - settings.marlinServerUrl - }/search?q=${encodeURIComponent(query)}&includeItemTypes=${types + const url = `${settings.marlinServerUrl}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types .map((type) => encodeURIComponent(type)) .join("&includeItemTypes=")}`; @@ -209,7 +221,7 @@ export default function search() { const ids = response1.data.ids; - if (!ids || !ids.length) { + if (!ids?.length) { return []; } @@ -293,6 +305,9 @@ export default function search() { }, hideWhenScrolling: false, autoFocus: false, + // Android: placeholder and icon color + hintTextColor: "#fff", + headerIconColor: "#fff", }, }); }, [navigation]); @@ -440,6 +455,180 @@ export default function search() { 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} - /> - )} ) : 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 index 831feac76..c649bdf62 100644 --- a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx +++ b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx @@ -8,7 +8,9 @@ import { useTranslation } from "react-i18next"; import { ActivityIndicator, Alert, + Platform, RefreshControl, + ScrollView, TouchableOpacity, useWindowDimensions, View, @@ -16,11 +18,18 @@ import { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { HeaderBackButton } from "@/components/common/HeaderBackButton"; import { Text } from "@/components/common/Text"; -import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; +import { + 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, @@ -32,9 +41,15 @@ import { 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 }>(); @@ -47,6 +62,8 @@ export default function WatchlistDetailScreen() { : 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; @@ -153,6 +170,28 @@ export default function WatchlistDetailScreen() { [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 }) => ( + {/* 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 ( null + : require("@/components/music/MiniPlayerBar").MiniPlayerBar; +const MusicPlaybackEngine = Platform.isTV + ? () => null + : require("@/components/music/MusicPlaybackEngine").MusicPlaybackEngine; + const { Navigator } = createNativeBottomTabNavigator(); export const NativeTabs = withLayoutContext< @@ -30,6 +37,9 @@ export default function TabLayout() { const { settings } = useSettings(); const { t } = useTranslation(); + // Handle TV back button - prevent app exit when at root + useTVHomeBackHandler(); + return ( )} - {isMounted === true && item && !isPipMode && ( - - )} + {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..180228e36 --- /dev/null +++ b/app/(auth)/tv-option-modal.tsx @@ -0,0 +1,189 @@ +import { BlurView } from "expo-blur"; +import { useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Animated, + Easing, + 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) => { + 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 79278b702..3d75e67c9 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -2,17 +2,19 @@ import "@/augmentations"; import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import NetInfo from "@react-native-community/netinfo"; -import { DarkTheme, ThemeProvider } from "@react-navigation/native"; import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; import { onlineManager, QueryClient } from "@tanstack/react-query"; import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import * as BackgroundTask from "expo-background-task"; import * as Device from "expo-device"; +import { 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, @@ -54,15 +56,31 @@ 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 () => ({ @@ -178,7 +196,7 @@ export default function RootLayout() { return ( - + @@ -232,6 +250,11 @@ function Layout() { const _segments = useSegments(); const router = useRouter(); + // Enable TV menu key interception so React Native handles it instead of tvOS + useEffect(() => { + enableTVMenuKeyInterception(); + }, []); + useEffect(() => { i18n.changeLanguage( settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en", @@ -252,22 +275,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, @@ -342,8 +362,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 { @@ -375,86 +395,153 @@ function Layout() { maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age dehydrateOptions: { shouldDehydrateQuery: (query) => { - // Only persist successful queries - return query.state.status === "success"; + return ( + query.state.status === "success" && query.options.gcTime !== 0 + ); }, }, }} > - - - - - - - - - - - - - - - - - - - - - - + {!Platform.isTV && } + + + + + + + + + + + + ); diff --git a/app/login.tsx b/app/login.tsx index 33d06d41d..d028ae4f2 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -1,659 +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, - 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"; +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, - 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); - - /** - * 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(); - - const result = CredentialsSchema.safeParse(credentials); - if (!result.success) return; - - if (saveAccount) { - // Show save account modal to choose security type - setPendingLogin({ - username: credentials.username, - password: credentials.password, - }); - setShowSaveModal(true); - } else { - // Login without saving - await performLogin(credentials.username, credentials.password); - } - }; - - const performLogin = async ( - username: string, - password: string, - options?: { - saveAccount?: boolean; - securityType?: AccountSecurityType; - pinCode?: string; - }, - ) => { - setLoading(true); - try { - 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_occured"), - ); - } - } finally { - setLoading(false); - setPendingLogin(null); - } - }; - - const handleSaveAccountConfirm = async ( - securityType: AccountSecurityType, - pinCode?: string, - ) => { - setShowSaveModal(false); - if (pendingLogin) { - await performLogin(pendingLogin.username, pendingLogin.password, { - saveAccount: true, - securityType, - pinCode, - }); - } - }; - - const handleQuickLoginWithSavedCredential = async ( - serverUrl: string, - userId: string, - ) => { - await loginWithSavedCredential(serverUrl, userId); - }; - - const handlePasswordLogin = async ( - serverUrl: string, - username: string, - password: string, - ) => { - await loginWithPassword(serverUrl, username, password); - }; - - const handleAddAccount = (server: SavedServer) => { - // Server is already selected, go to credential entry - setServer({ address: server.address }); - if (server.name) { - setServerName(server.name); - } - }; - - /** - * 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((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} - extraClassName='mb-4' - autoFocus={false} - blurOnSubmit={true} - /> - - {/* Password */} - - 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} - 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); - }} - onQuickLogin={handleQuickLoginWithSavedCredential} - onPasswordLogin={handlePasswordLogin} - onAddAccount={handleAddAccount} - /> - - - - )} - - - ) : ( - // Mobile layout - - - {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} - /> - - - )} - - - {/* Save Account Modal */} - { - setShowSaveModal(false); - setPendingLogin(null); - }} - onSave={handleSaveAccountConfirm} - username={pendingLogin?.username || credentials.username} - /> - - ); + 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/icons/gear.png b/assets/icons/gear.png new file mode 100644 index 000000000..f5b98cf07 Binary files /dev/null and b/assets/icons/gear.png 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/augmentations/number.ts b/augmentations/number.ts index 11c0837d1..bef44ac58 100644 --- a/augmentations/number.ts +++ b/augmentations/number.ts @@ -11,7 +11,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"]; diff --git a/biome.json b/biome.json index 6f51bd9b0..6e51af7d5 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json", "files": { "includes": [ "**/*", @@ -8,6 +8,8 @@ "!android", "!Streamyfin.app", "!utils/jellyseerr", + "!expo-env.d.ts", + "!modules/**/android/build", "!.expo", "!docs/jellyfin-openapi-stable.json" ] diff --git a/bun-patches/react-native-bottom-tabs@1.2.0.patch b/bun-patches/react-native-bottom-tabs@1.2.0.patch new file mode 100644 index 000000000..9483b873c --- /dev/null +++ b/bun-patches/react-native-bottom-tabs@1.2.0.patch @@ -0,0 +1,84 @@ +diff --git a/node_modules/react-native-bottom-tabs/.bun-tag-b32ab1c60a5dfcf7 b/.bun-tag-b32ab1c60a5dfcf7 +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/ios/BottomAccessoryProvider.swift b/ios/BottomAccessoryProvider.swift +index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c19280df69 100644 +--- a/ios/BottomAccessoryProvider.swift ++++ b/ios/BottomAccessoryProvider.swift +@@ -8,7 +8,7 @@ import SwiftUI + self.delegate = delegate + } + +- #if !os(macOS) ++ #if !os(macOS) && !os(tvOS) + @available(iOS 26.0, *) + public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) { + var placementValue = "none" +diff --git a/ios/TabView/NewTabView.swift b/ios/TabView/NewTabView.swift +index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644 +--- a/ios/TabView/NewTabView.swift ++++ b/ios/TabView/NewTabView.swift +@@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier { + } + + func body(content: Content) -> some View { +- #if os(macOS) +- // tabViewBottomAccessory is not available on macOS ++ #if os(macOS) || os(tvOS) ++ // tabViewBottomAccessory is not available on macOS or tvOS + content + #else +- if #available(iOS 26.0, tvOS 26.0, visionOS 3.0, *), bottomAccessoryView != nil { ++ if #available(iOS 26.0, visionOS 3.0, *), bottomAccessoryView != nil { + content + .tabViewBottomAccessory { + renderBottomAccessoryView() +@@ -95,7 +95,7 @@ struct ConditionalBottomAccessoryModifier: ViewModifier { + + @ViewBuilder + private func renderBottomAccessoryView() -> some View { +- #if !os(macOS) ++ #if !os(macOS) && !os(tvOS) + if let bottomAccessoryView { + if #available(iOS 26.0, *) { + BottomAccessoryRepresentableView(view: bottomAccessoryView) +@@ -105,7 +105,7 @@ struct ConditionalBottomAccessoryModifier: ViewModifier { + } + } + +-#if !os(macOS) ++#if !os(macOS) && !os(tvOS) + @available(iOS 26.0, *) + struct BottomAccessoryRepresentableView: PlatformViewRepresentable { + @Environment(\.tabViewBottomAccessoryPlacement) var tabViewBottomAccessoryPlacement +@@ -135,3 +135,4 @@ struct BottomAccessoryRepresentableView: PlatformViewRepresentable { + } + } + #endif ++ +diff --git a/ios/TabViewImpl.swift b/ios/TabViewImpl.swift +index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644 +--- a/ios/TabViewImpl.swift ++++ b/ios/TabViewImpl.swift +@@ -281,7 +281,7 @@ extension View { + + @ViewBuilder + func tabBarMinimizeBehavior(_ behavior: MinimizeBehavior?) -> some View { +- #if compiler(>=6.2) ++ #if compiler(>=6.2) && !os(tvOS) + if #available(iOS 26.0, macOS 26.0, *) { + if let behavior { + self.tabBarMinimizeBehavior(behavior.convert()) +diff --git a/ios/TabViewProps.swift b/ios/TabViewProps.swift +index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644 +--- a/ios/TabViewProps.swift ++++ b/ios/TabViewProps.swift +@@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String { + case onScrollUp + case onScrollDown + +-#if compiler(>=6.2) ++#if compiler(>=6.2) && !os(tvOS) + @available(iOS 26.0, macOS 26.0, *) + func convert() -> TabBarMinimizeBehavior { + #if os(macOS) diff --git a/bun-patches/react-native-ios-utilities@5.2.0.patch b/bun-patches/react-native-ios-utilities@5.2.0.patch new file mode 100644 index 000000000..4659493ba --- /dev/null +++ b/bun-patches/react-native-ios-utilities@5.2.0.patch @@ -0,0 +1,28 @@ +diff --git a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift +index 09be306d5aa39337c5114c2ad6ba7513218e0751..24ff8ee2c36fef8632a7e012514fd04db9bf89fd 100644 +--- a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift ++++ b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift +@@ -25,15 +25,14 @@ public extension RCTView { + return rootView.recursivelyFindSubview(whereType: targetType); + }; + +- var closestParentReactContentView: RCTRootContentView? { +- let targetType = RCTRootContentView.self; +- +- if let match = self.recursivelyFindParentView(whereType: targetType) { +- return match; +- }; +- +- guard let rootView = self.rootViewForCurrentWindow else { return nil }; +- return rootView.recursivelyFindSubview(whereType: targetType); ++ // PATCH (streamyfin): RCTRootContentView is a legacy paper class that the prebuilt ++ // new-architecture React (RN 0.85) does not export, so any reference to it fails to ++ // link (Undefined symbols: _OBJC_CLASS_$_RCTRootContentView). The app runs the new ++ // architecture, where this content-view lookup is unused; short-circuit to nil. ++ // Return type widened to RCTView? so the caller's `.reactTouchHandlers` (an RCTView ++ // extension) still resolves. ++ var closestParentReactContentView: RCTView? { ++ return nil; + }; + + var reactTouchHandlers: [RCTTouchHandler]? { diff --git a/bun-patches/react-native-udp@4.1.7.patch b/bun-patches/react-native-udp@4.1.7.patch new file mode 100644 index 000000000..823acb86e --- /dev/null +++ b/bun-patches/react-native-udp@4.1.7.patch @@ -0,0 +1,17 @@ +diff --git a/node_modules/react-native-udp/.bun-tag-ea7df8754aa4db91 b/.bun-tag-ea7df8754aa4db91 +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/react-native-udp.podspec b/react-native-udp.podspec +index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644 +--- a/react-native-udp.podspec ++++ b/react-native-udp.podspec +@@ -9,7 +9,8 @@ Pod::Spec.new do |s| + s.homepage = package_json["homepage"] + s.license = package_json["license"] + s.author = { package_json["author"] => package_json["author"] } +- s.platform = :ios, "7.0" ++ s.ios.deployment_target = "7.0" ++ s.tvos.deployment_target = "15.1" + s.source = { :git => package_json["repository"]["url"], :tag => "v#{s.version}" } + s.source_files = 'ios/**/*.{h,m}' + s.dependency 'React-Core' diff --git a/bun.lock b/bun.lock index 326bd749c..ed6a2e46b 100644 --- a/bun.lock +++ b/bun.lock @@ -1,73 +1,74 @@ { "lockfileVersion": 1, - "configVersion": 0, + "configVersion": 1, "workspaces": { "": { "name": "streamyfin", "dependencies": { - "@bottom-tabs/react-navigation": "1.1.0", + "@bottom-tabs/react-navigation": "1.2.0", "@douglowder/expo-av-route-picker-view": "^0.0.5", - "@expo/metro-runtime": "~6.1.1", + "@expo/metro-runtime": "~56.0.13", "@expo/react-native-action-sheet": "^4.1.1", - "@expo/ui": "0.2.0-beta.9", + "@expo/ui": "~56.0.14", "@expo/vector-icons": "^15.0.3", "@gorhom/bottom-sheet": "5.2.8", "@jellyfin/sdk": "^0.13.0", - "@react-native-community/netinfo": "^11.4.1", - "@react-navigation/material-top-tabs": "7.4.9", - "@react-navigation/native": "^7.0.14", + "@react-native-community/netinfo": "^12.0.0", + "@react-navigation/native": "^7.2.5", "@shopify/flash-list": "2.0.2", - "@tanstack/query-sync-storage-persister": "^5.90.18", + "@tanstack/query-sync-storage-persister": "^5.100.14", "@tanstack/react-pacer": "^0.19.1", - "@tanstack/react-query": "5.90.20", - "@tanstack/react-query-persist-client": "^5.90.18", + "@tanstack/react-query": "5.100.14", + "@tanstack/react-query-persist-client": "^5.100.14", "axios": "^1.7.9", - "expo": "~54.0.31", - "expo-application": "~7.0.8", - "expo-asset": "~12.0.12", - "expo-background-task": "~1.0.10", - "expo-blur": "~15.0.8", - "expo-brightness": "~14.0.8", - "expo-build-properties": "~1.0.10", - "expo-constants": "18.0.13", - "expo-crypto": "^15.0.8", - "expo-dev-client": "~6.0.20", - "expo-device": "~8.0.10", - "expo-font": "~14.0.10", - "expo-haptics": "~15.0.8", - "expo-image": "~3.0.11", - "expo-linear-gradient": "~15.0.8", - "expo-linking": "~8.0.11", - "expo-localization": "~17.0.8", - "expo-location": "^19.0.8", - "expo-notifications": "~0.32.16", - "expo-router": "~6.0.21", - "expo-screen-orientation": "~9.0.8", - "expo-secure-store": "^15.0.8", - "expo-sharing": "~14.0.8", - "expo-splash-screen": "~31.0.13", - "expo-status-bar": "~3.0.9", - "expo-system-ui": "~6.0.9", - "expo-task-manager": "14.0.9", - "expo-web-browser": "~15.0.10", - "i18next": "^25.0.0", - "jotai": "2.16.2", - "lodash": "4.17.23", + "expo": "~56.0.6", + "expo-application": "~56.0.3", + "expo-asset": "~56.0.15", + "expo-audio": "~56.0.11", + "expo-background-task": "~56.0.15", + "expo-blur": "~56.0.3", + "expo-brightness": "~56.0.5", + "expo-build-properties": "~56.0.15", + "expo-camera": "~56.0.7", + "expo-constants": "~56.0.16", + "expo-crypto": "~56.0.4", + "expo-dev-client": "~56.0.16", + "expo-device": "~56.0.4", + "expo-font": "~56.0.5", + "expo-haptics": "~56.0.3", + "expo-image": "~56.0.9", + "expo-linear-gradient": "~56.0.4", + "expo-linking": "~56.0.12", + "expo-localization": "~56.0.6", + "expo-location": "~56.0.14", + "expo-notifications": "~56.0.14", + "expo-router": "~56.2.7", + "expo-screen-orientation": "~56.0.5", + "expo-secure-store": "~56.0.4", + "expo-sharing": "~56.0.14", + "expo-splash-screen": "~56.0.10", + "expo-status-bar": "~56.0.4", + "expo-system-ui": "~56.0.5", + "expo-task-manager": "~56.0.15", + "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.5.4", - "react-native": "0.81.5", + "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.1.0", + "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", @@ -75,12 +76,13 @@ "react-native-ios-utilities": "5.2.0", "react-native-mmkv": "4.1.1", "react-native-nitro-modules": "0.33.1", - "react-native-pager-view": "^6.9.1", - "react-native-reanimated": "~4.1.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.6.0", - "react-native-screens": "~4.18.0", - "react-native-svg": "15.12.1", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "4.25.2", + "react-native-svg": "15.15.4", "react-native-text-ticker": "^1.15.0", "react-native-track-player": "github:lovegaoshi/react-native-track-player#APM", "react-native-udp": "^4.1.7", @@ -88,245 +90,203 @@ "react-native-uuid": "^2.0.3", "react-native-volume-manager": "^2.0.8", "react-native-web": "^0.21.0", - "react-native-worklets": "0.5.1", + "react-native-worklets": "0.8.3", "sonner-native": "0.21.2", "tailwindcss": "3.3.2", "use-debounce": "^10.0.4", - "zod": "4.1.13", + "zod": "4.4.3", }, "devDependencies": { - "@babel/core": "7.28.6", - "@biomejs/biome": "2.3.11", - "@react-native-community/cli": "20.1.1", - "@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.23", - "@types/react": "19.1.17", + "@types/lodash": "4.17.24", + "@types/react": "~19.2.10", "@types/react-test-renderer": "19.1.0", "cross-env": "10.1.0", - "expo-doctor": "1.17.14", + "expo-doctor": "1.19.7", "husky": "9.1.7", - "lint-staged": "16.2.7", + "lint-staged": "17.0.5", "react-test-renderer": "19.2.3", "typescript": "5.9.3", }, }, }, - "overrides": { - "expo-constants": "18.0.13", + "patchedDependencies": { + "react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch", + "react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch", + "react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch", }, "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.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], + "@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.6", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="], + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], - "@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@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-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], + "@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.6", "", { "dependencies": { "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw=="], + "@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.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + "@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.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + "@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.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], + "@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.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], + "@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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-optional-chaining": ["@babel/plugin-transform-optional-chaining@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-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg=="], - "@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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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/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/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-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/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/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/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=="], - "@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=="], + "@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-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=="], - "@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=="], + "@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/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=="], + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], - "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], + "@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/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + "@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/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], + "@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/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/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/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="], - "@biomejs/biome": ["@biomejs/biome@2.3.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.11", "@biomejs/cli-darwin-x64": "2.3.11", "@biomejs/cli-linux-arm64": "2.3.11", "@biomejs/cli-linux-arm64-musl": "2.3.11", "@biomejs/cli-linux-x64": "2.3.11", "@biomejs/cli-linux-x64-musl": "2.3.11", "@biomejs/cli-win32-arm64": "2.3.11", "@biomejs/cli-win32-x64": "2.3.11" }, "bin": { "biome": "bin/biome" } }, "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw=="], - - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg=="], - - "@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@1.1.0", "", { "dependencies": { "color": "^5.0.0" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-+4YppCodABcSNIgJiq95QUQ+3ClVBG+rLG3WmYI0+/nbxqKbCz6luFBep4KFOj98Iplj1JY2Ki6ix8CcOZVQ/Q=="], + "@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=="], @@ -336,59 +296,77 @@ "@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="], - "@expo/cli": ["@expo/cli@54.0.21", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~12.0.13", "@expo/config-plugins": "~54.0.4", "@expo/devcert": "^1.2.1", "@expo/env": "~2.0.8", "@expo/image-utils": "^0.8.8", "@expo/json-file": "^10.0.8", "@expo/metro": "~54.2.0", "@expo/metro-config": "~54.0.13", "@expo/osascript": "^2.3.8", "@expo/package-manager": "^1.9.9", "@expo/plist": "^0.4.8", "@expo/prebuild-config": "^54.0.8", "@expo/schema-utils": "^0.1.8", "@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.5", "freeport-async": "^2.0.0", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.1.6", "minimatch": "^9.0.0", "node-forge": "^1.3.3", "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.5.2", "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-L/FdpyZDsg/Nq6xW6kfiyF9DUzKfLZCKFXEVZcDqCNar6bXxQVotQyvgexRvtUF5nLinuT/UafLOdC3FUALUmA=="], + "@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="], + + "@expo/cli": ["@expo/cli@56.1.12", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.8", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.1", "@expo/inline-modules": "^0.0.10", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.12", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.13", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.0", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.13", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.12", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^1.0.1", "@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.4", "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-Ya/13E1yDx1oAuPw5MDmqzIGyzwSs7KSr1EjgSObOF0VO0GD9jqJjvjOiwurjScLUfxcGZQgq23UzMlBVHwdvA=="], "@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@12.0.13", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "@expo/config-plugins": "~54.0.4", "@expo/config-types": "^54.0.10", "@expo/json-file": "^10.0.8", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", "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.1" } }, "sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ=="], + "@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@54.0.4", "", { "dependencies": { "@expo/config-types": "^54.0.10", "@expo/json-file": "~10.0.8", "@expo/plist": "^0.4.8", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "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-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q=="], + "@expo/config-plugins": ["@expo/config-plugins@56.0.8", "", { "dependencies": { "@expo/config-types": "^56.0.5", "@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-phTuyBhgVLfqUHMjQkAfRtbyoY6yTxoKja1awtpVnEkoJDxPJuXx1KX5uvq1eZtt4bJQ08OBJ6P95INqRSHpRg=="], - "@expo/config-types": ["@expo/config-types@54.0.10", "", {}, "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA=="], + "@expo/config-types": ["@expo/config-types@56.0.5", "", {}, "sha512-GsAHO/MwW9ZRdgnmyfRXqVGLCP/zejD6rWnp5OROp8mBGRObKm4HfrjlUyT1skjMwCj1OrURx9ZfIc6yeBAkIA=="], "@expo/devcert": ["@expo/devcert@1.2.1", "", { "dependencies": { "@expo/sudo-prompt": "^9.3.1", "debug": "^3.1.0" } }, "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA=="], - "@expo/devtools": ["@expo/devtools@0.1.8", "", { "dependencies": { "chalk": "^4.1.2" }, "peerDependencies": { "react": "*", "react-native": "*" }, "optionalPeers": ["react", "react-native"] }, "sha512-SVLxbuanDjJPgc0sy3EfXUMLb/tXzp6XIHkhtPVmTWJAp+FOr6+5SeiCfJrCzZFet0Ifyke2vX3sFcKwEvCXwQ=="], + "@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/env": ["@expo/env@2.0.8", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0" } }, "sha512-5VQD6GT8HIMRaSaB5JFtOXuvfDVU80YtZIuUT/GDhUF782usIXY13Tn3IdDz1Tm/lqA9qnRZQ1BF4t7LlvdJPA=="], + "@expo/dom-webview": ["@expo/dom-webview@56.0.5", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-UIEJxkLg6cHqofKrpWpkn9E6ApxVRtCgZhZkARPr9VV7rBVloJgeroTHs31YgU/JpbI5lLQOnfOlGo54W6C2Ew=="], - "@expo/fingerprint": ["@expo/fingerprint@0.15.4", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "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": "^9.0.0", "p-limit": "^3.1.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-eYlxcrGdR2/j2M6pEDXo9zU9KXXF1vhP+V+Tl+lyY+bU8lnzrN6c637mz6Ye3em2ANy8hhUR03Raf8VsT9Ogng=="], + "@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/image-utils": ["@expo/image-utils@0.8.8", "", { "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-HHHaG4J4nKjTtVa1GG9PCh763xlETScfEyNxxOvfTRr8IKPJckjTyqSLEtdJoFNJ1vqiABEjW7tqGhqGibZLeA=="], + "@expo/expo-modules-macros-plugin": ["@expo/expo-modules-macros-plugin@0.0.9", "", {}, "sha512-odai6D7ng/gA7At8ukFcWcauNEeDdyVqzVPbQxDkyU2NTJ4kgphA4I5iigS5C4LXFicSIzEt2nzdlLM8sjsTdA=="], - "@expo/json-file": ["@expo/json-file@10.0.8", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "json5": "^2.2.3" } }, "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ=="], + "@expo/fingerprint": ["@expo/fingerprint@0.19.3", "", { "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-w9Au2IVrtc0Ct+WRa05DVHGNHXYq6VyPZWuFP+5x055OeZ5q6k5Yg+aJ1gfShmjdOhDftRcsvmWmTdTZlWaTZw=="], - "@expo/metro": ["@expo/metro@54.2.0", "", { "dependencies": { "metro": "0.83.3", "metro-babel-transformer": "0.83.3", "metro-cache": "0.83.3", "metro-cache-key": "0.83.3", "metro-config": "0.83.3", "metro-core": "0.83.3", "metro-file-map": "0.83.3", "metro-minify-terser": "0.83.3", "metro-resolver": "0.83.3", "metro-runtime": "0.83.3", "metro-source-map": "0.83.3", "metro-symbolicate": "0.83.3", "metro-transform-plugins": "0.83.3", "metro-transform-worker": "0.83.3" } }, "sha512-h68TNZPGsk6swMmLm9nRSnE2UXm48rWwgcbtAHVMikXvbxdS41NDHHeqg1rcQ9AbznDRp6SQVC2MVpDnsRKU1w=="], + "@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/metro-config": ["@expo/metro-config@54.0.13", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@expo/config": "~12.0.13", "@expo/env": "~2.0.8", "@expo/json-file": "~10.0.8", "@expo/metro": "~54.2.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": "^13.0.0", "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-RRufMCgLR2Za1WGsh02OatIJo5qZFt31yCnIOSfoubNc3Qqe92Z41pVsbrFnmw5CIaisv1NgdBy05DHe7pEyuw=="], + "@expo/inline-modules": ["@expo/inline-modules@0.0.10", "", { "dependencies": { "@expo/config-plugins": "~56.0.8" } }, "sha512-DKEfq877UTAmL/gOT5aFhlLNDg/EVmTSca7JQMKDGR6mjFSAcrmQf4GJNsi6ztiaqj6cTnIfoSz0hAYdnr6RJQ=="], - "@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=="], + "@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/osascript": ["@expo/osascript@2.3.8", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "exec-async": "^2.2.0" } }, "sha512-/TuOZvSG7Nn0I8c+FcEaoHeBO07yu6vwDgk7rZVvAXoeAK5rkA09jRyjYsZo+0tMEFaToBeywA6pj50Mb3ny9w=="], + "@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/package-manager": ["@expo/package-manager@1.9.9", "", { "dependencies": { "@expo/json-file": "^10.0.8", "@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-Nv5THOwXzPprMJwbnXU01iXSrCp3vJqly9M4EJ2GkKko9Ifer2ucpg7x6OUsE09/lw+npaoUnHMXwkw7gcKxlg=="], + "@expo/log-box": ["@expo/log-box@56.0.12", "", { "dependencies": { "@expo/dom-webview": "^56.0.5", "anser": "^1.4.9", "stacktrace-parser": "^0.1.10" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-budE6AGmJbpOJfGSOz+JVP3+FevElT82IEIg+ukQ4gZpW/dGO7QX1unFjanKdSaYgudBwJ4FCFGMwWhW/1tXVQ=="], - "@expo/plist": ["@expo/plist@0.4.8", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.2.3", "xmlbuilder": "^15.1.1" } }, "sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ=="], + "@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/prebuild-config": ["@expo/prebuild-config@54.0.8", "", { "dependencies": { "@expo/config": "~12.0.13", "@expo/config-plugins": "~54.0.4", "@expo/config-types": "^54.0.10", "@expo/image-utils": "^0.8.8", "@expo/json-file": "^10.0.8", "@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-EA7N4dloty2t5Rde+HP0IEE+nkAQiu4A/+QGZGT9mFnZ5KKjPPkqSyYcRvP5bhQE10D+tvz6X0ngZpulbMdbsg=="], + "@expo/metro-config": ["@expo/metro-config@56.0.13", "", { "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", "msgpackr": "^2.0.1", "picomatch": "^4.0.4", "postcss": "^8.5.14", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*" }, "optionalPeers": ["expo"] }, "sha512-OPyNYiex/6Ms8zT2POdIZsLhcAZYk7O+yJvpz5uG/4QRA7aiESfCy1I+0YHewMlR4P1YQeyxIrfTurs6m9xfZA=="], + + "@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.13", "", { "dependencies": { "@expo/log-box": "^56.0.12", "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-aMaFa/RPYm2iQoyYOB5q8AxDmWvf4E2yFbZ6rmBIQWaIPDdixGVUlLQeV8DlDAfZ/j+pNYO7l5M+774WbgkTgg=="], + + "@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.0", "", { "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-SWr6093nwBjn94cvElsYZNUnhvs+XtUatUz3h0vAn0IbaWG0B6l/V5ZfOBptX/xq6rMpFG5ibIf/eckLSXw8Gg=="], + + "@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.13", "", { "dependencies": { "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.8", "@expo/config-types": "^56.0.5", "@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.13", "resolve-from": "^5.0.0", "semver": "^7.6.0" } }, "sha512-caR1karpDasbNmM+LrcHKZrSnyEYdmxm7kedq+WjiuZg+9XAW5sbEjojo2i9Dq6cfbDJPyr7I0yEprLabnvmpA=="], "@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.8", "", {}, "sha512-9I6ZqvnAvKKDiO+ZF8BpQQFYWXOJvTAL5L/227RUbWG1OVZDInFifzCBiqAZ3b67NRfeAgpgvbA7rejsqhY62A=="], + "@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.12", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "@expo/metro-runtime": "^56.0.13", "expo": "*", "expo-constants": "^56.0.16", "expo-font": "^56.0.5", "expo-router": "*", "expo-server": "^56.0.4", "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-RqKV2/Z8BH/z8l0ngSpG6//5xxJPaF5dTQvSfPQ0nrvCjikGMeIvyj3B9BeLnmZZhxb3gBtXqrj3irAoiIp2aQ=="], + + "@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-beta.9", "", { "dependencies": { "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-RaBcp0cMe5GykQogJwRZGy4o4JHDLtrr+HaurDPhwPKqVATsV0rR11ysmFe4QX8XWLP/L3od7NOkXUi5ailvaw=="], + "@expo/ui": ["@expo/ui@56.0.14", "", { "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-0Wr8nsvk2C+BmhmZDQzYr/hxxddHK+ajuJ7ahacUvxt+gQnEXwbueTm0S/hk/54YGASEgplrPGDuR5zzcY+IZg=="], - "@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/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.8", "", { "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-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA=="], @@ -398,36 +376,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/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], - - "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], - - "@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=="], @@ -462,14 +420,26 @@ "@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=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + + "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], + "@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.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], "@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=="], @@ -498,7 +468,7 @@ "@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.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-slot": ["@radix-ui/react-slot@1.2.4", "", { "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-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], "@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=="], @@ -512,69 +482,69 @@ "@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.1", "", { "dependencies": { "@react-native-community/cli-clean": "20.1.1", "@react-native-community/cli-config": "20.1.1", "@react-native-community/cli-doctor": "20.1.1", "@react-native-community/cli-server-api": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "@react-native-community/cli-types": "20.1.1", "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-aLPUx43+WSeTOaUepR2FBD5a1V0OAZ1QB2DOlRlW4fOEjtBXgv40eM/ho8g3WCvAOKfPvTvx4fZdcuovTyV81Q=="], + "@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-clean": ["@react-native-community/cli-clean@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-6nGQ08w2+EcDwTFC4JFiW/wI2pLwzMrk9thz4um7tKRNW8sADX0IyCsfM2F4rHS720C0UNKYBZE9nAsfp8Vkcw=="], + "@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-config": ["@react-native-community/cli-config@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", "joi": "^17.2.1", "picocolors": "^1.1.1" } }, "sha512-ajs2i56MANie/v0bMQ1BmRcrOb6MEvLT2rh/I1CA62NXGqF1Rxv6QwsN84LrADMXHRg8QiEMAIADkyDeQHt7Kg=="], + "@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-android": ["@react-native-community/cli-config-android@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "fast-glob": "^3.3.2", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-1iUV2rPAyoWPo8EceAFC2vZTF+pEd9YqS87c0aqpbGOFE0gs1rHEB+auVR8CdjzftR4U9sq6m2jrdst0rvpIkg=="], + "@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-apple": ["@react-native-community/cli-config-apple@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-doepJgLJVqeJb5tNoP9hyFIcoZ1OMGO7QN/YMuCCIjbThUQe/J87XdwPol3Qrjr58KRt9xeBVz+kHeW5mtSutw=="], + "@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-doctor": ["@react-native-community/cli-doctor@20.1.1", "", { "dependencies": { "@react-native-community/cli-config": "20.1.1", "@react-native-community/cli-platform-android": "20.1.1", "@react-native-community/cli-platform-apple": "20.1.1", "@react-native-community/cli-platform-ios": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "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-eFpg5wWnV7uGqvLemshpgj2trPD8cckqxBuI4nT7sxKF/YpA/e3nnnyytHxPP5EnYfWbMcqfaq8hDJoOnJinGQ=="], + "@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-platform-android": ["@react-native-community/cli-platform-android@20.1.1", "", { "dependencies": { "@react-native-community/cli-config-android": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "logkitty": "^0.7.1", "picocolors": "^1.1.1" } }, "sha512-KPheizJQI0tVvBLy9owzpo+A9qDsDAa87e7a8xNaHnwqGpExnIzFPrbdvrltiZjstU2eB/+/UgNQxYIEd4Oc+g=="], + "@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-apple": ["@react-native-community/cli-platform-apple@20.1.1", "", { "dependencies": { "@react-native-community/cli-config-apple": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-mQEjOzRFCcQTrCt73Q/+5WWTfUg6U2vLZv5rPuFiNrLbrwRqxVH3OLaXg5gilJkDTJC80z8iOSsdd8MRxONOig=="], + "@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-ios": ["@react-native-community/cli-platform-ios@20.1.1", "", { "dependencies": { "@react-native-community/cli-platform-apple": "20.1.1" } }, "sha512-6vr10/oSjKkZO/BBgfFJNQTC/0CDF4WrN8iW9ss+Kt6ZL2QrBXLYz7fobrrboOlHwqqs5EyQadlEaNii7gKRJg=="], + "@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-server-api": ["@react-native-community/cli-server-api@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "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", "strict-url-sanitise": "0.0.1", "ws": "^6.2.3" } }, "sha512-phHfiCa4WqfKfaoV2vGVR3ZrYQDQTpI1k+C+i6rXAxFGxPuy8IgFFVOSL543qjKPpHBVwLcA+/xAJCVpdyCtVQ=="], + "@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-tools": ["@react-native-community/cli-tools@20.1.1", "", { "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-j+zX/H2X+6ZGneIDj56tZ1Hbnip5nSfnq7yGlMyF/zm3U1hKp3G1jN5v0YEfnz/zEmjr7zruh4Y06KmZrF1lrA=="], + "@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-types": ["@react-native-community/cli-types@20.1.1", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-Tp+s27I/RDONrGvWVj4IzEmga2HhJhXi8ZlZTfycMMyAcv4LG/CTPira+BUZs8nzLAJNrlJ79pVVPJPqQAe+aw=="], + "@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/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="], + "@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-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-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/assets-registry": ["@react-native/assets-registry@0.81.5", "", {}, "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w=="], + "@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/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-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/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/assets-registry": ["@react-native/assets-registry@0.85.3", "", {}, "sha512-u9ZiYP23vA2IFtdFQFmetzSmk6SM0xgKIoiOsr1hXNHjHaLhOm+/Ph1ud57wX6+Dbwdzx8coJgnzSKL3W21PCg=="], - "@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/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/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/babel-preset": ["@react-native/babel-preset@0.85.3", "", { "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.85.3", "babel-plugin-syntax-hermes-parser": "0.33.3", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.14.0" } }, "sha512-fD7fxEhkJB/aF57tWoXjaAWpklfrExYZS3k6aXPP3BQ77DZY7gvf/b7dbirwjID6NVnP1JDRJyTuPBGr0K/vlw=="], - "@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.81.5", "", {}, "sha512-bnd9FSdWKx2ncklOetCgrlwqSGhMHP2zOxObJbOWXoj7GHEmih4MKarBo5/a8gX8EfA1EwRATdfNBQ81DY+h+w=="], + "@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/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/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/gradle-plugin": ["@react-native/gradle-plugin@0.81.5", "", {}, "sha512-hORRlNBj+ReNMLo9jme3yQ6JQf4GZpVEBLxmTXGGlIL78MAezDZr5/uq9dwElSbcGmLEgeiax6e174Fie6qPLg=="], + "@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.85.3", "", {}, "sha512-uAu7rM5o/Np1zgp6fi5zM1sP1aB8DcS7DdOLcj/TkSutOAjkMqqd2lWt1/+3S7qXexRHVK5XcP+o3VXo4L/V0A=="], - "@react-native/js-polyfills": ["@react-native/js-polyfills@0.81.5", "", {}, "sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w=="], + "@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/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="], + "@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/virtualized-lists": ["@react-native/virtualized-lists@0.81.5", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-UVXgV/db25OPIvwZySeToXD/9sKKhOdkcWmmf4Jh8iBZuyfML+/5CasaZ1E7Lqg6g3uqVQq75NqIwkYmORJMPw=="], + "@react-native/gradle-plugin": ["@react-native/gradle-plugin@0.85.3", "", {}, "sha512-39dY2j50Q1pntejzwt3XL7vwXtrj8jcIfHq6E+gyu3jzYxZJVvMkMutQ39vSg6zinIQOX36oQDhidXUbCXzgoA=="], - "@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/js-polyfills": ["@react-native/js-polyfills@0.85.3", "", {}, "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A=="], - "@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-babel-transformer": ["@react-native/metro-babel-transformer@0.85.3", "", { "dependencies": { "@babel/core": "^7.25.2", "@react-native/babel-preset": "0.85.3", "hermes-parser": "0.33.3", "nullthrows": "^1.1.1" } }, "sha512-omuKq+r7jM4XvCMIlNMPP7Up3SyB8o5EAdZtF7YXniKyq7UOMBqhYHFqgsdOXr0lT+3ADf7VCJG3sb82jlBrrQ=="], - "@react-navigation/elements": ["@react-navigation/elements@2.9.2", "", { "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.25", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-J1GltOAGowNLznEphV/kr4zs0U7mUBO1wVA2CqpkN8ePBsoxrAmsd+T5sEYUCXN9KgTDFvc6IfcDqrGSQngd/g=="], + "@react-native/metro-config": ["@react-native/metro-config@0.85.3", "", { "dependencies": { "@react-native/js-polyfills": "0.85.3", "@react-native/metro-babel-transformer": "0.85.3", "metro-config": "^0.84.3", "metro-runtime": "^0.84.3" } }, "sha512-sVo6HepUmCcpdfozEf91lA0FjpLNNZYu/Zi9FiYiAQTK8pzATXDVTqhvdxpFrQn435p5eUTSbllvbH/KN+bnyA=="], - "@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.9", "", { "dependencies": { "@react-navigation/elements": "^2.9.2", "color": "^4.2.3", "react-native-tab-view": "^4.2.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.25", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-oYpdTfa2D1Tn0HJER9dRCR260agKGgYe+ydSHt3RIsJ9sLg8hU7ntKYWo1FnEC/Nsv1/N1u/tRst7ZpQRjjl4A=="], + "@react-native/normalize-colors": ["@react-native/normalize-colors@0.85.3", "", {}, "sha512-hj0PScZEhIbcOvQV5yMKX3ha4XEIOy/SVE1Rrpp0beW0dpNLOgSC7KDxGewmDnIHK9YdQUXGY9eMEfShUMIaZw=="], - "@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/core": ["@react-navigation/core@7.17.5", "", { "dependencies": { "@react-navigation/routers": "^7.5.5", "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-6fDCwDTWC7DJn0SDb9DJGRlipaygHIc+2elpZBJI6Crl/2Pu+Z1d6W4jMJ2gZO6iHKf+Pe5sUiQ/uwepGprZtg=="], - "@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/native": ["@react-navigation/native@7.2.5", "", { "dependencies": { "@react-navigation/core": "^7.17.5", "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-01AAUQiiHQAfTabq+ZyU1/ZWq+AbB/J3v0CB0UTJSON6M6cuadWNsbChzrZUdqQvHrXvg96U5i2PQLJzK3+zpg=="], - "@react-navigation/routers": ["@react-navigation/routers@7.5.1", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-pxipMW/iEBSUrjxz2cDD7fNwkqR4xoi0E/PcfTQGCcdJwLoaxzab5kSadBLj1MTJyT0YRrOXL9umHpXtp+Dv4w=="], + "@react-navigation/routers": ["@react-navigation/routers@7.5.5", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ=="], "@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=="], @@ -584,43 +554,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/devtools-event-client": ["@tanstack/devtools-event-client@0.4.0", "", {}, "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw=="], + "@tanstack/query-core": ["@tanstack/query-core@5.100.14", "", {}, "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew=="], - "@tanstack/pacer": ["@tanstack/pacer@0.17.1", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/store": "^0.8.0" } }, "sha512-52GytGu07L73lNCWB1N02NWBp/tzK2jZ20U8sFInXyiq2KHtHxbXaN1Qw/MR1REqFIKgEy5DOBNZRjuSy5zaRg=="], + "@tanstack/query-persist-client-core": ["@tanstack/query-persist-client-core@5.100.14", "", { "dependencies": { "@tanstack/query-core": "5.100.14" } }, "sha512-mn60cqoQO/xB6aHxp/hxlSj5mcdcTO4tjj4SXSz5MKzkaMZnvcEGySz3+cGQOT8McREN56fL41L0eR//v5RwNw=="], - "@tanstack/query-core": ["@tanstack/query-core@5.90.16", "", {}, "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww=="], + "@tanstack/query-sync-storage-persister": ["@tanstack/query-sync-storage-persister@5.100.14", "", { "dependencies": { "@tanstack/query-core": "5.100.14", "@tanstack/query-persist-client-core": "5.100.14" } }, "sha512-sDsiVjLJqslUdqIANGvRyB4hYpAooYj5R1fe2EzKfrSY7XufSe+AFBvirLgX/nL2uS1JeP4XeyUuG3TM0bAN9w=="], - "@tanstack/query-persist-client-core": ["@tanstack/query-persist-client-core@5.91.15", "", { "dependencies": { "@tanstack/query-core": "5.90.16" } }, "sha512-vnPSfQVo41EKJN8v20nkhWNZPyB1dMJIy5icOvCGzcCJzsmRefYY1owtr63ICOcjOiPPTuNEfPsdjdBhkzYnmA=="], + "@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/query-sync-storage-persister": ["@tanstack/query-sync-storage-persister@5.90.18", "", { "dependencies": { "@tanstack/query-core": "5.90.16", "@tanstack/query-persist-client-core": "5.91.15" } }, "sha512-tKngFopz/TuAe7LBDg7IOhWPh9blxdQ6QG/vVL2dFzRmlPNcSo4WdCSONqSDioJkcyTwh1YCSlcikmJ1WnSb3Q=="], + "@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-pacer": ["@tanstack/react-pacer@0.19.1", "", { "dependencies": { "@tanstack/pacer": "0.17.1", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-wfGwKLo2gosKr5tsXico+jWJ8LsWsBC8MA1HVtUY/D6dhFduEVizKxRUcvP60I3dRvnoXDbN202g4feJHlivnA=="], + "@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.100.14", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.100.14" }, "peerDependencies": { "@tanstack/react-query": "^5.100.14", "react": "^18 || ^19" } }, "sha512-lQSnbJva85o7jGcJiIDrA8s3VGGx9zaBCgAljm0H1QcScU2iaDYnPuRLg/xI0k0dC45pgg9RTvpgJx5iVHRsjA=="], - "@tanstack/react-query": ["@tanstack/react-query@5.90.20", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="], + "@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/react-query-persist-client": ["@tanstack/react-query-persist-client@5.90.18", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.91.15" }, "peerDependencies": { "@tanstack/react-query": "^5.90.16", "react": "^18 || ^19" } }, "sha512-ToVRTVpjzTrd9S/p7JIvGdLs+Xtz9aDMM/7+TQGSV9notY8Jt64irfAAAkZ05syftLKS+3KPgyKAnHcVeKVbWQ=="], + "@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], - "@tanstack/react-store": ["@tanstack/react-store@0.8.0", "", { "dependencies": { "@tanstack/store": "0.8.0", "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-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="], + "@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=="], - "@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="], + "@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=="], @@ -634,51 +600,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.23", "", {}, "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA=="], + "@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.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], "@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=="], @@ -686,15 +648,13 @@ "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.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - "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=="], @@ -716,9 +676,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=="], @@ -726,45 +686,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.16.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A=="], - "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.9", "", { "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-8J6hRdgEC2eJobjoft6mKJ294cLxmi3khCUy2JJQp4htOYYkllSLUq6vudWJkTJiIuGdVR4bR6xuz2EvJLWHNg=="], - - "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.13", "", { "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.15", "react-refresh": ">=0.14.0 <1.0.0" }, "optionalPeers": ["@babel/runtime", "expo", "expo-widgets"] }, "sha512-+CxxAQrN95N+/dF4AUJXNxEh5cEv4yhxb4CM5ijdc2OeIIw+hxzYh2OM1X7QHIm6hkT66H4vJCTT636yjJ8MnQ=="], "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.1.3", "", { "dependencies": { "zxing-wasm": "3.0.3" } }, "sha512-omL3/x26oU9jlR0gUQcGdXIjQtMlrUGKF7xRFO1RwrQkRkRU7WLz0mgQEsdUtYBm2uX3JH+HQLrKlyTS/BxZRw=="], + "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.32", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg=="], "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], @@ -774,7 +722,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.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], @@ -782,11 +730,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=="], @@ -796,7 +744,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=="], @@ -810,17 +758,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.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], "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=="], @@ -828,7 +774,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=="], @@ -836,15 +782,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=="], @@ -856,17 +802,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=="], "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.1", "", { "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-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="], "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=="], @@ -874,8 +818,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=="], @@ -890,11 +832,13 @@ "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=="], @@ -902,22 +846,18 @@ "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=="], @@ -928,8 +868,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.4", "", {}, "sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA=="], + + "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=="], @@ -938,17 +884,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.363", "", {}, "sha512-VjUKPyWzGnT1fujlkEGC/BvN70Hh70KXtAqcmniXviYlJC/ivcT+BWGPyxWVbJZLfvtKR6dqg1L7T7pgAMBtWA=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -956,11 +896,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=="], @@ -968,13 +906,13 @@ "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=="], @@ -984,103 +922,109 @@ "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.31", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.21", "@expo/config": "~12.0.13", "@expo/config-plugins": "~54.0.4", "@expo/devtools": "0.1.8", "@expo/fingerprint": "0.15.4", "@expo/metro": "~54.2.0", "@expo/metro-config": "54.0.13", "@expo/vector-icons": "^15.0.3", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~54.0.9", "expo-asset": "~12.0.12", "expo-constants": "~18.0.13", "expo-file-system": "~19.0.21", "expo-font": "~14.0.10", "expo-keep-awake": "~15.0.8", "expo-modules-autolinking": "3.0.24", "expo-modules-core": "3.0.29", "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-kQ3RDqA/a59I7y+oqQGyrPbbYlgPMUdKBOgvFLpoHbD2bCM+F75i4N0mUijy7dG5F/CUCu2qHmGGUCXBbMDkCg=="], + "expo": ["expo@56.0.6", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "^56.1.12", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.8", "@expo/devtools": "~56.0.2", "@expo/dom-webview": "~56.0.5", "@expo/fingerprint": "^0.19.3", "@expo/local-build-cache-provider": "^56.0.8", "@expo/log-box": "^56.0.12", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.13", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~56.0.13", "expo-asset": "~56.0.15", "expo-constants": "~56.0.16", "expo-file-system": "~56.0.7", "expo-font": "~56.0.5", "expo-keep-awake": "~56.0.3", "expo-modules-autolinking": "~56.0.14", "expo-modules-core": "~56.0.13", "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-zcFa/+6hGtzCUlcrGiusvzr/PIoNBAnjj4PlAFrvbAOZcVOj6c9Mp7lRSn9XYJk8Ok6pssQWt6dP4llJlKmYRQ=="], - "expo-application": ["expo-application@7.0.8", "", { "peerDependencies": { "expo": "*" } }, "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q=="], + "expo-application": ["expo-application@56.0.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw=="], - "expo-asset": ["expo-asset@12.0.12", "", { "dependencies": { "@expo/image-utils": "^0.8.8", "expo-constants": "~18.0.12" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ=="], + "expo-asset": ["expo-asset@56.0.15", "", { "dependencies": { "@expo/image-utils": "^0.10.1", "expo-constants": "~56.0.16" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-BHGi2IAOPQTcOelkUdcz1WIknfCTRjkcpYHX1azjMwgYenrVC+J5qcqJGaC8eUOWLCRtkRJWGnmFQRYtLU1nUQ=="], - "expo-background-task": ["expo-background-task@1.0.10", "", { "dependencies": { "expo-task-manager": "~14.0.9" }, "peerDependencies": { "expo": "*" } }, "sha512-EbPnuf52Ps/RJiaSFwqKGT6TkvMChv7bI0wF42eADbH3J2EMm5y5Qvj0oFmF1CBOwc3mUhqj63o7Pl6OLkGPZQ=="], + "expo-audio": ["expo-audio@56.0.11", "", { "peerDependencies": { "expo": "*", "expo-asset": "*", "react": "*", "react-native": "*" } }, "sha512-naionxilr49IpEjmMqCj5gXHCSfOsgu3nZ/KXndexR05Tv6dET7dmespyZkcMrADJN07gA5hyqPUC5WqWuaFLw=="], - "expo-blur": ["expo-blur@15.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-rWyE1NBRZEu9WD+X+5l7gyPRszw7n12cW3IRNAb5i6KFzaBp8cxqT5oeaphJapqURvcqhkOZn2k5EtBSbsuU7w=="], + "expo-background-task": ["expo-background-task@56.0.15", "", { "dependencies": { "expo-task-manager": "~56.0.15" }, "peerDependencies": { "expo": "*" } }, "sha512-ZBzLkKFmM5ZpJYl1D1kpmk6MomLbVx6LQbMX4GGLg8TSidvvtden0haIw4R5Rpkgzj3LOjvFMFli5a4kQA7VCA=="], - "expo-brightness": ["expo-brightness@14.0.8", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-WOg3UxzkHFTKBW3XvROlrVRmnJmZLhGBGd1RdzTfrtt2/MdSzvVmCevqWh4bohkeLABh0Yc9YRo1vFgfT73DWw=="], + "expo-blur": ["expo-blur@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-KDDtrpWc2tYlm1WCPaOgBtv+YEGqe5ELheFPIgSNgHt28NQUDcfBcFsA9Us2StDh6osmSD6NbKxOt5bU6PcDbQ=="], - "expo-build-properties": ["expo-build-properties@1.0.10", "", { "dependencies": { "ajv": "^8.11.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-mFCZbrbrv0AP5RB151tAoRzwRJelqM7bCJzCkxpu+owOyH+p/rFC/q7H5q8B9EpVWj8etaIuszR+gKwohpmu1Q=="], + "expo-brightness": ["expo-brightness@56.0.5", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-AkCGW+Lj8I4o2+Yjs1bzjIJz44cgNXfAN+pf01uDwmA1/1JTIy8x1eISvmz6d2r/1OhdyBZxeDkACNLVMDx5zA=="], - "expo-constants": ["expo-constants@18.0.13", "", { "dependencies": { "@expo/config": "~12.0.13", "@expo/env": "~2.0.8" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ=="], + "expo-build-properties": ["expo-build-properties@56.0.15", "", { "dependencies": { "@expo/schema-utils": "^56.0.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-3OlfTnBE6BIFxchjXzb0OlgDcWw19fxhIzpIZqgcgzZUVjyn4gCrQuNcsfazVVddBypwkEzOVfwArPROIk4J7g=="], - "expo-crypto": ["expo-crypto@15.0.8", "", { "dependencies": { "base64-js": "^1.3.0" }, "peerDependencies": { "expo": "*" } }, "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw=="], + "expo-camera": ["expo-camera@56.0.7", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw=="], - "expo-dev-client": ["expo-dev-client@6.0.20", "", { "dependencies": { "expo-dev-launcher": "6.0.20", "expo-dev-menu": "7.0.18", "expo-dev-menu-interface": "2.0.0", "expo-manifests": "~1.0.10", "expo-updates-interface": "~2.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA=="], + "expo-constants": ["expo-constants@56.0.16", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA=="], - "expo-dev-launcher": ["expo-dev-launcher@6.0.20", "", { "dependencies": { "ajv": "^8.11.0", "expo-dev-menu": "7.0.18", "expo-manifests": "~1.0.10" }, "peerDependencies": { "expo": "*" } }, "sha512-a04zHEeT9sB0L5EB38fz7sNnUKJ2Ar1pXpcyl60Ki8bXPNCs9rjY7NuYrDkP/irM8+1DklMBqHpyHiLyJ/R+EA=="], + "expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="], - "expo-dev-menu": ["expo-dev-menu@7.0.18", "", { "dependencies": { "expo-dev-menu-interface": "2.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-4kTdlHrnZCAWCT6tZRQHSSjZ7vECFisL4T+nsG/GJDo/jcHNaOVGV5qPV9wzlTxyMk3YOPggRw4+g7Ownrg5eA=="], + "expo-dev-client": ["expo-dev-client@56.0.16", "", { "dependencies": { "expo-dev-launcher": "~56.0.16", "expo-dev-menu": "~56.0.15", "expo-dev-menu-interface": "~56.0.0", "expo-manifests": "~56.0.4", "expo-updates-interface": "~56.0.1" }, "peerDependencies": { "expo": "*" } }, "sha512-mxmGA6YSP4KiMB4bREpriQ4K6EaS4tcm0eh1+LtAzgFCytq+Y4WxMfIvFe3B5kXlSpA0ohMLdAN0AUzU0xHGQg=="], - "expo-dev-menu-interface": ["expo-dev-menu-interface@2.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-BvAMPt6x+vyXpThsyjjOYyjwfjREV4OOpQkZ0tNl+nGpsPfcY9mc6DRACoWnH9KpLzyIt3BOgh3cuy/h/OxQjw=="], + "expo-dev-launcher": ["expo-dev-launcher@56.0.16", "", { "dependencies": { "@expo/schema-utils": "^56.0.0", "expo-dev-menu": "~56.0.15", "expo-manifests": "~56.0.4" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-3t2PCX2lCKetKL8EgRRo2tzSlGh1zcuaWuwp3V0k4/3nuM7pztyImaR6Sm3HUyarDOofAIPX1hIIxnuAfk5cnw=="], - "expo-device": ["expo-device@8.0.10", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA=="], + "expo-dev-menu": ["expo-dev-menu@56.0.15", "", { "dependencies": { "expo-dev-menu-interface": "~56.0.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-FY6Y5sZkNXxPBGDgC51ZArOi8N7Y8wpXwanTClFO36IVMoVf7BBqhjW13KpDecvJONtEtaUeNIAt9C25PO8MOQ=="], - "expo-doctor": ["expo-doctor@1.17.14", "", { "bin": { "expo-doctor": "build/index.js" } }, "sha512-+UsXFP5ZTVobDuGS5Du8aKU6O6s2sa49QOdGHdzP8UEjQKH8gPb59uw6hxEQmo6YtVboLwQd13QEdcSolBMvLw=="], + "expo-dev-menu-interface": ["expo-dev-menu-interface@56.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-odATx0ZL/Kis10sKSBiKiGQxAB6coSi/KQtKcMhnQVNno6FkRh5/4e5BqcEvpq2rNMTiQp4ytNAQHtdwbPXvGA=="], - "expo-file-system": ["expo-file-system@19.0.21", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg=="], + "expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="], - "expo-font": ["expo-font@14.0.10", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-UqyNaaLKRpj4pKAP4HZSLnuDQqueaO5tB1c/NWu5vh1/LF9ulItyyg2kF/IpeOp0DeOLk0GY0HrIXaKUMrwB+Q=="], + "expo-doctor": ["expo-doctor@1.19.7", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-pzn7QtCifRlvGIQz8k7kszeYFaI5Yn81WTHlk/20tmd3jwnXxPjlcdyhFSkuRtO2v4a9gA/6aUWVBOosfffj9w=="], - "expo-haptics": ["expo-haptics@15.0.8", "", { "peerDependencies": { "expo": "*" } }, "sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g=="], + "expo-file-system": ["expo-file-system@56.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA=="], - "expo-image": ["expo-image@3.0.11", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-4TudfUCLgYgENv+f48omnU8tjS2S0Pd9EaON5/s1ZUBRwZ7K8acEr4NfvLPSaeXvxW24iLAiyQ7sV7BXQH3RoA=="], + "expo-font": ["expo-font@56.0.5", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-WLoDu9hlEgPRKXJRR01HFLJ6Z2tFcORX/WFPRYBndmYc5kjQrFGH/j4BRaF3aBRPyYEAUXiUJybNLXkKCwEXQw=="], - "expo-json-utils": ["expo-json-utils@0.15.0", "", {}, "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ=="], + "expo-glass-effect": ["expo-glass-effect@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-xI9rXtDwi7RW82uAlfyaXO6+k21ApWJ2tHAWYqPr/FjfmZbKsgNJ4Q0iZzGPCwboqjTGxaRZ61SZxBl8hDt5iA=="], - "expo-keep-awake": ["expo-keep-awake@15.0.8", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-YK9M1VrnoH1vLJiQzChZgzDvVimVoriibiDIFLbQMpjYBnvyfUeHJcin/Gx1a+XgupNXy92EQJLgI/9ZuXajYQ=="], + "expo-haptics": ["expo-haptics@56.0.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-ycoahZJnR9tWAVh/0mJYxbETtHRYaWjiWS8cHlP6aDGU6Q6Y8rZ5NKsuBwWw6HR2Pe30mfVFgbF2HrBR6gtYmw=="], - "expo-linear-gradient": ["expo-linear-gradient@15.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-V2d8Wjn0VzhPHO+rrSBtcl+Fo+jUUccdlmQ6OoL9/XQB7Qk3d9lYrqKDJyccwDxmQT10JdST3Tmf2K52NLc3kw=="], + "expo-image": ["expo-image@56.0.9", "", { "dependencies": { "sf-symbols-typescript": "^2.2.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-FifiRehXnMul5XeUVHWv+COHFUeCAdsYf5MiCPUBlhr4pRb0sxjA4/floi/TEDpATOIw6GqxbrC4FdZBoyrJmw=="], - "expo-linking": ["expo-linking@8.0.11", "", { "dependencies": { "expo-constants": "~18.0.12", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA=="], + "expo-json-utils": ["expo-json-utils@56.0.0", "", {}, "sha512-lUqyv9aIGDbYTQ5Nux2FnH2/Dz0w5uJ8Pr080eS0StXi2jr5OmuMNErpzUnpfnYOU55xKotd4AHv68PfV/ludg=="], - "expo-localization": ["expo-localization@17.0.8", "", { "dependencies": { "rtl-detect": "^1.0.2" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g=="], + "expo-keep-awake": ["expo-keep-awake@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA=="], - "expo-location": ["expo-location@19.0.8", "", { "peerDependencies": { "expo": "*" } }, "sha512-H/FI75VuJ1coodJbbMu82pf+Zjess8X8Xkiv9Bv58ZgPKS/2ztjC1YO1/XMcGz7+s9DrbLuMIw22dFuP4HqneA=="], + "expo-linear-gradient": ["expo-linear-gradient@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-KUp1dNSRtuMyiExhf6FJf5YUtmw2cRaPytl10HQi7isj5Yac38udmD55T2tglNYTZlvgT5+oflpyFoH15hmOcw=="], - "expo-manifests": ["expo-manifests@1.0.10", "", { "dependencies": { "@expo/config": "~12.0.11", "expo-json-utils": "~0.15.0" }, "peerDependencies": { "expo": "*" } }, "sha512-oxDUnURPcL4ZsOBY6X1DGWGuoZgVAFzp6PISWV7lPP2J0r8u1/ucuChBgpK7u1eLGFp6sDIPwXyEUCkI386XSQ=="], + "expo-linking": ["expo-linking@56.0.12", "", { "dependencies": { "expo-constants": "~56.0.16", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-EJ+YoazVqlrUXMAARo1iTExpqEGjuKJDGiE/P1K+A3m5hs+2Uf8F9ucqpq9k5dizeiaV2D8B9+uLvqMHFzGGsQ=="], - "expo-modules-autolinking": ["expo-modules-autolinking@3.0.24", "", { "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-TP+6HTwhL7orDvsz2VzauyQlXJcAWyU3ANsZ7JGL4DQu8XaZv/A41ZchbtAYLfozNA2Ya1Hzmhx65hXryBMjaQ=="], + "expo-localization": ["expo-localization@56.0.6", "", { "dependencies": { "rtl-detect": "^1.0.2" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-zzBVoUFHCVNBywcxGsspoZeIXebihOo/AnmQYE4jMv8gHCSKlLNFT+ft+0+mWcZCMs9necvUs8S8TDonAu/xBA=="], - "expo-modules-core": ["expo-modules-core@3.0.29", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-LzipcjGqk8gvkrOUf7O2mejNWugPkf3lmd9GkqL9WuNyeN2fRwU0Dn77e3ZUKI3k6sI+DNwjkq4Nu9fNN9WS7Q=="], + "expo-location": ["expo-location@56.0.14", "", { "dependencies": { "@expo/image-utils": "^0.10.1" }, "peerDependencies": { "expo": "*" } }, "sha512-k9p6mR11o5S0R4yUs3uWLJfnSk6XIB9UIgSYiNu2goGLWb2f0sazuZ0iYhuc2p2wIsdidhpL/51ZXjtZl5JCOg=="], - "expo-notifications": ["expo-notifications@0.32.16", "", { "dependencies": { "@expo/image-utils": "^0.8.8", "@ide/backoff": "^1.0.0", "abort-controller": "^3.0.0", "assert": "^2.0.0", "badgin": "^1.1.5", "expo-application": "~7.0.8", "expo-constants": "~18.0.13" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-QQD/UA6v7LgvwIJ+tS7tSvqJZkdp0nCSj9MxsDk/jU1GttYdK49/5L2LvE/4U0H7sNBz1NZAyhDZozg8xgBLXw=="], + "expo-manifests": ["expo-manifests@56.0.4", "", { "dependencies": { "expo-json-utils": "~56.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-Fokawl2UkiExIF0bqGoblRFA8lYpROVD+EpvDwSW4LgqQyPwNua1gLSgHZjdl5GsVugfRMMWE3LHaibDyX93hw=="], - "expo-router": ["expo-router@6.0.21", "", { "dependencies": { "@expo/metro-runtime": "^6.1.2", "@expo/schema-utils": "^0.1.8", "@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.5", "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.12", "expo-linking": "^8.0.11", "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.3 || ~19.1.4 || ~19.2.3" }, "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-wjTUjrnWj6gRYjaYl1kYfcRnNE4ZAQ0kz0+sQf6/mzBd/OU6pnOdD7WrdAW3pTTpm52Q8sMoeX98tNQEddg2uA=="], + "expo-modules-autolinking": ["expo-modules-autolinking@56.0.14", "", { "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-9ugtZkheNPYDkW4DZopY1rH2BCbUICaafUEPxRgbLDR5UNRF5K3cdHMIMEt8pxZPq2+eX4wCm+6pbSvdY/DPHg=="], - "expo-screen-orientation": ["expo-screen-orientation@9.0.8", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-qRoPi3E893o3vQHT4h1NKo51+7g2hjRSbDeg1fsSo/u2pOW5s6FCeoacLvD+xofOP33cH2MkE4ua54aWWO7Icw=="], + "expo-modules-core": ["expo-modules-core@56.0.13", "", { "dependencies": { "@expo/expo-modules-macros-plugin": "~0.0.9", "expo-modules-jsi": "~56.0.7", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": "^0.7.4 || ^0.8.0" }, "optionalPeers": ["react-native-worklets"] }, "sha512-3Hgpi9Q1O0XqoesQtgFY7qhfDsNA3bJtdCJotEqdE42+N8Zv/LJACbNgIyFN/XrnMDzfF5rozh0vNWaRT0/eXQ=="], - "expo-secure-store": ["expo-secure-store@15.0.8", "", { "peerDependencies": { "expo": "*" } }, "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw=="], + "expo-modules-jsi": ["expo-modules-jsi@56.0.7", "", { "peerDependencies": { "react-native": "*" } }, "sha512-iBAj4Xeh/8HT201VVxFlmf+VBfmtQV1ZUoJdLQQENm0+j9gnD2QswZLJyNo3CmNNXl46esJeLR5lpGpYZts/zA=="], - "expo-server": ["expo-server@1.0.5", "", {}, "sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA=="], + "expo-notifications": ["expo-notifications@56.0.14", "", { "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.16" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-A+BDJYyBIkC17Bfqlrbf9A80npjOyoTbaSCydP2agfhVv+Ld7DuOYOJSApBmtzBZM0LvdUVX/pdrwjEp1ixmaw=="], - "expo-sharing": ["expo-sharing@14.0.8", "", { "peerDependencies": { "expo": "*" } }, "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q=="], + "expo-router": ["expo-router@56.2.7", "", { "dependencies": { "@expo/log-box": "^56.0.12", "@expo/metro-runtime": "^56.0.13", "@expo/schema-utils": "^56.0.0", "@expo/ui": "^56.0.14", "@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.4", "expo-symbols": "^56.0.5", "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", "vaul": "^1.1.2" }, "peerDependencies": { "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^56.0.16", "expo-linking": "^56.0.12", "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-T7MSugHfj6XDrVJG8dCkP5EEAWeCkPrkkxqKCqCRokXmBKTAiRGXsmPsgHzOXhr/5MxGDJXhj5ON19uWoCevDA=="], - "expo-splash-screen": ["expo-splash-screen@31.0.13", "", { "dependencies": { "@expo/prebuild-config": "^54.0.8" }, "peerDependencies": { "expo": "*" } }, "sha512-1epJLC1cDlwwj089R2h8cxaU5uk4ONVAC+vzGiTZH4YARQhL4Stlz1MbR6yAS173GMosvkE6CAeihR7oIbCkDA=="], + "expo-screen-orientation": ["expo-screen-orientation@56.0.5", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-Puf4L/cgM8z45Z2fwZzJtlVGSk0ZM/l3gBqXm50bKTACmUk8P8fr7HVbDfs8reyoZuEKKFZJ0VlnKo5i6cSotQ=="], - "expo-status-bar": ["expo-status-bar@3.0.9", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-xyYyVg6V1/SSOZWh4Ni3U129XHCnFHBTcUo0dhWtFDrZbNp/duw5AGsQfb2sVeU0gxWHXSY1+5F0jnKYC7WuOw=="], + "expo-secure-store": ["expo-secure-store@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-hjEi/gmpdFFJ9lYbdp3k3p/WchV7Gi0Qt8jt/m/0WJadqQrskafHAlDxbZkII1cN3Yd7zp9Lvkeq3UfGhSwirQ=="], - "expo-system-ui": ["expo-system-ui@6.0.9", "", { "dependencies": { "@react-native/normalize-colors": "0.81.5", "debug": "^4.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-eQTYGzw1V4RYiYHL9xDLYID3Wsec2aZS+ypEssmF64D38aDrqbDgz1a2MSlHLQp2jHXSs3FvojhZ9FVela1Zcg=="], + "expo-server": ["expo-server@56.0.4", "", {}, "sha512-4dJ57KuAwDl7eQGD6aG9kTzBIftWAfHH1+6Zxy7NcPCBrKYis3/H5enGUz1asH8HHhONXfJ5BdJqfEWAEAgWxA=="], - "expo-task-manager": ["expo-task-manager@14.0.9", "", { "dependencies": { "unimodules-app-loader": "~6.0.8" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-GKWtXrkedr4XChHfTm5IyTcSfMtCPxzx89y4CMVqKfyfROATibrE/8UI5j7UC/pUOfFoYlQvulQEvECMreYuUA=="], + "expo-sharing": ["expo-sharing@56.0.14", "", { "dependencies": { "@expo/config-plugins": "^56.0.8", "@expo/config-types": "^56.0.5", "@expo/plist": "^0.7.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-Hu7pm3U9vn9NFGBe5EUM6ct6wBhAc7Zgl5koOYpJnMvL6n85bkIA8sLvvxB6V+p4JRoh3TD6xXpOIr23qwsV2w=="], - "expo-updates-interface": ["expo-updates-interface@2.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg=="], + "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-web-browser": ["expo-web-browser@15.0.10", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg=="], + "expo-status-bar": ["expo-status-bar@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-IGs/fDfkHXofy2ZQrGiXayhFK04HB85FZXorhcEhDZEcqASKgSqpak+HwUtAaR0MeTJwWyHNF7I6VmVbbp8EcA=="], + + "expo-symbols": ["expo-symbols@56.0.5", "", { "dependencies": { "@expo-google-fonts/material-symbols": "^0.4.1", "sf-symbols-typescript": "^2.0.0" }, "peerDependencies": { "expo": "*", "expo-font": "*", "react": "*", "react-native": "*" } }, "sha512-RIukH0Xo80C7RU8qreipL2SPy2Py+Km8JFPbCmbPQpHkM3DW9Znlmg6VfhzbtUOlO5EuNSF0lAJ3l2VJi6qYrw=="], + + "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.15", "", { "dependencies": { "unimodules-app-loader": "~56.0.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8vbKYocXJHv27++9AubVaEvVujTdt5Z10XddaxHAhWO60uw1Zom6yRjSAayRbZ5hNFA1c72KfA2vOETXZR9IGg=="], + + "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=="], @@ -1090,13 +1034,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.8.0", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.2.0", "path-expression-matcher": "^1.5.0", "strnum": "^2.3.0", "xml-naming": "^0.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg=="], - "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=="], @@ -1106,6 +1050,8 @@ "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=="], @@ -1120,42 +1066,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", "", {}, "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.5", "", { "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-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], "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=="], @@ -1164,12 +1100,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=="], @@ -1182,11 +1116,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.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], - "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=="], @@ -1194,9 +1130,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=="], @@ -1204,9 +1140,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.0", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA=="], - "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=="], @@ -1218,27 +1154,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=="], @@ -1246,22 +1174,14 @@ "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-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], @@ -1272,28 +1192,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=="], @@ -1306,13 +1214,13 @@ "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=="], - "jotai": ["jotai@2.16.2", "", { "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-DH0lBiTXvewsxtqqwjDW6Hg9JPTDnq9LcOsXSFWCAUEt+qj5ohl9iRVX9zQXPPHKLXCdH+5mGvM28fsXMl17/g=="], + "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.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "jsc-safe-url": ["jsc-safe-url@0.2.4", "", {}, "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q=="], @@ -1320,8 +1228,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=="], @@ -1334,49 +1240,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.13.2", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.3" } }, "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg=="], "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.7", "", { "dependencies": { "commander": "^14.0.2", "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-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow=="], + "lint-staged": ["lint-staged@17.0.5", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.1.2" }, "optionalDependencies": { "yaml": "^2.8.4" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-d12yC+/e8RhBjZtaxZn71FyrgU/P5e+uAPifhCLwdosQZP/zamSdKRWDC30ocVIbzDKiFG1McHc/LUgB92GIPw=="], - "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.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], @@ -1392,6 +1298,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=="], @@ -1400,7 +1308,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=="], @@ -1408,33 +1316,33 @@ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - "metro": ["metro@0.83.3", "", { "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.3", "metro-cache": "0.83.3", "metro-cache-key": "0.83.3", "metro-config": "0.83.3", "metro-core": "0.83.3", "metro-file-map": "0.83.3", "metro-resolver": "0.83.3", "metro-runtime": "0.83.3", "metro-source-map": "0.83.3", "metro-symbolicate": "0.83.3", "metro-transform-plugins": "0.83.3", "metro-transform-worker": "0.83.3", "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-+rP+/GieOzkt97hSJ0MrPOuAH/jpaS21ZDvL9DJ35QYRDlQcwzcvUlGUf79AnQxq/2NPiS/AULhhM4TKutIt8Q=="], + "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.3", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.32.0", "nullthrows": "^1.1.1" } }, "sha512-1vxlvj2yY24ES1O5RsSIvg4a4WeL7PFXgKOHvXTXiW0deLvQr28ExXj6LjwCCDZ4YZLhq6HddLpZnX4dEdSq5g=="], + "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.3", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.3" } }, "sha512-3jo65X515mQJvKqK3vWRblxDEcgY55Sk3w4xa6LlfEXgQ9g1WgMh9m4qVZVwgcHoLy0a2HENTPCCX4Pk6s8c8Q=="], + "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.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-59ZO049jKzSmvBmG/B5bZ6/dztP0ilp0o988nc6dpaDsU05Cl1c/lRf+yx8m9WW/JVgbmfO5MziBU559XjI5Zw=="], + "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.3", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.83.3", "metro-cache": "0.83.3", "metro-core": "0.83.3", "metro-runtime": "0.83.3", "yaml": "^2.6.1" } }, "sha512-mTel7ipT0yNjKILIan04bkJkuCzUUkm2SeEaTads8VfEecCh+ltXchdq6DovXJqzQAXuR2P9cxZB47Lg4klriA=="], + "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.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.83.3" } }, "sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw=="], + "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.3", "", { "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-jg5AcyE0Q9Xbbu/4NAwwZkmQn7doJCKGW0SLeSJmzNB9Z24jBe0AL2PHNMy4eu0JiKtNWHz9IiONGZWq7hjVTA=="], + "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.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-O2BmfWj6FSfzBLrNCXt/rr2VYZdX5i6444QJU0fFoc7Ljg+Q+iqebwE3K0eTvkI6TRjELsXk1cjU+fXwAR4OjQ=="], + "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.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-0js+zwI5flFxb1ktmR///bxHYg7OLpRpWZlBBruYG8OKYxeMP7SV0xQ/o/hUelrEMdK4LJzqVtHAhBm25LVfAQ=="], + "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.3", "", { "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-eRGoKJU6jmqOakBMH5kUB7VitEWiNrDzBHpYbkBXW7C5fUGeOd2CyqrosEzbMK5VMiZYyOcNFEphvxk3OXey2A=="], + "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.3", "", { "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.3", "metro-babel-transformer": "0.83.3", "metro-cache": "0.83.3", "metro-cache-key": "0.83.3", "metro-minify-terser": "0.83.3", "metro-source-map": "0.83.3", "metro-transform-plugins": "0.83.3", "nullthrows": "^1.1.1" } }, "sha512-Ztekew9t/gOIMZX1tvJOgX7KlSLL5kWykl0Iwu2cL2vKMKVALRl1hysyhUw0vjpAvLFx+Kfq9VLjnHIkW32fPA=="], + "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=="], @@ -1448,43 +1356,47 @@ "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=="], + "msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], + + "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.3", "", {}, "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg=="], + "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], + + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], "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.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], "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=="], @@ -1496,7 +1408,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=="], @@ -1504,20 +1416,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=="], @@ -1546,21 +1452,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@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="], + "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=="], @@ -1568,13 +1472,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=="], @@ -1594,8 +1496,6 @@ "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=="], @@ -1610,13 +1510,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=="], @@ -1626,29 +1526,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.5.4", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g=="], + "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.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="], + "react-is": ["react-is@19.2.6", "", {}, "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw=="], - "react-native": ["react-native@0.81.5", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@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", "@react-native/virtualized-lists": "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-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw=="], + "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.1.0", "", { "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-Uu1gvM3i1Hb4DjVvR/38J1QVQEs0RkPc7K6yon99HgvRWWOyLs7kjPDhUswtb8ije4pKW712skIXWJ0lgKzbyQ=="], + "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=="], @@ -1656,63 +1554,65 @@ "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-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-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-drawer-layout": ["react-native-drawer-layout@4.2.4", "", { "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-l1Le5HcVidobnJm8xqFZo46Rs8FDHdxbTZhkjxpNSRgU+QMoQXilOfzTHAeNjEGiKVGgIs9cW3ctXeHqgp5jJg=="], - "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-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.1.1", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-nYFjM27l7zVhIiyAqWEFRagGASecb13JMIlzAuOeakRRz9GMJ49hCQntUBE2aeuZRE4u4ehSqTOomB0mTF56Ew=="], "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": ["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-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-safe-area-context": ["react-native-safe-area-context@5.6.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg=="], + "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-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-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-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-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-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="], - "react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#003afd0", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-003afd0"], + "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"], "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-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=="], @@ -1728,6 +1628,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=="], @@ -1738,25 +1640,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.1", "", { "dependencies": { "jsesc": "~3.1.0" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw=="], "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=="], @@ -1764,29 +1658,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.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=="], @@ -1798,7 +1688,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=="], @@ -1806,11 +1696,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-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=="], @@ -1826,9 +1716,9 @@ "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.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=="], @@ -1840,15 +1730,13 @@ "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=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "stream-buffers": ["stream-buffers@2.2.0", "", {}, "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg=="], @@ -1860,19 +1748,15 @@ "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.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], @@ -1880,7 +1764,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=="], @@ -1888,17 +1772,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=="], @@ -1910,9 +1792,11 @@ "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyexec": ["tinyexec@1.2.2", "", {}, "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g=="], - "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="], "tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="], @@ -1924,25 +1808,23 @@ "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=="], - "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=="], "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=="], @@ -1952,19 +1834,17 @@ "unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.2.0", "", {}, "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ=="], - "unimodules-app-loader": ["unimodules-app-loader@6.0.8", "", {}, "sha512-fqS8QwT/MC/HAmw1NKCHdzsPA6WaLm0dNmoC5Pz6lL+cDGYeYCNdHMO9fy08aL2ZD7cVkNM0pSR/AoNRe+rslA=="], - - "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=="], @@ -1974,8 +1854,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=="], @@ -2004,28 +1882,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=="], @@ -2034,7 +1906,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=="], @@ -2042,109 +1914,43 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@babel/helper-annotate-as-pure/@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=="], + "zxing-wasm": ["zxing-wasm@3.0.3", "", { "dependencies": { "@types/emscripten": "^1.41.5", "type-fest": "^5.6.0" } }, "sha512-DdOn/G5F+qvZELWeO5ZFFwcN611TfMybxPV0LUUoutUmiH2t47MZSB7gLV9O9YLhvudBdnzQNAoFOu4Xz8eOrQ=="], - "@babel/helper-create-class-features-plugin/@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=="], + "@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/helper-define-polyfill-provider/@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/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/helper-member-expression-to-functions/@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=="], + "@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/helper-member-expression-to-functions/@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=="], - - "@babel/helper-module-imports/@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=="], - - "@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - - "@babel/helper-optimise-call-expression/@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=="], - - "@babel/helper-remap-async-to-generator/@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=="], - - "@babel/helper-replace-supers/@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=="], - - "@babel/helper-skip-transparent-expression-wrappers/@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=="], - - "@babel/helper-skip-transparent-expression-wrappers/@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=="], - - "@babel/helper-wrap-function/@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=="], - - "@babel/helper-wrap-function/@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=="], - - "@babel/helper-wrap-function/@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=="], - - "@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-generator-functions/@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=="], - - "@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-classes/@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/plugin-transform-classes/@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=="], - - "@babel/plugin-transform-computed-properties/@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=="], - - "@babel/plugin-transform-destructuring/@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=="], - - "@babel/plugin-transform-function-name/@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/plugin-transform-function-name/@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=="], - - "@babel/plugin-transform-modules-commonjs/@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/plugin-transform-object-rest-spread/@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/plugin-transform-object-rest-spread/@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=="], - - "@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-react-jsx/@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=="], - - "@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/traverse--for-generate-function-map/@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/traverse--for-generate-function-map/@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/traverse--for-generate-function-map/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@babel/traverse--for-generate-function-map/@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=="], - - "@babel/traverse--for-generate-function-map/@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=="], + "@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@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="], + "@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.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "@expo/cli/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "@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@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="], + "@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.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], - - "@expo/config/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=="], + "@expo/config/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "@expo/config-plugins/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], - "@expo/config-plugins/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="], + "@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.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], - - "@expo/config-plugins/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "@expo/config-plugins/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], @@ -2152,49 +1958,27 @@ "@expo/fingerprint/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], - "@expo/fingerprint/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="], + "@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.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "@expo/fingerprint/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "@expo/image-utils/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], - "@expo/image-utils/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "@expo/image-utils/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], - "@expo/json-file/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="], - - "@expo/metro-config/@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=="], - - "@expo/metro-config/@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=="], - - "@expo/metro-config/@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=="], + "@expo/metro-config/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@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-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], "@expo/metro-config/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], - "@expo/metro-config/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="], - - "@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.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "@expo/prebuild-config/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], - "@expo/xcpretty/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="], + "@expo/require-utils/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@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-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], - "@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/@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=="], - - "@jest/transform/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "@jest/types/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], "@jimp/png/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], @@ -2204,89 +1988,51 @@ "@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/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], - "@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.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "@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/babel-plugin-codegen/@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=="], + "@react-native-community/cli-tools/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], - "@react-native/babel-preset/@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=="], + "@react-native/babel-preset/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@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-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], - "@react-native/babel-preset/@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=="], + "@react-native/codegen/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@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-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], - "@react-native/codegen/@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=="], + "@react-native/community-cli-plugin/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], - "@react-native/codegen/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], + "@react-native/metro-babel-transformer/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@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-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], - "@react-native/community-cli-plugin/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=="], + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], - "@react-native/community-cli-plugin/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=="], + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], - "@react-native/community-cli-plugin/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=="], - - "@react-native/community-cli-plugin/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], - - "@react-navigation/bottom-tabs/@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-navigation/bottom-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], - - "@react-navigation/core/react-is": ["react-is@19.2.0", "", {}, "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA=="], - - "@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/@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-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.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], - - "@types/babel__core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@types/babel__core/@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=="], - - "@types/babel__generator/@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=="], - - "@types/babel__template/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@types/babel__template/@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=="], - - "@types/babel__traverse/@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=="], + "@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=="], - "babel-jest/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "babel-plugin-jest-hoist/@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=="], + "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-plugin-jest-hoist/@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=="], - - "babel-plugin-polyfill-corejs2/@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], - - "babel-plugin-react-compiler/@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=="], - - "babel-preset-expo/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], - - "better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], - - "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "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=="], + "chrome-launcher/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + + "chromium-edge-launcher/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + + "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=="], @@ -2298,13 +2044,11 @@ "error-ex/is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], - "expo-build-properties/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], - - "expo-manifests/@expo/config": ["@expo/config@12.0.11", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "@expo/config-plugins": "~54.0.3", "@expo/config-types": "^54.0.9", "@expo/json-file": "^10.0.7", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", "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.1" } }, "sha512-bGKNCbHirwgFlcOJHXpsAStQvM0nU3cmiobK0o07UkTfcUxl9q9lOQQh2eoMGqpm6Vs1IcwBpYye6thC3Nri/w=="], + "expo-build-properties/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "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=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -2322,8 +2066,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=="], @@ -2332,87 +2074,67 @@ "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], - "istanbul-lib-instrument/@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=="], - - "istanbul-lib-instrument/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "jest-message-util/@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=="], - "jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "jest-util/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + + "jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "jest-worker/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "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/@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=="], + "metro/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@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-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], - "metro/@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=="], - - "metro/@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=="], - - "metro/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "metro/@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=="], - - "metro/@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=="], - - "metro/@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=="], + "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/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/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - "metro-babel-transformer/@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=="], + "metro-babel-transformer/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@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-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], - "metro-babel-transformer/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="], + "metro-babel-transformer/hermes-parser": ["hermes-parser@0.35.0", "", { "dependencies": { "hermes-estree": "0.35.0" } }, "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA=="], - "metro-source-map/@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=="], + "metro-cache/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "metro-source-map/@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=="], + "metro-transform-plugins/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@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-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], - "metro-transform-plugins/@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=="], + "metro-transform-worker/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@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-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], - "metro-transform-plugins/@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=="], - - "metro-transform-plugins/@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=="], - - "metro-transform-plugins/@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=="], - - "metro-transform-worker/@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=="], - - "metro-transform-worker/@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=="], - - "metro-transform-worker/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "metro-transform-worker/@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=="], - - "nativewind/@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=="], + "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.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], - "npm-package-arg/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], - - "parse-json/@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=="], + "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.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], - "path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], + "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=="], @@ -2422,34 +2144,30 @@ "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=="], - - "react-dom/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + "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/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + "react-native/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], - "react-native/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.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "react-native-reanimated/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "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.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "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=="], @@ -2462,173 +2180,27 @@ "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/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "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-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "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/helper-create-class-features-plugin/@babel/traverse/@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/helper-create-class-features-plugin/@babel/traverse/@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/helper-create-class-features-plugin/@babel/traverse/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@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=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@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=="], - - "@babel/helper-define-polyfill-provider/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse/@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/helper-member-expression-to-functions/@babel/traverse/@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/helper-member-expression-to-functions/@babel/traverse/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse/@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=="], - - "@babel/helper-remap-async-to-generator/@babel/traverse/@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/helper-remap-async-to-generator/@babel/traverse/@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/helper-remap-async-to-generator/@babel/traverse/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@babel/helper-remap-async-to-generator/@babel/traverse/@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=="], - - "@babel/helper-remap-async-to-generator/@babel/traverse/@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=="], - - "@babel/helper-replace-supers/@babel/traverse/@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/helper-replace-supers/@babel/traverse/@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/helper-replace-supers/@babel/traverse/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@babel/helper-replace-supers/@babel/traverse/@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=="], - - "@babel/helper-replace-supers/@babel/traverse/@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=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@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/helper-skip-transparent-expression-wrappers/@babel/traverse/@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/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@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=="], - - "@babel/helper-wrap-function/@babel/template/@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/helper-wrap-function/@babel/template/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@babel/helper-wrap-function/@babel/traverse/@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/helper-wrap-function/@babel/traverse/@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/helper-wrap-function/@babel/traverse/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@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=="], - - "@babel/plugin-transform-async-generator-functions/@babel/traverse/@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/plugin-transform-async-generator-functions/@babel/traverse/@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/plugin-transform-async-generator-functions/@babel/traverse/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@babel/plugin-transform-async-generator-functions/@babel/traverse/@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=="], - - "@babel/plugin-transform-async-generator-functions/@babel/traverse/@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=="], - - "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports/@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=="], - - "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports/@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=="], - - "@babel/plugin-transform-classes/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], - - "@babel/plugin-transform-classes/@babel/traverse/@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/plugin-transform-classes/@babel/traverse/@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/plugin-transform-classes/@babel/traverse/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@babel/plugin-transform-classes/@babel/traverse/@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=="], - - "@babel/plugin-transform-classes/@babel/traverse/@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=="], - - "@babel/plugin-transform-computed-properties/@babel/template/@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/plugin-transform-computed-properties/@babel/template/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@babel/plugin-transform-computed-properties/@babel/template/@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=="], - - "@babel/plugin-transform-destructuring/@babel/traverse/@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/plugin-transform-destructuring/@babel/traverse/@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/plugin-transform-destructuring/@babel/traverse/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@babel/plugin-transform-destructuring/@babel/traverse/@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=="], - - "@babel/plugin-transform-destructuring/@babel/traverse/@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=="], - - "@babel/plugin-transform-function-name/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], - - "@babel/plugin-transform-function-name/@babel/traverse/@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/plugin-transform-function-name/@babel/traverse/@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/plugin-transform-function-name/@babel/traverse/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@babel/plugin-transform-function-name/@babel/traverse/@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=="], - - "@babel/plugin-transform-function-name/@babel/traverse/@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=="], - - "@babel/plugin-transform-modules-commonjs/@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/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@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=="], - - "@babel/plugin-transform-object-rest-spread/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], - - "@babel/plugin-transform-object-rest-spread/@babel/traverse/@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/plugin-transform-object-rest-spread/@babel/traverse/@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/plugin-transform-object-rest-spread/@babel/traverse/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@babel/plugin-transform-object-rest-spread/@babel/traverse/@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=="], - - "@babel/plugin-transform-object-rest-spread/@babel/traverse/@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=="], - - "@babel/plugin-transform-react-jsx/@babel/helper-module-imports/@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=="], - - "@babel/plugin-transform-runtime/@babel/helper-module-imports/@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=="], - - "@babel/plugin-transform-runtime/@babel/helper-module-imports/@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=="], - - "@expo/cli/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], + "zxing-wasm/type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], "@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=="], @@ -2640,34 +2212,6 @@ "@expo/cli/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "@expo/config-plugins/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], - - "@expo/config/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], - - "@expo/config/sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], - - "@expo/fingerprint/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], - - "@expo/metro-config/@babel/core/@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=="], - - "@expo/metro-config/@babel/core/@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=="], - - "@expo/metro-config/@babel/core/@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="], - - "@expo/metro-config/@babel/core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@expo/metro-config/@babel/core/@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=="], - - "@expo/metro-config/@babel/core/@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=="], - - "@expo/metro-config/@babel/core/@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=="], - - "@expo/metro-config/@babel/generator/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@expo/metro-config/@babel/generator/@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=="], - - "@expo/metro-config/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], - "@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=="], "@expo/package-manager/ora/cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="], @@ -2676,147 +2220,11 @@ "@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=="], - - "@jest/transform/@babel/core/@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=="], - - "@jest/transform/@babel/core/@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=="], - - "@jest/transform/@babel/core/@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=="], - - "@jest/transform/@babel/core/@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=="], - - "@jest/transform/@babel/core/@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="], - - "@jest/transform/@babel/core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@jest/transform/@babel/core/@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=="], - - "@jest/transform/@babel/core/@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=="], - - "@jest/transform/@babel/core/@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=="], + "@jest/types/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="], - "@react-native/babel-plugin-codegen/@babel/traverse/@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=="], - - "@react-native/babel-plugin-codegen/@babel/traverse/@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=="], - - "@react-native/babel-plugin-codegen/@babel/traverse/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@react-native/babel-plugin-codegen/@babel/traverse/@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=="], - - "@react-native/babel-plugin-codegen/@babel/traverse/@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=="], - - "@react-native/babel-preset/@babel/core/@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=="], - - "@react-native/babel-preset/@babel/core/@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=="], - - "@react-native/babel-preset/@babel/core/@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=="], - - "@react-native/babel-preset/@babel/core/@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=="], - - "@react-native/babel-preset/@babel/core/@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="], - - "@react-native/babel-preset/@babel/core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@react-native/babel-preset/@babel/core/@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=="], - - "@react-native/babel-preset/@babel/core/@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=="], - - "@react-native/babel-preset/@babel/template/@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=="], - - "@react-native/babel-preset/@babel/template/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@react-native/babel-preset/@babel/template/@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=="], - - "@react-native/codegen/@babel/core/@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=="], - - "@react-native/codegen/@babel/core/@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=="], - - "@react-native/codegen/@babel/core/@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=="], - - "@react-native/codegen/@babel/core/@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=="], - - "@react-native/codegen/@babel/core/@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="], - - "@react-native/codegen/@babel/core/@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=="], - - "@react-native/codegen/@babel/core/@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=="], - - "@react-native/codegen/@babel/core/@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=="], - - "@react-native/codegen/@babel/parser/@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=="], - - "@react-native/community-cli-plugin/metro/@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=="], - - "@react-native/community-cli-plugin/metro/@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=="], - - "@react-native/community-cli-plugin/metro/@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=="], - - "@react-native/community-cli-plugin/metro/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@react-native/community-cli-plugin/metro/@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=="], - - "@react-native/community-cli-plugin/metro/@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=="], - - "@react-native/community-cli-plugin/metro/@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=="], - - "@react-native/community-cli-plugin/metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], - - "@react-native/community-cli-plugin/metro/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="], - - "@react-native/community-cli-plugin/metro/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=="], - - "@react-native/community-cli-plugin/metro/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=="], - - "@react-native/community-cli-plugin/metro/metro-cache-key": ["metro-cache-key@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-3EMG/GkGKYoTaf5RqguGLSWRqGTwO7NQ0qXKmNBjr0y6qD9s3VBXYlwB+MszGtmOKsqE9q3FPrE5Nd9Ipv7rZw=="], - - "@react-native/community-cli-plugin/metro/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=="], - - "@react-native/community-cli-plugin/metro/metro-resolver": ["metro-resolver@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Yf5mjyuiRE/Y+KvqfsZxrbHDA15NZxyfg8pIk0qg47LfAJhpMVEX+36e6ZRBq7KVBqy6VDX5Sq55iHGM4xSm7Q=="], - - "@react-native/community-cli-plugin/metro/metro-runtime": ["metro-runtime@0.83.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A=="], - - "@react-native/community-cli-plugin/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=="], - - "@react-native/community-cli-plugin/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=="], - - "@react-native/community-cli-plugin/metro/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=="], - - "@react-native/community-cli-plugin/metro/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=="], - - "@react-native/community-cli-plugin/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=="], - - "@react-native/community-cli-plugin/metro-config/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=="], - - "@react-native/community-cli-plugin/metro-config/metro-runtime": ["metro-runtime@0.83.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A=="], - - "@react-native/community-cli-plugin/metro-core/metro-resolver": ["metro-resolver@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Yf5mjyuiRE/Y+KvqfsZxrbHDA15NZxyfg8pIk0qg47LfAJhpMVEX+36e6ZRBq7KVBqy6VDX5Sq55iHGM4xSm7Q=="], - - "@react-navigation/bottom-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - - "@react-navigation/bottom-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], - - "@react-navigation/elements/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - - "@react-navigation/elements/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], - - "@react-navigation/material-top-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - - "@react-navigation/material-top-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], - - "@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=="], @@ -2824,19 +2232,13 @@ "ansi-fragments/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], - "babel-plugin-jest-hoist/@babel/template/@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-plugin-jest-hoist/@babel/template/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "babel-preset-expo/@babel/helper-module-imports/@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=="], - - "babel-preset-expo/@babel/helper-module-imports/@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=="], - - "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "chrome-launcher/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "chromium-edge-launcher/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -2844,48 +2246,30 @@ "connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "expo-manifests/@expo/config/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="], + "expo-router/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - "expo-manifests/@expo/config/@expo/config-plugins": ["@expo/config-plugins@54.0.3", "", { "dependencies": { "@expo/config-types": "^54.0.9", "@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": "^13.0.0", "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-tBIUZIxLQfCu5jmqTO+UOeeDUGIB0BbK6xTMkPRObAXRQeTLPPfokZRCo818d2owd+Bcmq1wBaDz0VY3g+glfw=="], - - "expo-manifests/@expo/config/@expo/config-types": ["@expo/config-types@54.0.9", "", {}, "sha512-Llf4jwcrAnrxgE5WCdAOxtMf8FGwS4Sk0SSgI0NnIaSyCnmOCAm80GPFvsK778Oj19Ub4tSyzdqufPyeQPksWw=="], - - "expo-manifests/@expo/config/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], - - "expo-manifests/@expo/config/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="], - - "expo-manifests/@expo/config/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], - - "expo-manifests/@expo/config/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=="], + "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=="], - "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=="], + "jest-util/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "istanbul-lib-instrument/@babel/core/@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=="], - - "istanbul-lib-instrument/@babel/core/@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=="], - - "istanbul-lib-instrument/@babel/core/@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=="], - - "istanbul-lib-instrument/@babel/core/@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=="], - - "istanbul-lib-instrument/@babel/core/@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="], - - "istanbul-lib-instrument/@babel/core/@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=="], - - "istanbul-lib-instrument/@babel/core/@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=="], - - "istanbul-lib-instrument/@babel/core/@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=="], - - "istanbul-lib-instrument/@babel/parser/@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=="], + "jest-worker/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "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=="], @@ -2894,154 +2278,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/@babel/core/@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=="], + "metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="], - "metro-babel-transformer/@babel/core/@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=="], + "metro-cache/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "metro-babel-transformer/@babel/core/@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=="], + "metro/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "metro-babel-transformer/@babel/core/@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=="], + "metro/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="], - "metro-babel-transformer/@babel/core/@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="], + "metro/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - "metro-babel-transformer/@babel/core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "metro-babel-transformer/@babel/core/@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=="], - - "metro-babel-transformer/@babel/core/@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=="], - - "metro-babel-transformer/@babel/core/@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=="], - - "metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="], - - "metro-source-map/@babel/traverse/@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=="], - - "metro-source-map/@babel/traverse/@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=="], - - "metro-source-map/@babel/traverse/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "metro-source-map/@babel/traverse/@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=="], - - "metro-transform-plugins/@babel/core/@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=="], - - "metro-transform-plugins/@babel/core/@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=="], - - "metro-transform-plugins/@babel/core/@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=="], - - "metro-transform-plugins/@babel/core/@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="], - - "metro-transform-plugins/@babel/core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "metro-transform-plugins/@babel/core/@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=="], - - "metro-transform-plugins/@babel/generator/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "metro-transform-plugins/@babel/generator/@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=="], - - "metro-transform-plugins/@babel/template/@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=="], - - "metro-transform-plugins/@babel/template/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "metro-transform-plugins/@babel/template/@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=="], - - "metro-transform-plugins/@babel/traverse/@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=="], - - "metro-transform-plugins/@babel/traverse/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "metro-transform-plugins/@babel/traverse/@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=="], - - "metro-transform-worker/@babel/core/@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=="], - - "metro-transform-worker/@babel/core/@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=="], - - "metro-transform-worker/@babel/core/@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=="], - - "metro-transform-worker/@babel/core/@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="], - - "metro-transform-worker/@babel/core/@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=="], - - "metro-transform-worker/@babel/core/@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=="], - - "metro/@babel/core/@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=="], - - "metro/@babel/core/@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=="], - - "metro/@babel/core/@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="], - - "metro/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="], - - "nativewind/@babel/generator/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "nativewind/@babel/generator/@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=="], - - "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=="], - - "sucrase/glob/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=="], - "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-cjs/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - - "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=="], - - "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports/@babel/traverse/@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/plugin-transform-async-to-generator/@babel/helper-module-imports/@babel/traverse/@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/plugin-transform-async-to-generator/@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports/@babel/traverse/@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=="], - - "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/helper-module-imports/@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=="], - - "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@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/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@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/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@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=="], - - "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@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=="], - - "@babel/plugin-transform-react-jsx/@babel/helper-module-imports/@babel/traverse/@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/plugin-transform-react-jsx/@babel/helper-module-imports/@babel/traverse/@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/plugin-transform-react-jsx/@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@babel/plugin-transform-react-jsx/@babel/helper-module-imports/@babel/traverse/@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=="], - - "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/traverse/@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/plugin-transform-runtime/@babel/helper-module-imports/@babel/traverse/@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/plugin-transform-runtime/@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/traverse/@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=="], - "@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=="], @@ -3054,10 +2326,6 @@ "@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/metro-config/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], - - "@expo/metro-config/@babel/core/@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=="], - "@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=="], @@ -3068,109 +2336,39 @@ "@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=="], - - "@jest/transform/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], - - "@jest/transform/@babel/core/@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=="], - - "@react-native/babel-preset/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], - - "@react-native/babel-preset/@babel/core/@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=="], - - "@react-native/codegen/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], - - "@react-native/codegen/@babel/core/@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=="], - - "@react-native/community-cli-plugin/metro/@babel/core/@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=="], - - "@react-native/community-cli-plugin/metro/@babel/core/@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=="], - - "@react-native/community-cli-plugin/metro/@babel/core/@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="], - - "@react-native/community-cli-plugin/metro/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "@react-native/community-cli-plugin/metro/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="], - - "@react-native/community-cli-plugin/metro/metro-source-map/ob1": ["ob1@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg=="], - - "@react-native/community-cli-plugin/metro/metro-transform-worker/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=="], - - "@react-navigation/bottom-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "@react-navigation/bottom-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "@react-navigation/elements/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "@react-navigation/elements/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "@react-navigation/material-top-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "@react-navigation/material-top-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "@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=="], - "babel-preset-expo/@babel/helper-module-imports/@babel/traverse/@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-preset-expo/@babel/helper-module-imports/@babel/traverse/@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-preset-expo/@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - - "babel-preset-expo/@babel/helper-module-imports/@babel/traverse/@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=="], - "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-manifests/@expo/config/@expo/config-plugins/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "expo-router/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "expo-manifests/@expo/config/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], - - "expo-manifests/@expo/config/sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], - - "istanbul-lib-instrument/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], - - "istanbul-lib-instrument/@babel/core/@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=="], + "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=="], - "metro-babel-transformer/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], + "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=="], - "metro-babel-transformer/@babel/core/@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=="], + "qrcode/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], - "metro-transform-plugins/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], + "qrcode/yargs/yargs-parser/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], - "metro-transform-plugins/@babel/core/@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=="], + "react-native-drawer-layout/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "metro-transform-worker/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], - - "metro-transform-worker/@babel/core/@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=="], - - "metro/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], - - "metro/@babel/core/@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=="], - - "serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - - "sucrase/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - - "wrap-ansi-cjs/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "@babel/highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + "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=="], @@ -3186,12 +2384,6 @@ "@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/community-cli-plugin/metro/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], - - "@react-native/community-cli-plugin/metro/@babel/core/@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=="], - "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=="], @@ -3200,6 +2392,10 @@ "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=="], @@ -3212,6 +2408,12 @@ "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/Badge.tsx b/components/Badge.tsx index b33fff2b6..4c3bcc821 100644 --- a/components/Badge.tsx +++ b/components/Badge.tsx @@ -1,5 +1,7 @@ +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 { @@ -14,6 +16,8 @@ export const Badge: React.FC = ({ variant = "purple", ...props }) => { + const typography = useScaledTVTypography(); + const content = ( {iconLeft && {iconLeft}} @@ -28,7 +32,7 @@ export const Badge: React.FC = ({ ); - if (Platform.OS === "ios") { + if (Platform.OS === "ios" && !Platform.isTV) { return ( @@ -38,21 +42,70 @@ export const Badge: React.FC = ({ ); } + // On TV, use BlurView for consistent styling + if (Platform.isTV) { + return ( + + + {iconLeft && {iconLeft}} + + {text} + + + + ); + } + return ( - {iconLeft && {iconLeft}} + {iconLeft && {iconLeft}} {text} diff --git a/components/Button.tsx b/components/Button.tsx index 03df89674..1471a5174 100644 --- a/components/Button.tsx +++ b/components/Button.tsx @@ -15,6 +15,7 @@ import { View, } from "react-native"; import { useHaptic } from "@/hooks/useHaptic"; +import { scaleSize } from "@/utils/scaleSize"; import { Loader } from "./Loader"; const getColorClasses = ( @@ -122,7 +123,7 @@ export const Button: React.FC> = ({ onPress={onPress} onFocus={() => { setFocused(true); - animateTo(1.08); + animateTo(1.03); }} onBlur={() => { setFocused(false); @@ -132,19 +133,29 @@ export const Button: React.FC> = ({ - + {children} diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index e50b4efca..b1f759b57 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -9,6 +9,7 @@ import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; +import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { type Href } from "expo-router"; import { t } from "i18next"; import { useAtom } from "jotai"; @@ -73,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, @@ -195,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, @@ -206,7 +232,7 @@ export const DownloadItems: React.FC = ({ const downloadDetails = await getDownloadUrl({ api, - item, + item: itemForDownload, userId: user.Id!, mediaSource: mediaSource!, audioStreamIndex: audioIndex ?? -1, @@ -218,7 +244,7 @@ export const DownloadItems: React.FC = ({ return { url: downloadDetails?.url, - item, + item: itemForDownload, mediaSource: downloadDetails?.mediaSource, }; }); diff --git a/components/GenreTags.tsx b/components/GenreTags.tsx index 030d554cf..29f1cb303 100644 --- a/components/GenreTags.tsx +++ b/components/GenreTags.tsx @@ -1,4 +1,5 @@ // GenreTags.tsx +import { BlurView } from "expo-blur"; import type React from "react"; import { Platform, @@ -9,6 +10,7 @@ import { 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 { @@ -23,7 +25,10 @@ export const Tag: React.FC< textStyle?: StyleProp; } & ViewProps > = ({ text, textClass, textStyle, ...props }) => { - if (Platform.OS === "ios") { + // Hook must be called at the top level, before any conditional returns + const typography = useScaledTVTypography(); + + if (Platform.OS === "ios" && !Platform.isTV) { return ( @@ -40,6 +45,32 @@ export const Tag: React.FC< ); } + // TV-specific styling with blur background + if (Platform.isTV) { + return ( + + + + {text} + + + + ); + } + return ( @@ -66,7 +97,8 @@ export const Tags: React.FC< return ( {tags.map((tag, idx) => ( diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 5195e993b..97b2f4293 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -15,7 +15,6 @@ 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"; @@ -37,6 +36,9 @@ 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; @@ -46,242 +48,267 @@ export type SelectedOptions = { }; interface ItemContentProps { - item: BaseItemDto; + item?: BaseItemDto | null; itemWithSources?: BaseItemDto | null; + isLoading?: boolean; } -export const ItemContent: React.FC = React.memo( - ({ item, itemWithSources }) => { - const [api] = useAtom(apiAtom); - const isOffline = useOfflineMode(); - const { getDownloadedItemById } = useDownload(); - const downloadedItem = - isOffline && item.Id ? getDownloadedItemById(item.Id) : null; - const { settings } = useSettings(); - const { orientation } = useOrientation(); - const navigation = useNavigation(); - const insets = useSafeAreaInsets(); - const [user] = useAtom(userAtom); +// Mobile-specific implementation +const ItemContentMobile: React.FC = ({ + item, + itemWithSources, +}) => { + const [api] = useAtom(apiAtom); + const isOffline = useOfflineMode(); + const { getDownloadedItemById } = useDownload(); + const downloadedItem = + isOffline && item?.Id ? getDownloadedItemById(item.Id) : null; + 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); - // Use itemWithSources for play settings since it has MediaSources data - const { - defaultAudioIndex, - defaultBitrate, - defaultMediaSource, - defaultSubtitleIndex, - } = useDefaultPlaySettings(itemWithSources ?? item, settings); + // 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 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 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(() => { - // When offline, use the indices stored in userData (the last-used tracks for this file) - // rather than the server's defaults, so MediaSourceButton reflects what will actually play. - const offlineUserData = downloadedItem?.userData; + // Needs to automatically change the selected to the default values for default indexes. + useEffect(() => { + // When offline, use the indices stored in userData (the last-used tracks for this file) + // rather than the server's defaults, so MediaSourceButton reflects what will actually play. + const offlineUserData = downloadedItem?.userData; - setSelectedOptions(() => ({ - bitrate: defaultBitrate, - mediaSource: defaultMediaSource ?? undefined, - subtitleIndex: - offlineUserData && !offlineUserData.isTranscoded - ? offlineUserData.subtitleStreamIndex - : (defaultSubtitleIndex ?? -1), - audioIndex: - offlineUserData && !offlineUserData.isTranscoded - ? offlineUserData.audioStreamIndex - : defaultAudioIndex, - })); - }, [ - defaultAudioIndex, - defaultBitrate, - defaultSubtitleIndex, - defaultMediaSource, - downloadedItem?.userData?.audioStreamIndex, - downloadedItem?.userData?.subtitleStreamIndex, - ]); + setSelectedOptions(() => ({ + bitrate: defaultBitrate, + mediaSource: defaultMediaSource ?? undefined, + subtitleIndex: + offlineUserData && !offlineUserData.isTranscoded + ? offlineUserData.subtitleStreamIndex + : (defaultSubtitleIndex ?? -1), + audioIndex: + offlineUserData && !offlineUserData.isTranscoded + ? offlineUserData.audioStreamIndex + : defaultAudioIndex, + })); + }, [ + defaultAudioIndex, + defaultBitrate, + defaultSubtitleIndex, + defaultMediaSource, + downloadedItem?.userData?.audioStreamIndex, + downloadedItem?.userData?.subtitleStreamIndex, + downloadedItem?.userData?.isTranscoded, + ]); - useEffect(() => { - if (!Platform.isTV && itemWithSources) { - navigation.setOptions({ - headerRight: () => - item && - (Platform.OS === "ios" ? ( - - - {item.Type !== "Program" && ( - - {!Platform.isTV && ( - + useEffect(() => { + if (!Platform.isTV && itemWithSources) { + navigation.setOptions({ + headerRight: () => + item && + (Platform.OS === "ios" ? ( + + + {item.Type !== "Program" && ( + + {!Platform.isTV && ( + + )} + {user?.Policy?.IsAdministrator && + !settings.hideRemoteSessionButton && ( + )} - {user?.Policy?.IsAdministrator && - !settings.hideRemoteSessionButton && ( - - )} - - - {settings.streamyStatsServerUrl && - !settings.hideWatchlistsTab && ( - - )} - - )} - - ) : ( - - - {item.Type !== "Program" && ( - - {!Platform.isTV && ( - + + + {settings.streamyStatsServerUrl && + !settings.hideWatchlistsTab && ( + )} - {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 ? ( - - ) : ( - - ) - } - > - - - + ) : ( + + + {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 && ( - + )} - {item.Type === "Episode" && ( - + + {item.Type === "Episode" && ( + + )} + + {!isOffline && + selectedOptions.mediaSource?.MediaStreams && + selectedOptions.mediaSource.MediaStreams.length > 0 && ( + )} - {!isOffline && - selectedOptions.mediaSource?.MediaStreams && - selectedOptions.mediaSource.MediaStreams.length > 0 && ( - + + + {item.Type !== "Program" && ( + <> + {item.Type === "Episode" && !isOffline && ( + )} - + - {item.Type !== "Program" && ( - <> - {item.Type === "Episode" && !isOffline && ( - - )} + {!isOffline && } + + )} + + + + ); +}; - +// Memoize the mobile component +const MemoizedItemContentMobile = React.memo(ItemContentMobile); - {!isOffline && } - - )} - - - - ); - }, -); +// 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 => { + if (!api || !item?.Id) return []; + + try { + // Fetch fresh item data with media sources + const response = await getUserLibraryApi(api).getItem({ + itemId: item.Id, + }); + + const freshItem = response.data; + const mediaSourceId = selectedOptions?.mediaSource?.Id; + + // Find the matching media source + const mediaSource = mediaSourceId + ? freshItem.MediaSources?.find( + (s: MediaSourceInfo) => s.Id === mediaSourceId, + ) + : freshItem.MediaSources?.[0]; + + // Get subtitle streams from the fresh data + const streams = + mediaSource?.MediaStreams?.filter( + (s: MediaStream) => s.Type === "Subtitle", + ) ?? []; + + // Convert to Track[] with setTrack callbacks + const tracks: Track[] = streams.map((stream) => ({ + name: + stream.DisplayTitle || + `${stream.Language || "Unknown"} (${stream.Codec})`, + index: stream.Index ?? -1, + setTrack: () => { + handleSubtitleChangeRef.current?.(stream.Index ?? -1); + }, + })); + + // Add locally downloaded subtitles + if (item?.Id) { + const localSubs = getSubtitlesForItem(item.Id); + let localIdx = 0; + for (const localSub of localSubs) { + 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: () => { + handleSubtitleChangeRef.current?.(localIndex); + }, + }); + localIdx++; + } + } + + return tracks; + } catch (error) { + console.error("Failed to refresh subtitle tracks:", error); + return []; + } + }, [api, item?.Id, selectedOptions?.mediaSource?.Id]); + + // Get display values for buttons + const selectedAudioLabel = useMemo(() => { + const track = audioTracks.find( + (t) => t.Index === selectedOptions?.audioIndex, + ); + return track?.DisplayTitle || track?.Language || t("item_card.audio"); + }, [audioTracks, selectedOptions?.audioIndex, t]); + + const selectedSubtitleLabel = useMemo(() => { + if (selectedOptions?.subtitleIndex === -1) + return t("item_card.subtitles.none"); + + // Check if it's a local subtitle (negative index starting at -100) + if ( + selectedOptions?.subtitleIndex !== undefined && + selectedOptions.subtitleIndex <= LOCAL_SUBTITLE_INDEX_START + ) { + const localTrack = subtitleTracksForModal.find( + (t) => t.index === selectedOptions.subtitleIndex, + ); + return localTrack?.name || t("item_card.subtitles.label"); + } + + const track = subtitleStreams.find( + (t) => t.Index === selectedOptions?.subtitleIndex, + ); + return ( + track?.DisplayTitle || track?.Language || t("item_card.subtitles.label") + ); + }, [ + subtitleStreams, + subtitleTracksForModal, + selectedOptions?.subtitleIndex, + t, + ]); + + const selectedMediaSourceLabel = useMemo(() => { + const source = selectedOptions?.mediaSource; + if (!source) return t("item_card.video"); + const videoStream = source.MediaStreams?.find((s) => s.Type === "Video"); + return videoStream?.DisplayTitle || source.Name || t("item_card.video"); + }, [selectedOptions?.mediaSource, t]); + + const selectedQualityLabel = useMemo(() => { + return selectedOptions?.bitrate?.key || t("item_card.quality"); + }, [selectedOptions?.bitrate?.key, t]); + + // Format year and duration + const year = item?.ProductionYear; + const duration = item?.RunTimeTicks + ? runtimeTicksToMinutes(item.RunTimeTicks) + : null; + const hasProgress = (item?.UserData?.PlaybackPositionTicks ?? 0) > 0; + const remainingTime = hasProgress + ? runtimeTicksToMinutes( + (item?.RunTimeTicks || 0) - + (item?.UserData?.PlaybackPositionTicks || 0), + ) + : null; + + // Get director + const director = item?.People?.find((p) => p.Type === "Director"); + + // Get cast (first 3 for text display) + const cast = item?.People?.filter((p) => p.Type === "Actor")?.slice(0, 3); + + // Get full cast for visual display (up to 10 actors) + const fullCast = useMemo(() => { + return ( + item?.People?.filter((p) => p.Type === "Actor")?.slice(0, 10) ?? [] + ); + }, [item?.People]); + + // Whether to show visual cast section + const showVisualCast = + (item?.Type === "Movie" || + item?.Type === "Series" || + item?.Type === "Episode") && + fullCast.length > 0; + + // Series/Season image URLs for episodes + const seriesImageUrl = useMemo(() => { + if (item?.Type !== "Episode" || !item.SeriesId) return null; + return getPrimaryImageUrlById({ api, id: item.SeriesId, width: 300 }); + }, [api, item?.Type, item?.SeriesId]); + + const seasonImageUrl = useMemo(() => { + if (item?.Type !== "Episode") return null; + const seasonId = item.SeasonId || item.ParentId; + if (!seasonId) return null; + return getPrimaryImageUrlById({ api, id: seasonId, width: 300 }); + }, [api, item?.Type, item?.SeasonId, item?.ParentId]); + + // Episode thumbnail URL - episode's own primary image (16:9 for episodes) + const episodeThumbnailUrl = useMemo(() => { + if (item?.Type !== "Episode" || !api) return null; + return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; + }, [api, item]); + + // Series thumb URL - used when showSeriesPosterOnEpisode setting is enabled + const seriesThumbUrl = useMemo(() => { + if (item?.Type !== "Episode" || !item.SeriesId || !api) return null; + return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=700&quality=80`; + }, [api, item]); + + // Navigation handlers + const handleActorPress = useCallback( + (personId: string) => { + router.push(`/(auth)/persons/${personId}`); + }, + [router], + ); + + const handleSeriesPress = useCallback(() => { + if (item?.SeriesId) { + router.push(`/(auth)/series/${item.SeriesId}`); + } + }, [router, item?.SeriesId]); + + const handleSeasonPress = useCallback(() => { + if (item?.SeriesId && item?.ParentIndexNumber) { + router.push( + `/(auth)/series/${item.SeriesId}?seasonIndex=${item.ParentIndexNumber}`, + ); + } + }, [router, item?.SeriesId, item?.ParentIndexNumber]); + + const handleEpisodePress = useCallback( + (episode: BaseItemDto) => { + const navigation = getItemNavigation(episode, "(home)"); + router.replace(navigation as any); + }, + [router], + ); + + if (!item || !selectedOptions) return null; + + return ( + + {/* Full-screen backdrop */} + + + {/* Main content area */} + + {/* Top section - Logo/Title + Metadata */} + + {/* Left side - Content */} + + {/* Logo or Title */} + {logoUrl ? ( + + ) : ( + + {item.Name} + + )} + + {/* Episode info for TV shows */} + {item.Type === "Episode" && ( + + + {item.SeriesName} + + + S{item.ParentIndexNumber} E{item.IndexNumber} · {item.Name} + + + )} + + {/* Metadata badges row */} + + + {/* Genres */} + {item.Genres && item.Genres.length > 0 && ( + + + + )} + + {/* Overview */} + {item.Overview && ( + + + + {item.Overview} + + + + )} + + {/* Action buttons */} + + + + + {hasProgress + ? `${remainingTime} ${t("item_card.left")}` + : t("common.play")} + + + + + + + + {/* Playback options */} + + {/* Quality selector */} + + showOptions({ + title: t("item_card.quality"), + options: qualityOptions, + onSelect: handleQualityChange, + }) + } + /> + + {/* Media source selector (only if multiple sources) */} + {mediaSources.length > 1 && ( + + showOptions({ + title: t("item_card.video"), + options: mediaSourceOptions, + onSelect: handleMediaSourceChange, + }) + } + /> + )} + + {/* Audio selector */} + {audioTracks.length > 0 && ( + + showOptions({ + title: t("item_card.audio"), + options: audioOptions, + onSelect: handleAudioChange, + }) + } + /> + )} + + {/* Subtitle selector */} + {(subtitleStreams.length > 0 || + selectedOptions?.subtitleIndex !== undefined) && ( + + showSubtitleModal({ + item, + mediaSourceId: selectedOptions?.mediaSource?.Id, + subtitleTracks: subtitleTracksForModal, + currentSubtitleIndex: + selectedOptions?.subtitleIndex ?? -1, + onDisableSubtitles: () => handleSubtitleChange(-1), + onServerSubtitleDownloaded: + handleServerSubtitleDownloaded, + onLocalSubtitleDownloaded: + handleLocalSubtitleDownloaded, + refreshSubtitleTracks, + }) + } + /> + )} + + + {/* Progress bar (if partially watched) */} + {hasProgress && item.RunTimeTicks != null && ( + + )} + + + {/* Right side - Poster */} + + + {item.Type === "Episode" ? ( + + ) : ( + + )} + + + + + {/* Additional info section */} + + {/* Season Episodes - Episode only */} + {item.Type === "Episode" && seasonEpisodes.length > 1 && ( + + + {t("item_card.more_from_this_season")} + + + + + )} + + {/* From this Series - Episode only */} + + + {/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */} + {showVisualCast && ( + + )} + + {/* Cast & Crew (text version - director, etc.) */} + + + {/* Technical details */} + {selectedOptions.mediaSource?.MediaStreams && + selectedOptions.mediaSource.MediaStreams.length > 0 && ( + + )} + + + + ); + }, +); + +// Alias for platform-resolved imports (tvOS auto-resolves .tv.tsx files) +export const ItemContent = ItemContentTV; diff --git a/components/ItemContentSkeleton.tv.tsx b/components/ItemContentSkeleton.tv.tsx new file mode 100644 index 000000000..e81864344 --- /dev/null +++ b/components/ItemContentSkeleton.tv.tsx @@ -0,0 +1,163 @@ +import React from "react"; +import { Dimensions, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +const { width: SCREEN_WIDTH } = Dimensions.get("window"); + +export const ItemContentSkeletonTV: React.FC = () => { + const insets = useSafeAreaInsets(); + + return ( + + {/* Left side - Content placeholders */} + + {/* Logo placeholder */} + + + {/* Metadata badges row */} + + + + + + + {/* Genres placeholder */} + + + + + + + {/* Overview placeholder */} + + + + + + + {/* Play button placeholder */} + + + + {/* Right side - Poster placeholder */} + + + + + ); +}; diff --git a/components/ItemTechnicalDetails.tsx b/components/ItemTechnicalDetails.tsx index 49614a2c9..5708318d7 100644 --- a/components/ItemTechnicalDetails.tsx +++ b/components/ItemTechnicalDetails.tsx @@ -77,7 +77,7 @@ export const ItemTechnicalDetails: React.FC = ({ source }) => { - {t("item_card.subtitles")} + {t("item_card.subtitles.label")} = ({ })); groups.push({ - title: t("item_card.subtitles"), + title: t("item_card.subtitles.label"), options: [noneOption, ...subtitleOptions], }); } diff --git a/components/PasswordEntryModal.tsx b/components/PasswordEntryModal.tsx index 63b4efe6a..efd1cc49d 100644 --- a/components/PasswordEntryModal.tsx +++ b/components/PasswordEntryModal.tsx @@ -128,7 +128,7 @@ export const PasswordEntryModal: React.FC = ({ {/* Password Input */} - {t("login.password")} + {t("login.password_placeholder")} = ({ setPassword(text); setError(null); }} - placeholder={t("login.password")} + placeholder={t("login.password_placeholder")} placeholderTextColor='#6B7280' secureTextEntry autoFocus @@ -174,7 +174,7 @@ export const PasswordEntryModal: React.FC = ({ {isLoading ? ( ) : ( - t("login.login") + t("common.login") )} diff --git a/components/PlatformDropdown.tsx b/components/PlatformDropdown.tsx index 24fd135c7..aaea71b3f 100644 --- a/components/PlatformDropdown.tsx +++ b/components/PlatformDropdown.tsx @@ -1,12 +1,28 @@ -import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui"; import { Ionicons } from "@expo/vector-icons"; import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; -import React, { useEffect } from "react"; -import { Platform, StyleSheet, TouchableOpacity, View } from "react-native"; +import React, { useEffect, useState } from "react"; +import { + type LayoutChangeEvent, + Platform, + StyleSheet, + TouchableOpacity, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { useGlobalModal } from "@/providers/GlobalModalProvider"; +// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds. +// A static top-level import evaluates requireNativeModule('ExpoUI') at module +// load and crashes the entire route tree on tvOS (expo-router requires every +// route file). Load it lazily and only off-TV; TV never renders these. +const { Button, Host, Menu } = Platform.isTV + ? ({} as typeof import("@expo/ui/swift-ui")) + : require("@expo/ui/swift-ui"); +const { disabled } = Platform.isTV + ? ({} as typeof import("@expo/ui/swift-ui/modifiers")) + : require("@expo/ui/swift-ui/modifiers"); + // Option types export type RadioOption = { type: "radio"; @@ -201,6 +217,24 @@ const PlatformDropdownComponent = ({ }: PlatformDropdownProps) => { const { showModal, hideModal, isVisible } = useGlobalModal(); + // @expo/ui's (SDK 55) fills its available space by default, and + // `matchContents` doesn't help here: it reports the native Menu's size via + // setStyleSize and overrides any explicit size. Instead we measure the + // trigger's intrinsic size in plain RN (off-layout) and pin it on the Host. + const [triggerSize, setTriggerSize] = useState<{ + width: number; + height: number; + } | null>(null); + + const handleMeasureTrigger = (e: LayoutChangeEvent) => { + const { width, height } = e.nativeEvent.layout; + setTriggerSize((prev) => + prev && prev.width === width && prev.height === height + ? prev + : { width, height }, + ); + }; + // Handle controlled open state for Android useEffect(() => { if (Platform.OS === "android" && controlledOpen === true) { @@ -230,12 +264,30 @@ const PlatformDropdownComponent = ({ } }, [isVisible, controlledOpen, controlledOnOpenChange]); - if (Platform.OS === "ios") { + if (Platform.OS === "ios" && !Platform.isTV) { + // Pin the wrapper to the measured trigger size. @expo/ui's (SDK 55) + // fills its parent and reports its own size via setStyleSize, so it can't + // size itself to content. If the wrapper has no size, the Host's `flex: 1` + // height depends on the parent while the parent depends on the Host — a + // circular dependency that collapses to 0 for any selector nested more than + // one level deep (so only the first, shallowest dropdown stays visible). + // Giving the wrapper the measured size breaks the cycle; the Host then + // fills a concrete box. return ( - - - {trigger} - + + {/* Hidden measurer: lays the trigger out off-flow to capture its + intrinsic size. Absolutely positioned WITHOUT right/bottom so it + sizes to the trigger's content rather than to its parent. */} + + {trigger} + + + {groups.flatMap((group, groupIndex) => { // Check if this group has radio options const radioOptions = group.options.filter( @@ -250,27 +302,40 @@ const PlatformDropdownComponent = ({ const items = []; - // Add Picker for radio options ONLY if there's a group title + // Group radio options under a submenu ONLY if there's a title // Otherwise render as individual buttons if (radioOptions.length > 0) { if (group.title) { - // Use Picker for grouped options + // Use a nested Menu as a submenu for grouped options. This + // reads as "Title: Selected" and expands to the choices on + // tap, keeping the nested look while staying a dropdown. + // (Menu opens on a single tap and nests cleanly; ContextMenu + // would require a long-press and read as a context menu.) + const selectedOption = radioOptions.find( + (opt) => opt.selected, + ); + const displayTitle = selectedOption + ? `${group.title}: ${selectedOption.label}` + : group.title; items.push( - opt.label)} - variant='menu' - selectedIndex={radioOptions.findIndex( - (opt) => opt.selected, - )} - onOptionSelected={(event: any) => { - const index = event.nativeEvent.index; - const selectedOption = radioOptions[index]; - selectedOption?.onPress(); - onOptionSelect?.(selectedOption?.value); - }} - />, + + {radioOptions.map((option, optionIndex) => ( + , ); } else { // Render radio options as direct buttons @@ -278,17 +343,18 @@ const PlatformDropdownComponent = ({ items.push( , + />, ); }); } @@ -299,17 +365,16 @@ const PlatformDropdownComponent = ({ items.push( , + />, ); }); @@ -318,21 +383,20 @@ const PlatformDropdownComponent = ({ items.push( , + />, ); }); return items; })} - - - + + + ); } diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 8b849a54d..4c7406555 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -35,9 +35,9 @@ import { useSettings } from "@/utils/atoms/settings"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; -import { chromecast } from "@/utils/profiles/chromecast"; -import { chromecasth265 } from "@/utils/profiles/chromecasth265"; import { runtimeTicksToMinutes } from "@/utils/time"; +import { chromecast } from "../utils/profiles/chromecast"; +import { chromecasth265 } from "../utils/profiles/chromecasth265"; import { Button } from "./Button"; import { Text } from "./common/Text"; import type { SelectedOptions } from "./ItemContent"; @@ -430,7 +430,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 diff --git a/components/PlayButton.tv.tsx b/components/PlayButton.tv.tsx index f79a3174b..c8b6b76e3 100644 --- a/components/PlayButton.tv.tsx +++ b/components/PlayButton.tv.tsx @@ -69,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(); @@ -77,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 diff --git a/components/PreviousServersList.tsx b/components/PreviousServersList.tsx index 008e1be2a..251a6ca3d 100644 --- a/components/PreviousServersList.tsx +++ b/components/PreviousServersList.tsx @@ -73,10 +73,19 @@ export const PreviousServersList: React.FC = ({ setLoadingServer(server.address); try { await onQuickLogin(server.address, account.userId); - } catch { - Alert.alert( + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : t("server.session_expired"); + const isSessionExpired = errorMessage.includes( t("server.session_expired"), - t("server.please_login_again"), + ); + 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 { @@ -122,10 +131,17 @@ export const PreviousServersList: React.FC = ({ setLoadingServer(selectedServer.address); try { await onQuickLogin(selectedServer.address, selectedAccount.userId); - } catch { - Alert.alert( + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : t("server.session_expired"); + const isSessionExpired = errorMessage.includes( t("server.session_expired"), - t("server.please_login_again"), + ); + Alert.alert( + isSessionExpired + ? t("server.session_expired") + : t("login.connection_failed"), + isSessionExpired ? t("server.please_login_again") : errorMessage, [ { text: t("common.ok"), diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index 6fca1955b..12c68e5c5 100644 --- a/components/SubtitleTrackSelector.tsx +++ b/components/SubtitleTrackSelector.tsx @@ -76,7 +76,7 @@ export const SubtitleTrackSelector: React.FC = ({ const trigger = ( - {t("item_card.subtitles")} + {t("item_card.subtitles.label")} = ({ = ({ item }) => { + if (Platform.isTV) { + // TV: Show white checkmark when watched + if ( + item.UserData?.Played && + (item.Type === "Movie" || item.Type === "Episode") + ) { + return ( + + + + ); + } + return null; + } + + // Mobile: Show purple triangle for unwatched return ( <> {item.UserData?.Played === false && diff --git a/components/apple-tv-carousel/AppleTVCarousel.tsx b/components/apple-tv-carousel/AppleTVCarousel.tsx deleted file mode 100644 index 82c0f7b4a..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 { 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 useRouter from "@/hooks/useAppRouter"; -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 ?? undefined, - 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..42a90b89e --- /dev/null +++ b/components/chapters/ChapterList.tsx @@ -0,0 +1,196 @@ +/** + * 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 { + 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 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 ( + + + e.stopPropagation()} style={styles.sheet}> + + {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", + backgroundColor: "rgba(0,0,0,0.6)", + }, + sheet: { + backgroundColor: Colors.background, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + maxHeight: "70%", + paddingBottom: 24, + }, + 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/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 ( = ({ item }) => { ); diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx index 2c85e0947..cc40d2dc7 100644 --- a/components/common/TouchableItemRouter.tsx +++ b/components/common/TouchableItemRouter.tsx @@ -2,7 +2,11 @@ import { useActionSheet } from "@expo/react-native-action-sheet"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { useSegments } from "expo-router"; import { type PropsWithChildren, useCallback } from "react"; -import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; +import { + Platform, + TouchableOpacity, + type TouchableOpacityProps, +} from "react-native"; import useRouter from "@/hooks/useAppRouter"; import { useFavorite } from "@/hooks/useFavorite"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; @@ -121,6 +125,12 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => { } 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! }, 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 66f2a81b1..c67f60583 100644 --- a/components/downloads/DownloadCard.tsx +++ b/components/downloads/DownloadCard.tsx @@ -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/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 1da3b358e..637e20418 100644 --- a/components/home/Home.tsx +++ b/components/home/Home.tsx @@ -35,6 +35,7 @@ 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"; @@ -44,6 +45,9 @@ 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"; title?: string; @@ -64,7 +68,7 @@ type MediaListSectionType = { type Section = InfiniteScrollingCollectionListSection | MediaListSectionType; -export const Home = () => { +const HomeMobile = () => { const router = useRouter(); const { t } = useTranslation(); const api = useAtomValue(apiAtom); @@ -86,6 +90,10 @@ export const Home = () => { 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"); @@ -595,11 +603,14 @@ export const Home = () => { style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }} > {sections.map((section, index) => { - // Render Streamystats sections after Continue Watching and Next Up - // When merged, they appear after index 0; otherwise after index 1 - const streamystatsIndex = settings.mergeNextUpAndContinueWatching - ? 0 - : 1; + // 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 || @@ -687,3 +698,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/HomeWithCarousel.tsx b/components/home/Home.tv.tsx similarity index 52% rename from components/home/HomeWithCarousel.tsx rename to components/home/Home.tv.tsx index c513294f2..40131767c 100644 --- a/components/home/HomeWithCarousel.tsx +++ b/components/home/Home.tv.tsx @@ -1,4 +1,4 @@ -import { Feather, Ionicons } from "@expo/vector-icons"; +import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto, BaseItemDtoQueryResult, @@ -12,37 +12,46 @@ import { getUserViewsApi, } from "@jellyfin/sdk/lib/utils/api"; import { type QueryFunction, useQuery } from "@tanstack/react-query"; -import { useNavigation, useSegments } from "expo-router"; +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, - Platform, - TouchableOpacity, + Animated, + Easing, + ScrollView, 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 { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists"; -import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations"; +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 { MediaListSection } from "@/components/medialists/MediaListSection"; -import { Colors } from "@/constants/Colors"; +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 { useDownload } from "@/providers/DownloadProvider"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; +import { + apiAtom, + cacheVersionAtom, + userAtom, +} from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; -import { eventBus } from "@/utils/eventBus"; -import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel"; +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"; @@ -51,97 +60,141 @@ type InfiniteScrollingCollectionListSection = { queryFn: QueryFunction; orientation?: "horizontal" | "vertical"; pageSize?: number; + parentId?: string; }; -type MediaListSectionType = { - type: "MediaListSection"; - queryKey: (string | undefined)[]; - queryFn: QueryFunction; -}; +type Section = InfiniteScrollingCollectionListSection; -type Section = InfiniteScrollingCollectionListSection | MediaListSectionType; +// Debounce delay in ms - prevents rapid backdrop changes when scrolling fast +const BACKDROP_DEBOUNCE_MS = 300; -export const HomeWithCarousel = () => { - const router = useRouter(); +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 [_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 { settings } = useSettings(); + const scrollRef = useRef(null); const { isConnected, serverConnected, loading: retryLoading, retryCheck, } = useNetworkStatus(); - const invalidateCache = useInvalidatePlaybackProgressCache(); - const [scrollY, setScrollY] = useState(0); + const _invalidateCache = useInvalidatePlaybackProgressCache(); + const { showItemActions } = useTVItemActionModal(); - useEffect(() => { - if (isConnected && !prevIsConnected.current) { - invalidateCache(); + // 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); } - 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"), - ); + // Set new timer to update focused item after debounce delay + debounceTimerRef.current = setTimeout(() => { + setFocusedItem(item); + }, BACKDROP_DEBOUNCE_MS); }, []); - const segments = useSegments(); + // Cleanup debounce timer on unmount useEffect(() => { - const unsubscribe = eventBus.on("scrollToTop", () => { - if ((segments as string[])[2] === "(home)") - animatedScrollRef.current?.scrollTo({ - y: Platform.isTV ? -152 : -100, - animated: true, - }); + 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 the image before starting the crossfade + try { + await Image.prefetch(backdropUrl); + } 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 () => { - unsubscribe(); + isCancelled = true; }; - }, [segments]); + }, [backdropUrl]); const { data, @@ -162,8 +215,72 @@ export const HomeWithCarousel = () => { }, 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], @@ -178,44 +295,36 @@ export const HomeWithCarousel = () => { ); }, [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, + pageSize = 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"], + limit: 10, + fields: ["PrimaryImageAspectRatio"], imageTypeLimit: 1, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], + enableImageTypes: ["Primary", "Backdrop", "Thumb"], includeItemTypes, parentId, }) ).data || []; - // Simulate pagination by slicing return allData.slice(pageParam, pageParam + pageSize); }, type: "InfiniteScrollingCollectionList", pageSize, + parentId, }), [api, user?.Id], ); @@ -244,7 +353,6 @@ export const HomeWithCarousel = () => { ); }); - // 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 || ""; @@ -253,7 +361,6 @@ export const HomeWithCarousel = () => { }); }; - // Helper to deduplicate items by ID const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => { const seen = new Set(); return items.filter((item) => { @@ -263,29 +370,25 @@ export const HomeWithCarousel = () => { }); }; - // 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", "Logo"], + enableImageTypes: ["Primary", "Backdrop", "Thumb"], includeItemTypes: ["Movie", "Series", "Episode"], - fields: ["Genres"], startIndex: 0, limit: 20, }), getTvShowsApi(api).getNextUp({ userId: user?.Id, - fields: ["MediaSourceCount", "Genres"], startIndex: 0, limit: 20, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], + enableImageTypes: ["Primary", "Backdrop", "Thumb"], enableResumable: false, }), ]); @@ -293,12 +396,10 @@ export const HomeWithCarousel = () => { 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", @@ -314,9 +415,8 @@ export const HomeWithCarousel = () => { ( await getItemsApi(api).getResumeItems({ userId: user.Id, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], + enableImageTypes: ["Primary", "Backdrop", "Thumb"], includeItemTypes: ["Movie", "Series", "Episode"], - fields: ["Genres"], startIndex: pageParam, limit: 10, }) @@ -332,10 +432,9 @@ export const HomeWithCarousel = () => { ( await getTvShowsApi(api).getNextUp({ userId: user?.Id, - fields: ["MediaSourceCount", "Genres"], startIndex: pageParam, limit: 10, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], + enableImageTypes: ["Primary", "Backdrop", "Thumb"], enableResumable: false, }) ).data.Items || [], @@ -348,7 +447,6 @@ export const HomeWithCarousel = () => { const ss: Section[] = [ ...firstSections, ...latestMediaViews, - // Only show Jellyfin suggested movies if StreamyStats recommendations are disabled ...(!settings?.streamyStatsMovieRecommendations ? [ { @@ -409,29 +507,26 @@ export const HomeWithCarousel = () => { 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, }); 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 + limit: section.latest?.limit || 10, isPlayed: section.latest?.isPlayed, groupItems: section.latest?.groupItems, }) ).data || []; - // Simulate pagination by slicing return allData.slice(pageParam, pageParam + pageSize); } if (section.custom) { @@ -461,6 +556,22 @@ export const HomeWithCarousel = () => { 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 = ""; @@ -476,32 +587,44 @@ export const HomeWithCarousel = () => { subtitle = t("home.server_unreachable_message"); } return ( - - {title} - {subtitle} - - - {!Platform.isTV && ( - - )} + + + {title} + + + {subtitle} + + + + + + + + + + + + ) : ( + + + + 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..8d0168b23 --- /dev/null +++ b/components/login/TVAddServerForm.tsx @@ -0,0 +1,123 @@ +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"; + +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 */} + {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..4ce611924 --- /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_occured"), + ); + } + } 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_occured"); + 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_occured"), + ); + } + } 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_occured"); + 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..596bb610a --- /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/music/MiniPlayerBar.tsx b/components/music/MiniPlayerBar.tsx index 41f5a5535..c0ffd094d 100644 --- a/components/music/MiniPlayerBar.tsx +++ b/components/music/MiniPlayerBar.tsx @@ -243,7 +243,7 @@ export const MiniPlayerBar: React.FC = () => { ]} > - {Platform.OS === "ios" ? ( + {Platform.OS === "ios" && !Platform.isTV ? ( = ({ 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 () => { + // Prefetch the image before starting the crossfade + try { + await Image.prefetch(backdropUrl); + } 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/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..cba3a5548 --- /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(); + + const hasMovies = movieResults && movieResults.length > 0; + const hasTv = tvResults && tvResults.length > 0; + const hasPersons = personResults && personResults.length > 0; + + if (loading) { + return null; + } + + if (noResults && searchQuery.length > 0) { + return ( + + + {t("search.no_results_found_for")} + + + "{searchQuery}" + + + ); + } + + return ( + + + + + + ); +}; 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..ab928d845 --- /dev/null +++ b/components/search/TVSearchPage.tsx @@ -0,0 +1,352 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { useAtom } from "jotai"; +import { useMemo } 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 { 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. */} + + {/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search` + module). It renders the native search bar + grid keyboard and + forwards typed text into the existing query pipeline via setSearch; + our own results grid renders below. */} + + setSearch(e.nativeEvent.text)} + /> + + + + + {/* Search Type Tab Badges */} + {showDiscover && ( + + + + )} + + {/* Loading State */} + {currentLoading && ( + + + + + )} + + {/* Library Search Results */} + {isLibraryMode && !loading && ( + + {sections.map((section, index) => ( + + ))} + + )} + + {/* 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..3d72de8df --- /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" }} + contentInset={{ + left: edgePadding, + right: edgePadding, + }} + contentOffset={{ x: -edgePadding, y: 0 }} + contentContainerStyle={{ + 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/TVEpisodeList.tsx b/components/series/TVEpisodeList.tsx new file mode 100644 index 000000000..0f2713208 --- /dev/null +++ b/components/series/TVEpisodeList.tsx @@ -0,0 +1,89 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import React, { useCallback } from "react"; +import { ScrollView, View } from "react-native"; +import { TVHorizontalList } from "@/components/tv/TVHorizontalList"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; + +interface TVEpisodeListProps { + episodes: BaseItemDto[]; + /** Shows "Now Playing" badge on the episode matching this ID */ + currentEpisodeId?: string; + /** Disable all cards (e.g., when modal is open) */ + disabled?: boolean; + /** Handler when an episode is pressed */ + onEpisodePress: (episode: BaseItemDto) => 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 f9074213e..844096177 100644 --- a/components/settings/AppearanceSettings.tsx +++ b/components/settings/AppearanceSettings.tsx @@ -42,14 +42,6 @@ export const AppearanceSettings: React.FC = () => { } /> - - - updateSettings({ showLargeHomeCarousel: value }) - } - /> - 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 index 0ceae68bb..4ef2a8001 100644 --- a/components/settings/MpvSubtitleSettings.tsx +++ b/components/settings/MpvSubtitleSettings.tsx @@ -1,6 +1,6 @@ import { Ionicons } from "@expo/vector-icons"; import { useMemo } from "react"; -import { Platform, View, type ViewProps } from "react-native"; +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"; @@ -55,7 +55,6 @@ export const MpvSubtitleSettings: React.FC = ({ ...props }) => { return [{ options }]; }, [settings?.mpvSubtitleAlignY, updateSettings]); - if (isTv) return null; if (!settings) return null; return ( @@ -68,65 +67,83 @@ export const MpvSubtitleSettings: React.FC = ({ ...props }) => { } > - - - updateSettings({ mpvSubtitleScale: Math.round(value * 10) / 10 }) + {!isTv && ( + <> + + + updateSettings({ mpvSubtitleMarginY: value }) + } + /> + + + + + + {alignXLabels[settings?.mpvSubtitleAlignX ?? "center"]} + + + + } + title='Horizontal Alignment' + /> + + + + + + {alignYLabels[settings?.mpvSubtitleAlignY ?? "bottom"]} + + + + } + title='Vertical Alignment' + /> + + + )} + + + + updateSettings({ mpvSubtitleBackgroundEnabled: value }) } /> - - updateSettings({ mpvSubtitleMarginY: value })} - /> - - - - - - {alignXLabels[settings?.mpvSubtitleAlignX ?? "center"]} - - - - } - title='Horizontal Alignment' - /> - - - - - - {alignYLabels[settings?.mpvSubtitleAlignY ?? "bottom"]} - - - - } - title='Vertical Alignment' - /> - + {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 fcca24988..7abf10fbd 100644 --- a/components/settings/OtherSettings.tsx +++ b/components/settings/OtherSettings.tsx @@ -158,14 +158,6 @@ export const OtherSettings: React.FC = () => { } /> - - - updateSettings({ showLargeHomeCarousel: value }) - } - /> - router.push("/settings/hide-libraries/page")} title={t("home.settings.other.hide_libraries")} diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx index 526cb8629..77f5453e5 100644 --- a/components/settings/SubtitleToggles.tsx +++ b/components/settings/SubtitleToggles.tsx @@ -1,9 +1,10 @@ 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 { useSettings } from "@/utils/atoms/settings"; import { Text } from "../common/Text"; @@ -23,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, @@ -160,17 +166,55 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { disabled={pluginSettings?.subtitleSize?.locked} > - updateSettings({ subtitleSize: Math.round(value * 100) }) + updateSettings({ mpvSubtitleScale: Math.round(value * 10) / 10 }) } /> + + {/* 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.opensubtitles_get_key") || + "Get your free API key at opensubtitles.com/en/consumers"} + + + ); }; diff --git a/components/stacks/NestedTabPageStack.tsx b/components/stacks/NestedTabPageStack.tsx index db0f9b960..3c5fa57f1 100644 --- a/components/stacks/NestedTabPageStack.tsx +++ b/components/stacks/NestedTabPageStack.tsx @@ -1,18 +1,13 @@ -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", 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/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..fad7261be --- /dev/null +++ b/components/tv/TVPosterCard.tsx @@ -0,0 +1,593 @@ +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 { 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} + + ); + } + + // Fallback rendering for older tvOS versions + return ( + + + {PlayButtonOverlay} + {NowPlayingBadge} + + + + ); + }; + + // 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..a35104eb0 --- /dev/null +++ b/components/tv/index.ts @@ -0,0 +1,70 @@ +// 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 { 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/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx index 51abf68c7..b0e32dad4 100644 --- a/components/video-player/controls/BottomControls.tsx +++ b/components/video-player/controls/BottomControls.tsx @@ -1,18 +1,34 @@ -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import type { FC } from "react"; -import { View } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import type { + BaseItemDto, + ChapterInfo, +} from "@jellyfin/sdk/lib/generated-client"; +import { type FC, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Pressable, View } from "react-native"; import { Slider } from "react-native-awesome-slider"; import { type SharedValue } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { ChapterList } from "@/components/chapters/ChapterList"; +import { ChapterTicks } from "@/components/chapters/ChapterTicks"; import { Text } from "@/components/common/Text"; import { useSettings } from "@/utils/atoms/settings"; +import { chapterMarkers, chapterNameAt } from "@/utils/chapters"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import SkipButton from "./SkipButton"; import { TimeDisplay } from "./TimeDisplay"; import { TrickplayBubble } from "./TrickplayBubble"; +// Chapter tick height in dp — matches the slider track height for a clean, +// flush look (no top/bottom overflow). +const TICK_HEIGHT = 10; + interface BottomControlsProps { item: BaseItemDto; + /** Item chapters, used for the tick overlay and chapter list. */ + chapters?: ChapterInfo[] | null; + /** Total media duration in milliseconds. */ + durationMs: number; showControls: boolean; isSliding: boolean; showRemoteBubble: boolean; @@ -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: { @@ -57,10 +75,15 @@ interface BottomControlsProps { minutes: number; seconds: number; }; + + // Chapter props + chapterPositions?: number[]; } export const BottomControls: FC = ({ item, + chapters, + durationMs, showControls, isSliding, showRemoteBubble, @@ -84,12 +107,39 @@ export const BottomControls: FC = ({ handleSliderChange, handleTouchStart, handleTouchEnd, + seekTo, trickPlayUrl, trickplayInfo, time, + chapterPositions = [], }) => { const { settings } = useSettings(); + const { t } = useTranslation(); const insets = useSafeAreaInsets(); + 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 ( = ({ onTouchStart={handleControlsInteraction} > = ({ {item?.Type === "Audio" && ( {item?.Album} )} + {currentChapterName ? ( + + {currentChapterName} + + ) : null} - + + {hasChapters && ( + setChapterListVisible(true)} + hitSlop={10} + className='justify-center mr-4' + accessibilityRole='button' + accessibilityLabel={t("chapters.open")} + > + + + )} = ({ 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} @@ -203,6 +272,7 @@ export const BottomControls: FC = ({ trickPlayUrl={trickPlayUrl} trickplayInfo={trickplayInfo} time={time} + chapterName={scrubChapterName} /> ) } @@ -212,6 +282,7 @@ export const BottomControls: FC = ({ minimumValue={min} maximumValue={max} /> + = ({ /> + setChapterListVisible(false)} + /> ); }; diff --git a/components/video-player/controls/CenterControls.tsx b/components/video-player/controls/CenterControls.tsx index 76ce4bed3..c668f3d6e 100644 --- a/components/video-player/controls/CenterControls.tsx +++ b/components/video-player/controls/CenterControls.tsx @@ -18,6 +18,12 @@ interface CenterControlsProps { togglePlay: () => void; handleSkipBackward: () => void; handleSkipForward: () => void; + // Chapter navigation props + hasChapters?: boolean; + hasPreviousChapter?: boolean; + hasNextChapter?: boolean; + goToPreviousChapter?: () => void; + goToNextChapter?: () => void; } export const CenterControls: FC = ({ @@ -29,6 +35,11 @@ export const CenterControls: FC = ({ togglePlay, handleSkipBackward, handleSkipForward, + hasChapters = false, + hasPreviousChapter = false, + hasNextChapter = false, + goToPreviousChapter, + goToNextChapter, }) => { const { settings } = useSettings(); const insets = useSafeAreaInsets(); @@ -44,7 +55,7 @@ export const CenterControls: FC = ({ justifyContent: "space-between", alignItems: "center", transform: [{ translateY: -22.5 }], - paddingHorizontal: "28%", + paddingHorizontal: hasChapters ? "18%" : "28%", }} pointerEvents={showControls ? "box-none" : "none"} > @@ -94,6 +105,20 @@ export const CenterControls: FC = ({ )} + {!Platform.isTV && hasChapters && ( + + + + )} + {!isBuffering ? ( @@ -108,6 +133,20 @@ export const CenterControls: FC = ({ + {!Platform.isTV && hasChapters && ( + + + + )} + {!Platform.isTV && ( 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/Controls.tsx b/components/video-player/controls/Controls.tsx index 96dfad6b3..1e64f16ad 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -33,6 +33,7 @@ import { CONTROLS_CONSTANTS } from "./constants"; 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"; @@ -211,6 +212,21 @@ export const Controls: FC = ({ isSeeking, }); + // Chapter navigation hook + const { + hasChapters, + hasPreviousChapter, + hasNextChapter, + goToPreviousChapter, + goToNextChapter, + chapterPositions, + } = useChapterNavigation({ + chapters: item.Chapters, + progress, + maxMs, + seek, + }); + const toggleControls = useCallback(() => { if (showControls) { setShowAudioSlider(false); @@ -251,6 +267,7 @@ export const Controls: FC = ({ handleTouchEnd, handleSliderComplete, handleSliderChange, + seekTo, } = useVideoSlider({ progress, isSeeking, @@ -339,10 +356,15 @@ export const Controls: FC = ({ mediaSource: newMediaSource, audioIndex: defaultAudioIndex, subtitleIndex: defaultSubtitleIndex, - } = getDefaultPlaySettings(item, settings, { - indexes: previousIndexes, - source: mediaSource ?? undefined, - }); + } = getDefaultPlaySettings( + item, + settings, + { + indexes: previousIndexes, + source: mediaSource ?? undefined, + }, + { applyLanguagePreferences: true }, + ); const queryParams = new URLSearchParams({ ...(offline && { offline: "true" }), @@ -481,6 +503,7 @@ export const Controls: FC = ({ getTechnicalInfo={getTechnicalInfo} playMethod={playMethod} transcodeReasons={transcodeReasons} + mediaSource={mediaSource} /> )} = ({ 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} + chapterPositions={chapterPositions} /> diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx new file mode 100644 index 000000000..5ddd4bc38 --- /dev/null +++ b/components/video-player/controls/Controls.tv.tsx @@ -0,0 +1,1623 @@ +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 => { + try { + const streams = (await onRefreshSubtitleTracks?.()) ?? []; + // Skip streams without a real index: `?? -1` would alias them to the + // "disable subtitles" sentinel and mis-route selection. + return streams + .filter((stream) => typeof stream.Index === "number") + .map((stream) => { + const index = stream.Index as number; + return { + name: + stream.DisplayTitle || + `${stream.Language || "Unknown"} (${stream.Codec})`, + index, + setTrack: () => onSubtitleIndexChange?.(index), + }; + }); + } catch { + return []; + } + }, [onRefreshSubtitleTracks, onSubtitleIndexChange]); + + const { + trickPlayUrl, + calculateTrickplayUrl, + trickplayInfo, + prefetchAllTrickplayImages, + } = useTrickplay(item); + + const min = useSharedValue(0); + const maxMs = ticksToMs(item.RunTimeTicks || 0); + const max = useSharedValue(maxMs); + + const controlsOpacity = useSharedValue(showControls ? 1 : 0); + const bottomTranslateY = useSharedValue(showControls ? 0 : 50); + + useEffect(() => { + prefetchAllTrickplayImages(); + }, [prefetchAllTrickplayImages]); + + useEffect(() => { + const animationConfig = { + duration: 300, + easing: Easing.out(Easing.quad), + }; + + controlsOpacity.value = withTiming(showControls ? 1 : 0, animationConfig); + bottomTranslateY.value = withTiming(showControls ? 0 : 30, animationConfig); + + // Hide minimal seek bar immediately when normal controls show + if (showControls) { + setShowMinimalSeekBar(false); + if (minimalSeekBarTimeoutRef.current) { + clearTimeout(minimalSeekBarTimeoutRef.current); + minimalSeekBarTimeoutRef.current = null; + } + } + }, [showControls, controlsOpacity, bottomTranslateY]); + + // Overlay only fades, no slide + const overlayAnimatedStyle = useAnimatedStyle(() => ({ + opacity: controlsOpacity.value, + })); + + // Bottom controls fade and slide up + const bottomAnimatedStyle = useAnimatedStyle(() => ({ + opacity: controlsOpacity.value, + transform: [{ translateY: bottomTranslateY.value }], + })); + + // Minimal seek bar animation + useEffect(() => { + const animationConfig = { + duration: 200, + easing: Easing.out(Easing.quad), + }; + minimalSeekBarOpacity.value = withTiming( + showMinimalSeekBar ? 1 : 0, + animationConfig, + ); + }, [showMinimalSeekBar, minimalSeekBarOpacity]); + + const minimalSeekBarAnimatedStyle = useAnimatedStyle(() => ({ + opacity: minimalSeekBarOpacity.value, + })); + + useEffect(() => { + if (item) { + progress.value = ticksToMs(item?.UserData?.PlaybackPositionTicks); + max.value = ticksToMs(item.RunTimeTicks || 0); + } + }, [item, progress, max]); + + const { currentTime, remainingTime } = useVideoTime({ + progress, + max, + isSeeking, + }); + + // Chapter navigation hook + const { + hasChapters, + hasPreviousChapter, + hasNextChapter, + goToPreviousChapter, + goToNextChapter, + chapterPositions, + } = useChapterNavigation({ + chapters: item.Chapters, + progress, + maxMs, + seek, + }); + + // Skip intro/credits hooks + // Note: hooks expect seek callback that takes ms, and seek prop already expects ms + const offline = useOfflineMode(); + const { showSkipButton, skipIntro } = useIntroSkipper( + item.Id!, + currentTime, + seek, + _play, + offline, + api, + downloadedFiles, + ); + + const { showSkipCreditButton, skipCredit, hasContentAfterCredits } = + useCreditSkipper( + item.Id!, + currentTime, + seek, + _play, + offline, + api, + downloadedFiles, + max.value, + ); + + // Countdown logic + const isCountdownActive = useMemo(() => { + if (!nextItem) return false; + if (item?.Type !== "Episode") return false; + return remainingTime > 0 && remainingTime <= 10000; + }, [nextItem, item, remainingTime]); + + // Simple boolean - when skip cards or countdown are visible, they have focus + const isSkipOrCountdownVisible = useMemo(() => { + const skipIntroVisible = showSkipButton && !isCountdownActive; + const skipCreditsVisible = + showSkipCreditButton && + (hasContentAfterCredits || !nextItem) && + !isCountdownActive; + return skipIntroVisible || skipCreditsVisible || isCountdownActive; + }, [ + showSkipButton, + showSkipCreditButton, + hasContentAfterCredits, + nextItem, + isCountdownActive, + ]); + + // Live TV detection - check for both Program (when playing from guide) and TvChannel (when playing from channels) + const isLiveTV = item?.Type === "Program" || item?.Type === "TvChannel"; + + // For live TV, determine if we're at the live edge (within 5 seconds of max) + const LIVE_EDGE_THRESHOLD = 5000; // 5 seconds in ms + + const getFinishTime = () => { + const now = new Date(); + const finishTime = new Date(now.getTime() + remainingTime); + return finishTime.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + }; + + const toggleControls = useCallback(() => { + if (isSkipOrCountdownVisible) return; // Skip/countdown has focus, don't toggle + setShowControls(!showControls); + }, [showControls, setShowControls, isSkipOrCountdownVisible]); + + const [showSeekBubble, setShowSeekBubble] = useState(false); + const [seekBubbleTime, setSeekBubbleTime] = useState({ + hours: 0, + minutes: 0, + seconds: 0, + }); + const seekBubbleTimeoutRef = useRef | null>( + null, + ); + const continuousSeekRef = useRef | null>(null); + const seekAccelerationRef = useRef(1); + const controlsInteractionRef = useRef<() => void>(() => {}); + const goToNextItemRef = useRef<(opts?: { isAutoPlay?: boolean }) => void>( + () => {}, + ); + const exitingRef = useRef(false); + const [isExiting, setIsExiting] = useState(false); + + const updateSeekBubbleTime = useCallback((ms: number) => { + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + setSeekBubbleTime({ hours, minutes, seconds }); + }, []); + + // Show minimal seek bar (only progress bar, no buttons) + const showMinimalSeek = useCallback(() => { + setShowMinimalSeekBar(true); + + // Clear existing timeout + if (minimalSeekBarTimeoutRef.current) { + clearTimeout(minimalSeekBarTimeoutRef.current); + } + + // Auto-hide after timeout + minimalSeekBarTimeoutRef.current = setTimeout(() => { + setShowMinimalSeekBar(false); + }, 2500); + }, []); + + // Show minimal seek bar without auto-hide (for continuous seeking) + const showMinimalSeekPersistent = useCallback(() => { + setShowMinimalSeekBar(true); + + // Clear existing timeout - don't set a new one + if (minimalSeekBarTimeoutRef.current) { + clearTimeout(minimalSeekBarTimeoutRef.current); + minimalSeekBarTimeoutRef.current = null; + } + }, []); + + // Start the minimal seek bar hide timeout + const startMinimalSeekHideTimeout = useCallback(() => { + if (minimalSeekBarTimeoutRef.current) { + clearTimeout(minimalSeekBarTimeoutRef.current); + } + minimalSeekBarTimeoutRef.current = setTimeout(() => { + setShowMinimalSeekBar(false); + }, 2500); + }, []); + + const handleOpenAudioSheet = useCallback(() => { + setLastOpenedModal("audio"); + showOptions({ + title: t("item_card.audio"), + options: audioOptions, + onSelect: handleAudioChange, + }); + controlsInteractionRef.current(); + }, [showOptions, t, audioOptions, handleAudioChange]); + + const handleLocalSubtitleDownloaded = useCallback( + (path: string) => { + addSubtitleFile?.(path); + }, + [addSubtitleFile], + ); + + const handleOpenSubtitleSheet = useCallback(() => { + setLastOpenedModal("subtitle"); + // Filter out the "Disable" option from VideoContext tracks since the modal adds its own "None" option. + // Wrap each setTrack so selecting a subtitle ALSO updates the player's live + // index via onSubtitleIndexChange. The modal is a separate route, so the + // VideoContext router.setParams inside setTrack targets the modal — not the + // player — leaving currentSubtitleIndex stale. Without this sync, the next + // episode carries the previously-shown subtitle instead of the one the user + // just picked. (The audio sheet already uses onAudioIndexChange directly.) + const tracksWithoutDisable = (videoContextSubtitleTracks ?? []) + .filter((track) => track.index !== -1) + .map((track) => ({ + ...track, + setTrack: () => { + track.setTrack(); + onSubtitleIndexChange?.(track.index); + }, + })); + showSubtitleModal({ + item, + mediaSourceId: mediaSource?.Id, + subtitleTracks: tracksWithoutDisable, + currentSubtitleIndex: subtitleIndex ?? -1, + onDisableSubtitles: () => { + // Find and call the "Disable" track's setTrack from VideoContext + const disableTrack = videoContextSubtitleTracks?.find( + (t) => t.index === -1, + ); + disableTrack?.setTrack(); + onSubtitleIndexChange?.(-1); + }, + onLocalSubtitleDownloaded: handleLocalSubtitleDownloaded, + refreshSubtitleTracks: onRefreshSubtitleTracks + ? refreshSubtitleTracks + : undefined, + }); + controlsInteractionRef.current(); + }, [ + showSubtitleModal, + item, + mediaSource?.Id, + videoContextSubtitleTracks, + subtitleIndex, + onSubtitleIndexChange, + handleLocalSubtitleDownloaded, + onRefreshSubtitleTracks, + refreshSubtitleTracks, + ]); + + const handleToggleTechnicalInfo = useCallback(() => { + setLastOpenedModal("techInfo"); + onToggleTechnicalInfo?.(); + controlsInteractionRef.current(); + }, [onToggleTechnicalInfo]); + + const effectiveProgress = useSharedValue(0); + + const SEEK_THRESHOLD_MS = 5000; + + useAnimatedReaction( + () => progress.value, + (current, _previous) => { + const progressUnit = CONTROLS_CONSTANTS.PROGRESS_UNIT_MS; + const progressDiff = Math.abs(current - effectiveProgress.value); + if (progressDiff >= progressUnit) { + if (progressDiff >= SEEK_THRESHOLD_MS) { + effectiveProgress.value = withTiming(current, { + duration: 200, + easing: Easing.out(Easing.quad), + }); + } else { + effectiveProgress.value = current; + } + } + }, + [], + ); + + const handleSeekForwardButton = useCallback(() => { + // For live TV, check if we're already at the live edge + if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) { + // Already at live edge, don't seek further + controlsInteractionRef.current(); + return; + } + + const newPosition = Math.min(max.value, progress.value + 30 * 1000); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + setShowSeekBubble(true); + + if (seekBubbleTimeoutRef.current) { + clearTimeout(seekBubbleTimeoutRef.current); + } + seekBubbleTimeoutRef.current = setTimeout(() => { + setShowSeekBubble(false); + }, 2000); + + controlsInteractionRef.current(); + }, [ + progress, + max, + seek, + calculateTrickplayUrl, + updateSeekBubbleTime, + isLiveTV, + ]); + + const handleSeekBackwardButton = useCallback(() => { + const newPosition = Math.max(min.value, progress.value - 30 * 1000); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + setShowSeekBubble(true); + + if (seekBubbleTimeoutRef.current) { + clearTimeout(seekBubbleTimeoutRef.current); + } + seekBubbleTimeoutRef.current = setTimeout(() => { + setShowSeekBubble(false); + }, 2000); + + controlsInteractionRef.current(); + }, [progress, min, seek, calculateTrickplayUrl, updateSeekBubbleTime]); + + // Progress bar D-pad seeking (10s increments for finer control) + const handleProgressSeekRight = useCallback(() => { + // For live TV, check if we're already at the live edge + if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) { + // Already at live edge, don't seek further + controlsInteractionRef.current(); + return; + } + + const newPosition = Math.min(max.value, progress.value + 10 * 1000); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + setShowSeekBubble(true); + + if (seekBubbleTimeoutRef.current) { + clearTimeout(seekBubbleTimeoutRef.current); + } + seekBubbleTimeoutRef.current = setTimeout(() => { + setShowSeekBubble(false); + }, 2000); + + controlsInteractionRef.current(); + }, [ + progress, + max, + seek, + calculateTrickplayUrl, + updateSeekBubbleTime, + isLiveTV, + ]); + + const handleProgressSeekLeft = useCallback(() => { + const newPosition = Math.max(min.value, progress.value - 10 * 1000); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + setShowSeekBubble(true); + + if (seekBubbleTimeoutRef.current) { + clearTimeout(seekBubbleTimeoutRef.current); + } + seekBubbleTimeoutRef.current = setTimeout(() => { + setShowSeekBubble(false); + }, 2000); + + controlsInteractionRef.current(); + }, [progress, min, seek, calculateTrickplayUrl, updateSeekBubbleTime]); + + // Minimal seek mode handlers (only show progress bar, not full controls) + const handleMinimalSeekRight = useCallback(() => { + // For live TV, check if we're already at the live edge + if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) { + // Already at live edge, don't seek further + return; + } + + const newPosition = Math.min(max.value, progress.value + 10 * 1000); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + setShowSeekBubble(true); + + // Show minimal seek bar and reset its timeout + showMinimalSeek(); + + if (seekBubbleTimeoutRef.current) { + clearTimeout(seekBubbleTimeoutRef.current); + } + seekBubbleTimeoutRef.current = setTimeout(() => { + setShowSeekBubble(false); + }, 2000); + }, [ + progress, + max, + seek, + calculateTrickplayUrl, + updateSeekBubbleTime, + showMinimalSeek, + isLiveTV, + ]); + + const handleMinimalSeekLeft = useCallback(() => { + const newPosition = Math.max(min.value, progress.value - 10 * 1000); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + setShowSeekBubble(true); + + // Show minimal seek bar and reset its timeout + showMinimalSeek(); + + if (seekBubbleTimeoutRef.current) { + clearTimeout(seekBubbleTimeoutRef.current); + } + seekBubbleTimeoutRef.current = setTimeout(() => { + setShowSeekBubble(false); + }, 2000); + }, [ + progress, + min, + seek, + calculateTrickplayUrl, + updateSeekBubbleTime, + showMinimalSeek, + ]); + + // Continuous seeking functions (for button long-press and D-pad long-press) + const stopContinuousSeeking = useCallback(() => { + if (continuousSeekRef.current) { + clearInterval(continuousSeekRef.current); + continuousSeekRef.current = null; + } + seekAccelerationRef.current = 1; + + if (seekBubbleTimeoutRef.current) { + clearTimeout(seekBubbleTimeoutRef.current); + } + seekBubbleTimeoutRef.current = setTimeout(() => { + setShowSeekBubble(false); + }, 2000); + + // Start minimal seekbar hide timeout (if it's showing) + startMinimalSeekHideTimeout(); + }, [startMinimalSeekHideTimeout]); + + const startContinuousSeekForward = useCallback(() => { + // For live TV, check if we're already at the live edge + if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) { + // Already at live edge, don't start continuous seeking + return; + } + + seekAccelerationRef.current = 1; + + handleSeekForwardButton(); + + continuousSeekRef.current = setInterval(() => { + // For live TV, stop continuous seeking when we hit the live edge + if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) { + stopContinuousSeeking(); + return; + } + + const seekAmount = + CONTROLS_CONSTANTS.LONG_PRESS_INITIAL_SEEK * + seekAccelerationRef.current * + 1000; + const newPosition = Math.min(max.value, progress.value + seekAmount); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + + seekAccelerationRef.current = Math.min( + seekAccelerationRef.current * + CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION, + CONTROLS_CONSTANTS.LONG_PRESS_MAX_ACCELERATION, + ); + + controlsInteractionRef.current(); + }, CONTROLS_CONSTANTS.LONG_PRESS_INTERVAL); + }, [ + handleSeekForwardButton, + max, + progress, + seek, + calculateTrickplayUrl, + updateSeekBubbleTime, + isLiveTV, + stopContinuousSeeking, + ]); + + const startContinuousSeekBackward = useCallback(() => { + seekAccelerationRef.current = 1; + + handleSeekBackwardButton(); + + continuousSeekRef.current = setInterval(() => { + const seekAmount = + CONTROLS_CONSTANTS.LONG_PRESS_INITIAL_SEEK * + seekAccelerationRef.current * + 1000; + const newPosition = Math.max(min.value, progress.value - seekAmount); + progress.value = newPosition; + seek(newPosition); + + calculateTrickplayUrl(msToTicks(newPosition)); + updateSeekBubbleTime(newPosition); + + seekAccelerationRef.current = Math.min( + seekAccelerationRef.current * + CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION, + CONTROLS_CONSTANTS.LONG_PRESS_MAX_ACCELERATION, + ); + + controlsInteractionRef.current(); + }, CONTROLS_CONSTANTS.LONG_PRESS_INTERVAL); + }, [ + handleSeekBackwardButton, + min, + progress, + seek, + calculateTrickplayUrl, + updateSeekBubbleTime, + ]); + + // D-pad long press handlers - show minimal seekbar when controls are hidden + const handleDpadLongSeekForward = useCallback(() => { + if (!showControls) { + showMinimalSeekPersistent(); + } + startContinuousSeekForward(); + }, [showControls, showMinimalSeekPersistent, startContinuousSeekForward]); + + const handleDpadLongSeekBackward = useCallback(() => { + if (!showControls) { + showMinimalSeekPersistent(); + } + startContinuousSeekBackward(); + }, [showControls, showMinimalSeekPersistent, startContinuousSeekBackward]); + + // Callback for remote interactions to reset timeout + const handleRemoteInteraction = useCallback(() => { + controlsInteractionRef.current(); + }, []); + + // Callback for up/down D-pad - show controls with play button focused + const handleVerticalDpad = useCallback(() => { + if (isSkipOrCountdownVisible) return; // Skip/countdown has focus, don't show controls + setFocusPlayButton(true); + setShowControls(true); + }, [setShowControls, isSkipOrCountdownVisible]); + + const hideControls = useCallback(() => { + setShowControls(false); + setFocusPlayButton(false); + }, [setShowControls]); + + // When controls hide (and no skip/countdown overlay is visible), move focus + // to the invisible overlay so hidden buttons can't receive select events. + useEffect(() => { + if (!showControls && !isSkipOrCountdownVisible) { + // Small delay to let the controls fade-out animation start and + // the focus engine settle before stealing focus + const t = setTimeout(() => { + focusOverlayRef.current?.focus(); + }, 100); + return () => clearTimeout(t); + } + }, [showControls, isSkipOrCountdownVisible]); + + const handleBack = useCallback(() => { + router.back(); + }, [router]); + + const handleWillExit = useCallback(() => { + exitingRef.current = true; + setIsExiting(true); + }, []); + + const handleCancelExit = useCallback(() => { + exitingRef.current = false; + setIsExiting(false); + }, []); + + const { isSliding: isRemoteSliding } = useRemoteControl({ + showControls: showControls, + toggleControls, + togglePlay, + isProgressBarFocused, + onSeekLeft: handleProgressSeekLeft, + onSeekRight: handleProgressSeekRight, + onMinimalSeekLeft: handleMinimalSeekLeft, + onMinimalSeekRight: handleMinimalSeekRight, + onInteraction: handleRemoteInteraction, + onLongSeekLeftStart: handleDpadLongSeekBackward, + onLongSeekRightStart: handleDpadLongSeekForward, + onLongSeekStop: stopContinuousSeeking, + onVerticalDpad: handleVerticalDpad, + onHideControls: hideControls, + onBack: handleBack, + onWillExit: handleWillExit, + onCancelExit: handleCancelExit, + videoTitle: item?.Name ?? undefined, + }); + + const { handleControlsInteraction } = useControlsTimeout({ + showControls: showControls, + isSliding: isRemoteSliding, + episodeView: false, + onHideControls: hideControls, + timeout: TV_AUTO_HIDE_TIMEOUT, + disabled: false, + }); + + controlsInteractionRef.current = handleControlsInteraction; + + const handlePlayPauseButton = useCallback(() => { + togglePlay(); + controlsInteractionRef.current(); + }, [togglePlay]); + + const handlePreviousItem = useCallback(() => { + if (goToPreviousItem) { + goToPreviousItem(); + } + controlsInteractionRef.current(); + }, [goToPreviousItem]); + + const handleNextItemButton = useCallback(() => { + if (goToNextItemProp) { + goToNextItemProp(); + } else { + goToNextItemRef.current({ isAutoPlay: false }); + } + controlsInteractionRef.current(); + }, [goToNextItemProp]); + + const goToNextItem = useCallback( + ({ isAutoPlay: _isAutoPlay }: { isAutoPlay?: boolean } = {}) => { + if (!nextItem || !settings) { + return; + } + + // Use the live selection passed down from the player (currentSubtitleIndex + // / currentAudioIndex), not the stale URL params the episode started with. + // This path runs on autoplay; the manual "Next" button uses goToNextItemProp. + const previousIndexes = { + subtitleIndex, + audioIndex, + }; + + const { + mediaSource: newMediaSource, + audioIndex: defaultAudioIndex, + subtitleIndex: defaultSubtitleIndex, + } = getDefaultPlaySettings(nextItem, settings, { + indexes: previousIndexes, + source: 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(); + + router.replace(`player/direct-player?${queryParams}` as any); + }, + [ + nextItem, + settings, + subtitleIndex, + audioIndex, + mediaSource, + bitrateValue, + router, + ], + ); + + goToNextItemRef.current = goToNextItem; + + const handleAutoPlayFinish = useCallback(() => { + if (exitingRef.current) return; + goToNextItem({ isAutoPlay: true }); + }, [goToNextItem]); + + const topOverlayFocusTarget = skipSegmentRef ?? nextEpisodeRef; + + return ( + + + + {/* Invisible overlay that steals focus when controls are hidden. + Prevents hidden control buttons from receiving select/enter events + from the TV remote. Pressing center button here toggles play/pause. */} + { + togglePlay(); + setShowControls(true); + setFocusPlayButton(true); + }} + /> + + {getTechnicalInfo && ( + + )} + + {/* Skip intro card */} + + + {/* Skip credits card - show when there's content after credits, OR no next episode */} + + + {nextItem && ( + + )} + + {/* Minimal seek bar - shows only progress bar when seeking while controls hidden */} + {/* Uses exact same layout as normal controls for alignment */} + + + + + + + {/* Same padding as TVFocusableProgressBar for alignment */} + + + + + ({ + width: `${max.value > 0 ? (cacheProgress.value / max.value) * 100 : 0}%`, + })), + ]} + /> + ({ + width: `${max.value > 0 ? (effectiveProgress.value / max.value) * 100 : 0}%`, + })), + ]} + /> + + {/* Chapter markers */} + {chapterPositions.length > 0 && ( + + {chapterPositions.map((position, index) => ( + + ))} + + )} + + + + + + + {formatTimeString(currentTime, "ms")} + + {!isLiveTV && ( + + + -{formatTimeString(remainingTime, "ms")} + + + {t("player.ends_at")} {getFinishTime()} + + + )} + + + + + + + + {item?.Type === "Episode" && ( + {`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`} + )} + + + {item?.Name} + + {isLiveTV && ( + + + {t("player.live")} + + + )} + + {item?.Type === "Movie" && ( + + {item?.ProductionYear} + + )} + + + {/* Upward: control buttons → visible skip segment or next episode card */} + {topOverlayFocusTarget && ( + + )} + + + + {hasChapters && ( + + )} + + {hasChapters && ( + + )} + + + + + {audioOptions.length > 0 && ( + + )} + + + + {getTechnicalInfo && ( + + )} + + + + + + + {/* Bidirectional focus guides - stacked together per docs */} + {/* Downward: play button → progress bar */} + {progressBarRef && ( + + )} + {/* Upward: progress bar → play button */} + {playButtonRef && ( + + )} + + {/* Progress bar with focus trapping for left/right */} + + setIsProgressBarFocused(true)} + onBlur={() => setIsProgressBarFocused(false)} + refSetter={setProgressBarRef} + hasTVPreferredFocus={false} + /> + + + + + {formatTimeString(currentTime, "ms")} + + {!isLiveTV && ( + + + -{formatTimeString(remainingTime, "ms")} + + + {t("player.ends_at")} {getFinishTime()} + + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + controlsContainer: { + ...StyleSheet.absoluteFill, + }, + darkOverlay: { + ...StyleSheet.absoluteFill, + backgroundColor: "rgba(0, 0, 0, 0.4)", + }, + focusStealingOverlay: { + ...StyleSheet.absoluteFill, + zIndex: 1, + }, + bottomContainer: { + position: "absolute", + bottom: 0, + left: 0, + right: 0, + zIndex: 10, + }, + bottomInner: { + flexDirection: "column", + }, + metadataContainer: { + marginBottom: 16, + }, + titleRow: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + subtitleText: { + color: "rgba(255,255,255,0.6)", + }, + titleText: { + color: "#fff", + fontWeight: "bold", + }, + liveBadge: { + backgroundColor: "#EF4444", + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 6, + }, + liveBadgeText: { + color: "#FFF", + fontWeight: "bold", + }, + controlButtonsRow: { + flexDirection: "row", + alignItems: "center", + gap: 16, + marginBottom: 20, + paddingVertical: 8, + }, + controlButtonsSpacer: { + flex: 1, + }, + trickplayBubbleContainer: { + position: "absolute", + bottom: 190, + left: 0, + right: 0, + zIndex: 20, + }, + trickplayBubblePositioned: { + position: "absolute", + bottom: 0, + }, + focusGuide: { + height: 1, + width: "100%", + }, + progressBarContainer: { + height: TV_SEEKBAR_HEIGHT, + justifyContent: "center", + marginBottom: 8, + }, + progressTrack: { + height: TV_SEEKBAR_HEIGHT, + backgroundColor: "rgba(255,255,255,0.2)", + borderRadius: 8, + overflow: "hidden", + }, + cacheProgress: { + position: "absolute", + top: 0, + left: 0, + height: "100%", + backgroundColor: "rgba(255,255,255,0.3)", + borderRadius: 8, + }, + progressFill: { + position: "absolute", + top: 0, + left: 0, + height: "100%", + backgroundColor: "#fff", + borderRadius: 8, + }, + timeContainer: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginTop: 12, + }, + timeText: { + color: "rgba(255,255,255,0.7)", + }, + timeRight: { + flexDirection: "column", + alignItems: "flex-end", + }, + endsAtText: { + color: "rgba(255,255,255,0.5)", + marginTop: 2, + }, + // Minimal seek bar styles + minimalSeekBarContainer: { + position: "absolute", + bottom: 0, + left: 0, + right: 0, + zIndex: 5, + }, + minimalProgressWrapper: { + // Match TVFocusableProgressBar padding for alignment + paddingVertical: 8, + paddingHorizontal: 4, + }, + minimalProgressGlow: { + // Same glow effect and scale as focused TVFocusableProgressBar + shadowColor: "#fff", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.5, + shadowRadius: 12, + transform: [{ scale: 1.02 }], + }, + minimalProgressTrack: { + // Brighter track like focused state + backgroundColor: "rgba(255,255,255,0.35)", + }, + minimalProgressTrackWrapper: { + position: "relative", + height: TV_SEEKBAR_HEIGHT, + }, + minimalChapterMarkersContainer: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + minimalChapterMarker: { + position: "absolute", + width: 2, + height: TV_SEEKBAR_HEIGHT + 5, + bottom: 0, + backgroundColor: "rgba(255, 255, 255, 0.6)", + borderRadius: 1, + transform: [{ translateX: -1 }], + }, +}); 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 9891ea0c2..2b1d6125c 100644 --- a/components/video-player/controls/HeaderControls.tsx +++ b/components/video-player/controls/HeaderControls.tsx @@ -123,7 +123,9 @@ export const HeaderControls: FC = ({ - {!Platform.isTV && ( + {/* Rotate toggle is Android-only: iOS does not reliably rotate the + player back to portrait programmatically. */} + {Platform.OS === "android" && ( = ({ } }, ); + + // Cancel animation on unmount to prevent onFinish from firing after exit + return () => { + cancelAnimation(progress); + }; } }, [show, onFinish]); diff --git a/components/video-player/controls/TVSubtitleSheet.tsx b/components/video-player/controls/TVSubtitleSheet.tsx new file mode 100644 index 000000000..6284b5660 --- /dev/null +++ b/components/video-player/controls/TVSubtitleSheet.tsx @@ -0,0 +1,560 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { + BaseItemDto, + MediaStream, +} from "@jellyfin/sdk/lib/generated-client"; +import { BlurView } from "expo-blur"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + Animated, + Easing, + ScrollView, + StyleSheet, + TVFocusGuideView, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { + TVCancelButton, + TVLanguageCard, + TVSubtitleResultCard, + TVTabButton, + TVTrackCard, +} from "@/components/tv"; +import { + type SubtitleSearchResult, + useRemoteSubtitles, +} from "@/hooks/useRemoteSubtitles"; +import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api"; + +interface TVSubtitleSheetProps { + visible: boolean; + item: BaseItemDto; + mediaSourceId?: string | null; + subtitleTracks: MediaStream[]; + currentSubtitleIndex: number; + onSubtitleIndexChange: (index: number) => void; + onClose: () => void; + onServerSubtitleDownloaded?: () => void; + onLocalSubtitleDownloaded?: (path: string) => void; +} + +type TabType = "tracks" | "download"; + +export const TVSubtitleSheet: React.FC = ({ + visible, + item, + mediaSourceId, + subtitleTracks, + currentSubtitleIndex, + onSubtitleIndexChange, + onClose, + onServerSubtitleDownloaded, + onLocalSubtitleDownloaded, +}) => { + const { t } = useTranslation(); + + console.log( + "[TVSubtitleSheet] visible:", + visible, + "tracks:", + subtitleTracks.length, + ); + 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 { + hasOpenSubtitlesApiKey, + isSearching, + searchError, + searchResults, + search, + downloadAsync, + reset, + } = useRemoteSubtitles({ + itemId: item.Id ?? "", + item, + mediaSourceId, + }); + + const resetRef = useRef(reset); + resetRef.current = reset; + + const overlayOpacity = useRef(new Animated.Value(0)).current; + const sheetTranslateY = useRef(new Animated.Value(300)).current; + + const initialSelectedTrackIndex = useMemo(() => { + if (currentSubtitleIndex === -1) return 0; + const trackIdx = subtitleTracks.findIndex( + (t) => t.Index === currentSubtitleIndex, + ); + return trackIdx >= 0 ? trackIdx + 1 : 0; + }, [subtitleTracks, currentSubtitleIndex]); + + useEffect(() => { + if (visible) { + 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(); + } + }, [visible, overlayOpacity, sheetTranslateY]); + + useEffect(() => { + if (!visible) { + setHasSearchedThisSession(false); + setActiveTab("tracks"); + resetRef.current(); + setIsReady(false); + } + }, [visible]); + + useEffect(() => { + if (visible) { + const timer = setTimeout(() => setIsReady(true), 100); + return () => clearTimeout(timer); + } + setIsReady(false); + }, [visible]); + + useEffect(() => { + if (visible && activeTab === "download" && !hasSearchedThisSession) { + search({ language: selectedLanguage }); + setHasSearchedThisSession(true); + } + }, [visible, activeTab, hasSearchedThisSession, search, selectedLanguage]); + + useEffect(() => { + if (isReady) { + setIsTabContentReady(false); + const timer = setTimeout(() => setIsTabContentReady(true), 50); + return () => clearTimeout(timer); + } + setIsTabContentReady(false); + }, [activeTab, isReady]); + + const handleLanguageSelect = useCallback( + (code: string) => { + setSelectedLanguage(code); + search({ language: code }); + }, + [search], + ); + + const handleTrackSelect = useCallback( + (index: number) => { + onSubtitleIndexChange(index); + onClose(); + }, + [onSubtitleIndexChange, onClose], + ); + + const handleDownload = useCallback( + async (result: SubtitleSearchResult) => { + setDownloadingId(result.id); + + try { + const downloadResult = await downloadAsync(result); + + if (downloadResult.type === "server") { + onServerSubtitleDownloaded?.(); + } else if (downloadResult.type === "local" && downloadResult.path) { + onLocalSubtitleDownloaded?.(downloadResult.path); + } + + onClose(); + } catch (error) { + console.error("Failed to download subtitle:", error); + } finally { + setDownloadingId(null); + } + }, + [ + downloadAsync, + onServerSubtitleDownloaded, + onLocalSubtitleDownloaded, + onClose, + ], + ); + + 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, + }; + const options = subtitleTracks.map((track) => ({ + label: + track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`, + sublabel: track.Codec?.toUpperCase(), + value: track.Index!, + selected: track.Index === currentSubtitleIndex, + })); + return [noneOption, ...options]; + }, [subtitleTracks, currentSubtitleIndex, t]); + + if (!visible) return null; + + return ( + + + + + {/* Header with tabs */} + + + {t("item_card.subtitles.label") || "Subtitles"} + + + {/* Tab bar */} + + setActiveTab("tracks")} + /> + setActiveTab("download")} + /> + + + + {/* Tracks Tab Content */} + {activeTab === "tracks" && isTabContentReady && ( + + + {trackOptions.map((option, index) => ( + handleTrackSelect(option.value)} + /> + ))} + + + )} + + {/* 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 && ( + + + + {t("player.searching") || "Searching..."} + + + )} + + {/* 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"} + + + )} + + )} + + {/* Cancel button */} + {isReady && ( + + + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0, 0, 0, 0.6)", + justifyContent: "flex-end", + zIndex: 1000, + }, + sheetContainer: { + maxHeight: "70%", + }, + blurContainer: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + overflow: "hidden", + }, + content: { + paddingTop: 24, + paddingBottom: 48, + }, + header: { + paddingHorizontal: 48, + marginBottom: 20, + }, + title: { + fontSize: 24, + fontWeight: "600", + color: "#fff", + marginBottom: 16, + }, + tabRow: { + flexDirection: "row", + gap: 24, + }, + section: { + marginBottom: 20, + }, + sectionTitle: { + fontSize: 14, + fontWeight: "500", + color: "rgba(255,255,255,0.5)", + textTransform: "uppercase", + letterSpacing: 1, + marginBottom: 12, + paddingHorizontal: 48, + }, + tracksScroll: { + overflow: "visible", + }, + tracksScrollContent: { + paddingHorizontal: 48, + paddingVertical: 8, + gap: 12, + }, + languageScroll: { + overflow: "visible", + }, + languageScrollContent: { + paddingHorizontal: 48, + paddingVertical: 8, + gap: 10, + }, + resultsScroll: { + overflow: "visible", + }, + resultsScrollContent: { + paddingHorizontal: 48, + paddingVertical: 8, + gap: 12, + }, + loadingContainer: { + paddingVertical: 40, + alignItems: "center", + }, + loadingText: { + color: "rgba(255,255,255,0.6)", + marginTop: 12, + fontSize: 14, + }, + errorContainer: { + paddingVertical: 40, + paddingHorizontal: 48, + alignItems: "center", + }, + errorText: { + color: "rgba(255,100,100,0.9)", + marginTop: 8, + fontSize: 16, + fontWeight: "500", + }, + errorHint: { + color: "rgba(255,255,255,0.5)", + marginTop: 4, + fontSize: 13, + textAlign: "center", + }, + emptyContainer: { + paddingVertical: 40, + alignItems: "center", + }, + emptyText: { + color: "rgba(255,255,255,0.5)", + marginTop: 8, + fontSize: 14, + }, + apiKeyHint: { + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingHorizontal: 48, + paddingTop: 8, + }, + apiKeyHintText: { + color: "rgba(255,255,255,0.4)", + fontSize: 12, + }, + cancelButtonContainer: { + paddingHorizontal: 48, + paddingTop: 20, + alignItems: "flex-start", + }, +}); diff --git a/components/video-player/controls/TechnicalInfoOverlay.tsx b/components/video-player/controls/TechnicalInfoOverlay.tsx index 1498990d1..20ec1fc47 100644 --- a/components/video-player/controls/TechnicalInfoOverlay.tsx +++ b/components/video-player/controls/TechnicalInfoOverlay.tsx @@ -1,4 +1,12 @@ -import { type FC, memo, useCallback, useEffect, useState } from "react"; +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, @@ -7,6 +15,7 @@ import Animated, { withTiming, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import type { TechnicalInfo } from "@/modules/mpv-player"; import { useSettings } from "@/utils/atoms/settings"; import { HEADER_LAYOUT } from "./constants"; @@ -19,6 +28,9 @@ interface TechnicalInfoOverlayProps { getTechnicalInfo: () => Promise; playMethod?: PlayMethod; transcodeReasons?: string[]; + mediaSource?: MediaSourceInfo | null; + currentSubtitleIndex?: number; + currentAudioIndex?: number; } const formatBitrate = (bitsPerSecond: number): string => { @@ -47,10 +59,51 @@ const formatCodec = (codec: string): string => { 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"; @@ -121,18 +174,55 @@ const formatTranscodeReason = (reason: string): string => { export const TechnicalInfoOverlay: FC = memo( ({ - showControls, + showControls: _showControls, visible, getTechnicalInfo, playMethod, transcodeReasons, + mediaSource, + currentSubtitleIndex, + currentAudioIndex, }) => { + const typography = useScaledTVTypography(); const { settings } = useSettings(); const insets = useSafeAreaInsets(); 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, { @@ -168,64 +258,85 @@ export const TechnicalInfoOverlay: FC = memo( opacity: opacity.value, })); - // Hide on TV platforms - if (Platform.isTV) return null; - // 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: + (settings?.safeAreaInControlsEnabled ?? true) + ? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4 + : HEADER_LAYOUT.CONTAINER_PADDING + 4, + left: + (settings?.safeAreaInControlsEnabled ?? true) + ? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20 + : 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) @@ -235,18 +346,22 @@ export const TechnicalInfoOverlay: FC = memo( )} {info?.cacheSeconds !== undefined && ( - + Buffer: {info.cacheSeconds.toFixed(1)}s )} + {info?.voDriver && ( + + VO: {info.voDriver} + {info.hwdec ? ` / ${info.hwdec}` : ""} + + )} {info?.droppedFrames !== undefined && info.droppedFrames > 0 && ( - + Dropped: {info.droppedFrames} frames )} - {!info && !playMethod && ( - Loading... - )} + {!info && !playMethod && Loading...} ); @@ -267,12 +382,23 @@ const styles = StyleSheet.create({ 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", }, @@ -280,4 +406,7 @@ const styles = StyleSheet.create({ color: "#fbbf24", fontSize: 10, }, + reasonTextTV: { + color: "#fbbf24", + }, }); 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/constants.ts b/components/video-player/controls/constants.ts index dc735cf95..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; diff --git a/components/video-player/controls/contexts/VideoContext.tsx b/components/video-player/controls/contexts/VideoContext.tsx index ec9ca995f..7c5750849 100644 --- a/components/video-player/controls/contexts/VideoContext.tsx +++ b/components/video-player/controls/contexts/VideoContext.tsx @@ -47,6 +47,7 @@ */ import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client"; +import { File } from "expo-file-system"; import { useLocalSearchParams } from "expo-router"; import type React from "react"; import { @@ -57,13 +58,19 @@ import { useMemo, useState, } from "react"; +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 { 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 { subtitleTracks: Track[] | null; audioTracks: Track[] | null; @@ -339,12 +346,40 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({ }; }); + // TV only: Merge locally downloaded subtitles (from OpenSubtitles) + if (Platform.isTV && itemId) { + const localSubs = getSubtitlesForItem(itemId); + 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; + subs.push({ + name: localSub.name, + index: localIndex, + mpvIndex: -1, // Will be loaded dynamically via addSubtitleFile + isLocal: true, + localPath: localSub.filePath, + setTrack: () => { + // Add the subtitle file to MPV and select it + playerControls.addSubtitleFile(localSub.filePath, true); + router.setParams({ subtitleIndex: String(localIndex) }); + }, + }); + localIdx++; + } + } + setSubtitleTracks(subs.sort((a, b) => a.index - b.index)); setAudioTracks(audio); }; fetchTracks(); - }, [tracksReady, mediaSource, offline, downloadedItem]); + }, [tracksReady, mediaSource, offline, downloadedItem, itemId]); return ( diff --git a/components/video-player/controls/dropdown/DropdownView.tsx b/components/video-player/controls/dropdown/DropdownView.tsx index 5b631ec48..7b6713b39 100644 --- a/components/video-player/controls/dropdown/DropdownView.tsx +++ b/components/video-player/controls/dropdown/DropdownView.tsx @@ -15,16 +15,18 @@ import { usePlayerContext } from "../contexts/PlayerContext"; import { useVideoContext } from "../contexts/VideoContext"; import { PlaybackSpeedScope } from "../utils/playback-speed-settings"; -// Subtitle size presets (stored as scale * 100, so 1.0 = 100) -const SUBTITLE_SIZE_PRESETS = [ - { label: "0.5", value: 50 }, - { label: "0.6", value: 60 }, - { label: "0.7", value: 70 }, - { label: "0.8", value: 80 }, - { label: "0.9", value: 90 }, - { label: "1.0", value: 100 }, - { label: "1.1", value: 110 }, - { label: "1.2", value: 120 }, +// Subtitle scale presets (direct multiplier values) +const SUBTITLE_SCALE_PRESETS = [ + { label: "0.1x", value: 0.1 }, + { label: "0.25x", value: 0.25 }, + { label: "0.5x", value: 0.5 }, + { label: "0.75x", value: 0.75 }, + { label: "1.0x", value: 1.0 }, + { label: "1.25x", value: 1.25 }, + { label: "1.5x", value: 1.5 }, + { label: "2.0x", value: 2.0 }, + { label: "2.5x", value: 2.5 }, + { label: "3.0x", value: 3.0 }, ] as const; interface DropdownViewProps { @@ -124,15 +126,15 @@ const DropdownView = ({ })), }); - // Subtitle Size Section + // Subtitle Scale Section groups.push({ - title: "Subtitle Size", - options: SUBTITLE_SIZE_PRESETS.map((preset) => ({ + title: "Subtitle Scale", + options: SUBTITLE_SCALE_PRESETS.map((preset) => ({ type: "radio" as const, label: preset.label, value: preset.value.toString(), - selected: settings.subtitleSize === preset.value, - onPress: () => updateSettings({ subtitleSize: preset.value }), + selected: (settings.mpvSubtitleScale ?? 1.0) === preset.value, + onPress: () => updateSettings({ mpvSubtitleScale: preset.value }), })), }); } @@ -190,7 +192,7 @@ const DropdownView = ({ audioTracksKey, subtitleIndex, audioIndex, - settings.subtitleSize, + settings.mpvSubtitleScale, updateSettings, playbackSpeed, setPlaybackSpeed, diff --git a/components/video-player/controls/hooks/index.ts b/components/video-player/controls/hooks/index.ts index 08b234ac5..cfb317599 100644 --- a/components/video-player/controls/hooks/index.ts +++ b/components/video-player/controls/hooks/index.ts @@ -1,3 +1,4 @@ +export { useChapterNavigation } from "./useChapterNavigation"; export { useRemoteControl } from "./useRemoteControl"; export { useVideoNavigation } from "./useVideoNavigation"; export { useVideoSlider } from "./useVideoSlider"; diff --git a/components/video-player/controls/hooks/useChapterNavigation.ts b/components/video-player/controls/hooks/useChapterNavigation.ts new file mode 100644 index 000000000..00d3330c0 --- /dev/null +++ b/components/video-player/controls/hooks/useChapterNavigation.ts @@ -0,0 +1,150 @@ +import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client"; +import { useCallback, useMemo } from "react"; +import type { SharedValue } from "react-native-reanimated"; +import { ticksToMs } from "@/utils/time"; + +export interface UseChapterNavigationProps { + /** Chapters array from the item */ + chapters: ChapterInfo[] | null | undefined; + /** Current progress in milliseconds (SharedValue) */ + progress: SharedValue; + /** Total duration in milliseconds */ + maxMs: number; + /** Seek function that accepts milliseconds */ + seek: (ms: number) => void; +} + +export interface UseChapterNavigationReturn { + /** Array of chapters */ + chapters: ChapterInfo[]; + /** Index of the current chapter (-1 if no chapters) */ + currentChapterIndex: number; + /** Current chapter info or null */ + currentChapter: ChapterInfo | null; + /** Whether there's a next chapter available */ + hasNextChapter: boolean; + /** Whether there's a previous chapter available */ + hasPreviousChapter: boolean; + /** Navigate to the next chapter */ + goToNextChapter: () => void; + /** Navigate to the previous chapter (or restart current if >3s in) */ + goToPreviousChapter: () => void; + /** Array of chapter positions as percentages (0-100) for tick marks */ + chapterPositions: number[]; + /** Whether chapters are available */ + hasChapters: boolean; +} + +// Threshold in ms - if more than 3 seconds into chapter, restart instead of going to previous +const RESTART_THRESHOLD_MS = 3000; + +/** + * Hook for chapter navigation in video player + * Provides current chapter info and navigation functions + */ +export function useChapterNavigation({ + chapters: rawChapters, + progress, + maxMs, + seek, +}: UseChapterNavigationProps): UseChapterNavigationReturn { + // Ensure chapters is always an array + const chapters = useMemo(() => rawChapters ?? [], [rawChapters]); + + // Calculate chapter positions as percentages for tick marks + const chapterPositions = useMemo(() => { + if (!chapters.length || maxMs <= 0) return []; + + return chapters + .map((chapter) => { + const positionMs = ticksToMs(chapter.StartPositionTicks); + return (positionMs / maxMs) * 100; + }) + .filter((pos) => pos > 0 && pos < 100); // Skip first (0%) and any at the end + }, [chapters, maxMs]); + + // Find current chapter index based on progress + // The current chapter is the one with the largest StartPositionTicks that is <= current progress + const getCurrentChapterIndex = useCallback((): number => { + if (!chapters.length) return -1; + + const currentMs = progress.value; + let currentIndex = -1; + + for (let i = 0; i < chapters.length; i++) { + const chapterMs = ticksToMs(chapters[i].StartPositionTicks); + if (chapterMs <= currentMs) { + currentIndex = i; + } else { + break; + } + } + + return currentIndex; + }, [chapters, progress]); + + // Current chapter index (computed once for rendering) + const currentChapterIndex = getCurrentChapterIndex(); + + // Current chapter info + const currentChapter = useMemo(() => { + if (currentChapterIndex < 0 || currentChapterIndex >= chapters.length) { + return null; + } + return chapters[currentChapterIndex]; + }, [chapters, currentChapterIndex]); + + // Navigation availability + const hasNextChapter = + chapters.length > 0 && currentChapterIndex < chapters.length - 1; + const hasPreviousChapter = chapters.length > 0 && currentChapterIndex >= 0; + + // Navigate to next chapter + const goToNextChapter = useCallback(() => { + const idx = getCurrentChapterIndex(); + if (idx < chapters.length - 1) { + const nextChapter = chapters[idx + 1]; + const nextMs = ticksToMs(nextChapter.StartPositionTicks); + progress.value = nextMs; + seek(nextMs); + } + }, [chapters, getCurrentChapterIndex, progress, seek]); + + // Navigate to previous chapter (or restart current if >3s in) + const goToPreviousChapter = useCallback(() => { + const idx = getCurrentChapterIndex(); + if (idx < 0) return; + + const currentChapterMs = ticksToMs(chapters[idx].StartPositionTicks); + const currentMs = progress.value; + const timeIntoChapter = currentMs - currentChapterMs; + + // If more than 3 seconds into the current chapter, restart it + // Otherwise, go to the previous chapter + if (timeIntoChapter > RESTART_THRESHOLD_MS && idx >= 0) { + progress.value = currentChapterMs; + seek(currentChapterMs); + } else if (idx > 0) { + const prevChapter = chapters[idx - 1]; + const prevMs = ticksToMs(prevChapter.StartPositionTicks); + progress.value = prevMs; + seek(prevMs); + } else { + // At the first chapter, just restart it + progress.value = currentChapterMs; + seek(currentChapterMs); + } + }, [chapters, getCurrentChapterIndex, progress, seek]); + + return { + chapters, + currentChapterIndex, + currentChapter, + hasNextChapter, + hasPreviousChapter, + goToNextChapter, + goToPreviousChapter, + chapterPositions, + hasChapters: chapters.length > 0, + }; +} diff --git a/components/video-player/controls/hooks/useRemoteControl.ts b/components/video-player/controls/hooks/useRemoteControl.ts index 0a71b8b06..8a4fd902e 100644 --- a/components/video-player/controls/hooks/useRemoteControl.ts +++ b/components/video-player/controls/hooks/useRemoteControl.ts @@ -1,183 +1,237 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { Platform } from "react-native"; +import { useEffect, useRef, useState } from "react"; +import { Alert } from "react-native"; import { type SharedValue, useSharedValue } from "react-native-reanimated"; -import { msToTicks, ticksToSeconds } from "@/utils/time"; -import { CONTROLS_CONSTANTS } from "../constants"; - -// TV event handler with fallback for non-TV platforms -let useTVEventHandler: (callback: (evt: any) => void) => void; -if (Platform.isTV) { - try { - useTVEventHandler = require("react-native").useTVEventHandler; - } catch { - // Fallback for non-TV platforms - useTVEventHandler = () => {}; - } -} else { - // No-op hook for non-TV platforms - useTVEventHandler = () => {}; -} +import { useTVBackPress } from "@/hooks/useTVBackPress"; +import { useTVEventHandler } from "@/hooks/useTVEventHandler"; interface UseRemoteControlProps { - progress: SharedValue; - min: SharedValue; - max: SharedValue; showControls: boolean; - isPlaying: boolean; - seek: (value: number) => void; - play: () => void; - togglePlay: () => void; toggleControls: () => void; - calculateTrickplayUrl: (progressInTicks: number) => void; - handleSeekForward: (seconds: number) => void; - handleSeekBackward: (seconds: number) => void; + /** When true, disables handling D-pad events (e.g., when settings modal is open) */ + disableSeeking?: boolean; + /** Callback for back/menu button press (tvOS: menu, Android TV: back) */ + onBack?: () => void; + /** Callback to hide controls (called on back press when controls are visible) */ + onHideControls?: () => void; + /** Title of the video being played (shown in exit confirmation) */ + videoTitle?: string; + /** Whether the progress bar currently has focus */ + isProgressBarFocused?: boolean; + /** Callback for seeking left when progress bar is focused */ + onSeekLeft?: () => void; + /** Callback for seeking right when progress bar is focused */ + onSeekRight?: () => void; + /** Callback for seeking left when controls are hidden (minimal seek mode) */ + onMinimalSeekLeft?: () => void; + /** Callback for seeking right when controls are hidden (minimal seek mode) */ + onMinimalSeekRight?: () => void; + /** Callback for any interaction that should reset the controls timeout */ + onInteraction?: () => void; + /** Callback when long press seek left starts (eventKeyAction: 0) */ + onLongSeekLeftStart?: () => void; + /** Callback when long press seek right starts (eventKeyAction: 0) */ + onLongSeekRightStart?: () => void; + /** Callback when long press seek ends (eventKeyAction: 1) */ + onLongSeekStop?: () => void; + /** Callback when up/down D-pad pressed (to show controls with play button focused) */ + onVerticalDpad?: () => void; + /** Called before the exit confirmation Alert is shown (e.g., to pause countdown) */ + onWillExit?: () => void; + /** Called when the user cancels the exit confirmation Alert */ + onCancelExit?: () => void; + // Legacy props - kept for backwards compatibility with mobile Controls.tsx + // These are ignored in the simplified implementation + progress?: SharedValue; + min?: SharedValue; + max?: SharedValue; + isPlaying?: boolean; + seek?: (value: number) => void; + play?: () => void; + togglePlay?: () => void; + calculateTrickplayUrl?: (progressInTicks: number) => void; + handleSeekForward?: (seconds: number) => void; + handleSeekBackward?: (seconds: number) => void; } /** * Hook to manage TV remote control interactions. - * MPV player uses milliseconds for time values. + * Simplified version - D-pad navigation is handled by native focus system. + * This hook handles: + * - Showing controls on any button press + * - Play/pause button on TV remote */ export function useRemoteControl({ - progress, - min, - max, showControls, - isPlaying, - seek, - play, togglePlay, - toggleControls, - calculateTrickplayUrl, - handleSeekForward, - handleSeekBackward, + onBack, + onHideControls, + videoTitle, + isProgressBarFocused, + onSeekLeft, + onSeekRight, + onMinimalSeekLeft, + onMinimalSeekRight, + onInteraction, + onLongSeekLeftStart, + onLongSeekRightStart, + onLongSeekStop, + onVerticalDpad, + onWillExit, + onCancelExit, }: UseRemoteControlProps) { + // Keep these for backward compatibility with the component const remoteScrubProgress = useSharedValue(null); const isRemoteScrubbing = useSharedValue(false); - const [showRemoteBubble, setShowRemoteBubble] = useState(false); - const [longPressScrubMode, setLongPressScrubMode] = useState< - "FF" | "RW" | null - >(null); - const [isSliding, setIsSliding] = useState(false); - const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 }); + const [showRemoteBubble] = useState(false); + const [isSliding] = useState(false); + const [time] = useState({ hours: 0, minutes: 0, seconds: 0 }); - const longPressTimeoutRef = useRef | null>( - null, - ); - // MPV uses ms - const SCRUB_INTERVAL = CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS; + // Use refs to avoid stale closures in BackHandler + const showControlsRef = useRef(showControls); + const onHideControlsRef = useRef(onHideControls); + const onBackRef = useRef(onBack); + const videoTitleRef = useRef(videoTitle); + const onWillExitRef = useRef(onWillExit); + const onCancelExitRef = useRef(onCancelExit); - const updateTime = useCallback((progressValue: number) => { - // Convert ms to ticks for calculation - const progressInTicks = msToTicks(progressValue); - const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks)); - const hours = Math.floor(progressInSeconds / 3600); - const minutes = Math.floor((progressInSeconds % 3600) / 60); - const seconds = progressInSeconds % 60; - setTime({ hours, minutes, seconds }); + useEffect(() => { + showControlsRef.current = showControls; + onHideControlsRef.current = onHideControls; + onBackRef.current = onBack; + videoTitleRef.current = videoTitle; + onWillExitRef.current = onWillExit; + onCancelExitRef.current = onCancelExit; + }, [ + showControls, + onHideControls, + onBack, + videoTitle, + onWillExit, + onCancelExit, + ]); + + // BackHandler owns player exit: Android TV sends hardware back here, and + // react-native-tvos maps the Apple TV menu button to the same API. + useTVBackPress(() => { + if (showControlsRef.current && onHideControlsRef.current) { + // Controls are visible, so the first back press only hides them. + onHideControlsRef.current(); + return true; + } + if (onBackRef.current) { + // Signal Controls that exit is imminent (pauses countdown, sets guard) + onWillExitRef.current?.(); + + // Controls are hidden, so confirm before leaving playback. + Alert.alert( + "Stop Playback", + videoTitleRef.current + ? `Stop playing "${videoTitleRef.current}"?` + : "Are you sure you want to stop playback?", + [ + { + text: "Cancel", + style: "cancel", + onPress: () => onCancelExitRef.current?.(), + }, + { text: "Stop", style: "destructive", onPress: onBackRef.current }, + ], + ); + return true; + } + return false; }, []); // TV remote control handling (no-op on non-TV platforms) useTVEventHandler((evt) => { if (!evt) return; - switch (evt.eventType) { - case "longLeft": { - setLongPressScrubMode((prev) => (!prev ? "RW" : null)); - break; - } - case "longRight": { - setLongPressScrubMode((prev) => (!prev ? "FF" : null)); - break; - } - case "left": - case "right": { - isRemoteScrubbing.value = true; - setShowRemoteBubble(true); - - const direction = evt.eventType === "left" ? -1 : 1; - const base = remoteScrubProgress.value ?? progress.value; - const updated = Math.max( - min.value, - Math.min(max.value, base + direction * SCRUB_INTERVAL), - ); - remoteScrubProgress.value = updated; - // Convert ms to ticks for trickplay - const progressInTicks = msToTicks(updated); - calculateTrickplayUrl(progressInTicks); - updateTime(updated); - break; - } - case "select": { - if (isRemoteScrubbing.value && remoteScrubProgress.value != null) { - progress.value = remoteScrubProgress.value; - - // MPV uses ms, seek expects ms - const seekTarget = Math.max(0, remoteScrubProgress.value); - - seek(seekTarget); - if (isPlaying) play(); - - isRemoteScrubbing.value = false; - remoteScrubProgress.value = null; - setShowRemoteBubble(false); - } else { - togglePlay(); - } - break; - } - case "down": - case "up": - // cancel scrubbing on other directions - isRemoteScrubbing.value = false; - remoteScrubProgress.value = null; - setShowRemoteBubble(false); - break; - default: - break; + // Back/menu is handled by useTVBackPress above. Keep this handler focused + // on remote-control events like play/pause, D-pad, and long seek. + if (evt.eventType === "menu") { + return; } - if (!showControls) toggleControls(); + // Handle play/pause button press on TV remote + if (evt.eventType === "playPause") { + togglePlay?.(); + onInteraction?.(); + return; + } + + // Handle long press D-pad for continuous seeking (works in both modes) + // Must be checked BEFORE the showControls check to work when controls are hidden + if (evt.eventType === "longLeft") { + if (evt.eventKeyAction === 0 && onLongSeekLeftStart) { + // Key pressed - start continuous seeking backward + onLongSeekLeftStart(); + } else if (evt.eventKeyAction === 1 && onLongSeekStop) { + // Key released - stop seeking + onLongSeekStop(); + } + return; + } + + if (evt.eventType === "longRight") { + if (evt.eventKeyAction === 0 && onLongSeekRightStart) { + // Key pressed - start continuous seeking forward + onLongSeekRightStart(); + } else if (evt.eventKeyAction === 1 && onLongSeekStop) { + // Key released - stop seeking + onLongSeekStop(); + } + return; + } + + // Handle D-pad when controls are hidden + if (!showControls) { + // Ignore select/enter events - let the native Pressable handle them + // This prevents controls from showing when pressing buttons like skip intro + if (evt.eventType === "select" || evt.eventType === "enter") { + return; + } + // Minimal seek mode for left/right + if (evt.eventType === "left" && onMinimalSeekLeft) { + onMinimalSeekLeft(); + return; + } + if (evt.eventType === "right" && onMinimalSeekRight) { + onMinimalSeekRight(); + return; + } + // Up/down shows controls with play button focused + if ( + (evt.eventType === "up" || evt.eventType === "down") && + onVerticalDpad + ) { + onVerticalDpad(); + return; + } + // Ignore all other events (focus/blur, swipes, etc.) + // User can press up/down to show controls + return; + } + + // Controls are showing - handle seeking when progress bar is focused + if (isProgressBarFocused) { + if (evt.eventType === "left" && onSeekLeft) { + onSeekLeft(); + return; + } + if (evt.eventType === "right" && onSeekRight) { + onSeekRight(); + return; + } + } + + // Reset the timeout on any D-pad navigation when controls are showing + onInteraction?.(); }); - useEffect(() => { - let isActive = true; - let seekTime = CONTROLS_CONSTANTS.LONG_PRESS_INITIAL_SEEK; - - const scrubWithLongPress = () => { - if (!isActive || !longPressScrubMode) return; - - setIsSliding(true); - const scrubFn = - longPressScrubMode === "FF" ? handleSeekForward : handleSeekBackward; - scrubFn(seekTime); - seekTime *= CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION; - - longPressTimeoutRef.current = setTimeout( - scrubWithLongPress, - CONTROLS_CONSTANTS.LONG_PRESS_INTERVAL, - ); - }; - - if (longPressScrubMode) { - isActive = true; - scrubWithLongPress(); - } - - return () => { - isActive = false; - setIsSliding(false); - if (longPressTimeoutRef.current) { - clearTimeout(longPressTimeoutRef.current); - longPressTimeoutRef.current = null; - } - }; - }, [longPressScrubMode, handleSeekForward, handleSeekBackward]); - return { remoteScrubProgress, isRemoteScrubbing, showRemoteBubble, - longPressScrubMode, isSliding, time, }; diff --git a/components/video-player/controls/hooks/useVideoSlider.ts b/components/video-player/controls/hooks/useVideoSlider.ts index dfc1164bb..3c19ce7ad 100644 --- a/components/video-player/controls/hooks/useVideoSlider.ts +++ b/components/video-player/controls/hooks/useVideoSlider.ts @@ -74,6 +74,21 @@ export function useVideoSlider({ [seek, play, progress, isSeeking], ); + // Programmatic seek (chapter list, hotkeys) that bypasses the slide gesture. + // Reads `isPlaying` directly instead of `wasPlayingRef`, which is only set + // during a real slide and would carry stale state on a tap-to-seek. + const seekTo = useCallback( + (value: number) => { + const seekValue = Math.max(0, Math.floor(value)); + progress.value = seekValue; + seek(seekValue); + if (isPlaying) { + play(); + } + }, + [seek, play, progress, isPlaying], + ); + const handleSliderChange = useCallback( debounce((value: number) => { // Convert ms to ticks for trickplay @@ -96,5 +111,6 @@ export function useVideoSlider({ handleTouchEnd, handleSliderComplete, handleSliderChange, + seekTo, }; } diff --git a/components/video-player/controls/types.ts b/components/video-player/controls/types.ts index 5ec03eddb..ca2ea1413 100644 --- a/components/video-player/controls/types.ts +++ b/components/video-player/controls/types.ts @@ -22,6 +22,10 @@ type Track = { index: number; mpvIndex?: number; setTrack: () => void; + /** True for client-side downloaded subtitles (e.g., from OpenSubtitles) */ + isLocal?: boolean; + /** File path for local subtitles */ + localPath?: string; }; -export type { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track }; +export type { EmbeddedSubtitle, ExternalSubtitle, Track, TranscodedSubtitle }; diff --git a/constants/TVPosterSizes.ts b/constants/TVPosterSizes.ts new file mode 100644 index 000000000..132b75a66 --- /dev/null +++ b/constants/TVPosterSizes.ts @@ -0,0 +1,12 @@ +/** + * @deprecated Import from "@/constants/TVSizes" instead. + * This file is kept for backwards compatibility. + */ + +export { + type ScaledTVPosterSizes, + TVPosterSizes, + useScaledTVPosterSizes, +} from "./TVSizes"; + +export type TVPosterSizeKey = keyof typeof import("./TVSizes").TVPosterSizes; diff --git a/constants/TVSizes.ts b/constants/TVSizes.ts new file mode 100644 index 000000000..117609021 --- /dev/null +++ b/constants/TVSizes.ts @@ -0,0 +1,179 @@ +import { TVTypographyScale, useSettings } from "@/utils/atoms/settings"; +import { scaleSize } from "@/utils/scaleSize"; + +/** + * TV Layout Sizes + * + * Unified constants for TV interface layout including posters, gaps, and padding. + * Base values are designed for 1920x1080 and scaled to the actual viewport via + * scaleSize(), then further adjusted by the user's tvTypographyScale setting. + */ + +// ============================================================================= +// BASE VALUES (at Default scale) +// ============================================================================= + +/** + * Base poster widths in pixels. + * Heights are calculated from aspect ratios. + */ +export const TVPosterSizes = { + /** Portrait posters (movies, series) - 10:15 aspect ratio */ + poster: 300, + + /** Landscape posters (continue watching, thumbs, hero) - 16:9 aspect ratio */ + landscape: 470, + + /** Episode cards - 16:9 aspect ratio */ + episode: 440, +} as const; + +/** + * Base gap/spacing values in pixels. + */ +export const TVGaps = { + /** Gap between items in horizontal lists */ + item: 24, + + /** Gap between sections vertically */ + section: 32, + + /** Small gap for tight layouts */ + small: 12, + + /** Large gap for spacious layouts */ + large: 48, +} as const; + +/** + * Base padding values in pixels. + */ +export const TVPadding = { + /** Horizontal padding from screen edges (static — matches native search inset) */ + horizontal: 80, + + /** Padding to accommodate scale animations (1.05x) */ + scale: 20, + + /** Vertical padding for content areas */ + vertical: 24, + + /** Hero section height as percentage of screen height (0.0 - 1.0) */ + heroHeight: 0.6, +} as const; + +/** + * Animation and interaction values. + */ +export const TVAnimation = { + /** Scale factor for focused items */ + focusScale: 1.05, +} as const; + +// ============================================================================= +// SCALING +// ============================================================================= + +/** + * Scale multipliers for each typography scale level. + * Applied to poster sizes and gaps. + */ +const sizeScaleMultipliers: Record = { + [TVTypographyScale.Small]: 0.53, + [TVTypographyScale.Default]: 0.63, + [TVTypographyScale.Large]: 0.77, + [TVTypographyScale.ExtraLarge]: 0.84, +}; + +// ============================================================================= +// HOOKS +// ============================================================================= + +export type ScaledTVPosterSizes = { + poster: number; + landscape: number; + episode: number; +}; + +export type ScaledTVGaps = { + item: number; + section: number; + small: number; + large: number; +}; + +export type ScaledTVPadding = { + horizontal: number; + scale: number; + vertical: number; + heroHeight: number; +}; + +export type ScaledTVSizes = { + posters: ScaledTVPosterSizes; + gaps: ScaledTVGaps; + padding: ScaledTVPadding; + animation: typeof TVAnimation; +}; + +/** + * Hook that returns all scaled TV sizes based on user settings. + * + * @example + * const sizes = useScaledTVSizes(); + * + */ +export const useScaledTVSizes = (): ScaledTVSizes => { + const { settings } = useSettings(); + const scale = + sizeScaleMultipliers[settings.tvTypographyScale] ?? + sizeScaleMultipliers[TVTypographyScale.Default]; + + return { + posters: { + poster: Math.round(scaleSize(TVPosterSizes.poster) * scale), + landscape: Math.round(scaleSize(TVPosterSizes.landscape) * scale), + episode: Math.round(scaleSize(TVPosterSizes.episode) * scale), + }, + gaps: { + item: Math.round(scaleSize(TVGaps.item) * scale), + section: Math.round(scaleSize(TVGaps.section) * scale), + small: Math.round(scaleSize(TVGaps.small) * scale), + large: Math.round(scaleSize(TVGaps.large) * scale), + }, + padding: { + // Static: matches the native tvOS search bar inset, which is a fixed + // point value and does not change with the typography scale setting. + horizontal: TVPadding.horizontal, + scale: Math.round(scaleSize(TVPadding.scale) * scale), + vertical: Math.round(scaleSize(TVPadding.vertical) * scale), + heroHeight: TVPadding.heroHeight * scale, + }, + animation: TVAnimation, + }; +}; + +/** + * Hook that returns only scaled poster sizes. + * Use this for backwards compatibility or when you only need poster sizes. + */ +export const useScaledTVPosterSizes = (): ScaledTVPosterSizes => { + const sizes = useScaledTVSizes(); + return sizes.posters; +}; + +/** + * Hook that returns only scaled gap sizes. + */ +export const useScaledTVGaps = (): ScaledTVGaps => { + const sizes = useScaledTVSizes(); + return sizes.gaps; +}; + +/** + * Hook that returns only scaled padding sizes. + */ +export const useScaledTVPadding = (): ScaledTVPadding => { + const sizes = useScaledTVSizes(); + return sizes.padding; +}; diff --git a/constants/TVTypography.ts b/constants/TVTypography.ts new file mode 100644 index 000000000..cbac9b693 --- /dev/null +++ b/constants/TVTypography.ts @@ -0,0 +1,75 @@ +import { TVTypographyScale, useSettings } from "@/utils/atoms/settings"; +import { scaleSize } from "@/utils/scaleSize"; + +/** + * TV Typography Scale + * + * Consistent text sizes for TV interface components. + * Base values are designed for 1920x1080 and scaled to the actual viewport via + * scaleSize(), then further adjusted by the user's tvTypographyScale setting. + */ + +// ============================================================================= +// BASE VALUES (at Default scale) +// ============================================================================= + +export const TVTypography = { + /** Hero titles, movie/show names */ + display: 70, + + /** Episode series name, major headings */ + title: 42, + + /** Section headers (Cast, Technical Details, From this Series) */ + heading: 32, + + /** Overview, actor names, card titles, metadata */ + body: 40, + + /** Secondary text, labels, subtitles */ + callout: 26, +}; + +export type TVTypographyKey = keyof typeof TVTypography; + +// ============================================================================= +// SCALING +// ============================================================================= + +const scaleMultipliers: Record = { + [TVTypographyScale.Small]: 0.6, + [TVTypographyScale.Default]: 0.7, + [TVTypographyScale.Large]: 0.84, + [TVTypographyScale.ExtraLarge]: 0.98, +}; + +// ============================================================================= +// HOOKS +// ============================================================================= + +export type ScaledTVTypography = { + display: number; + title: number; + heading: number; + body: number; + callout: number; +}; + +/** + * Hook that returns scaled TV typography values based on user settings. + * Use this instead of the static TVTypography constant for dynamic scaling. + */ +export const useScaledTVTypography = (): ScaledTVTypography => { + const { settings } = useSettings(); + const scale = + scaleMultipliers[settings.tvTypographyScale] ?? + scaleMultipliers[TVTypographyScale.Default]; + + return { + display: Math.round(scaleSize(TVTypography.display) * scale), + title: Math.round(scaleSize(TVTypography.title) * scale), + heading: Math.round(scaleSize(TVTypography.heading) * scale), + body: Math.round(scaleSize(TVTypography.body) * scale), + callout: Math.round(scaleSize(TVTypography.callout) * scale), + }; +}; diff --git a/docs/research/hdr-mpv.md b/docs/research/hdr-mpv.md new file mode 100644 index 000000000..6061e026d --- /dev/null +++ b/docs/research/hdr-mpv.md @@ -0,0 +1,436 @@ +# HDR Support on tvOS with mpv - Research Document + +## Problem Statement + +HDR content appears washed out on Apple TV when using the mpv-based player. The TV doesn't show an HDR indicator and colors look flat compared to other apps like Infuse. + +**Key Discovery:** HDR works correctly on iPhone but not on tvOS, despite using the same mpv player. + +--- + +## Why HDR Works on iPhone + +In `MpvPlayerView.swift`: +```swift +#if !os(tvOS) +if #available(iOS 17.0, *) { + displayLayer.wantsExtendedDynamicRangeContent = true +} +#endif +``` + +On iOS 17+, setting `wantsExtendedDynamicRangeContent = true` on `AVSampleBufferDisplayLayer` enables Extended Dynamic Range (EDR). This tells the display layer to preserve HDR metadata and render in high dynamic range. + +**This API does not exist on tvOS.** Attempting to use it results in: +> 'wantsExtendedDynamicRangeContent' is unavailable in tvOS + +tvOS uses a different HDR architecture designed for external displays via HDMI. + +--- + +## tvOS HDR Architecture + +Unlike iPhone (integrated display), Apple TV connects to external TVs. Apple expects apps to: + +1. **Use `AVDisplayCriteria`** to request display mode changes +2. **Attach proper colorspace metadata** to pixel buffers +3. **Let the TV handle HDR rendering** via HDMI passthrough + +This is how Netflix, Infuse, and the TV app work - they signal "I'm playing HDR10 at 24fps" and tvOS switches the TV to that mode. + +--- + +## MPVKit vo_avfoundation Analysis + +**Location:** `/MPVKit/Sources/BuildScripts/patch/libmpv/0004-avfoundation-video-output.patch` + +### Existing HDR Infrastructure + +The driver has comprehensive HDR support already built in: + +#### 1. HDR Metadata Copy Function (lines 253-270) +```c +static void copy_hdr_metadata(CVPixelBufferRef src, CVPixelBufferRef dst) +{ + const CFStringRef keys[] = { + kCVImageBufferTransferFunctionKey, // PQ for HDR10, HLG for HLG + kCVImageBufferColorPrimariesKey, // BT.2020 for HDR + kCVImageBufferYCbCrMatrixKey, + kCVImageBufferMasteringDisplayColorVolumeKey, // HDR10 static metadata + kCVImageBufferContentLightLevelInfoKey, // MaxCLL, MaxFALL + }; + + for (size_t i = 0; i < MP_ARRAY_SIZE(keys); i++) { + CFTypeRef value = CVBufferGetAttachment(src, keys[i], NULL); + if (value) { + CVBufferSetAttachment(dst, keys[i], value, kCVAttachmentMode_ShouldPropagate); + } + } +} +``` + +#### 2. 10-bit HDR Format Support (lines 232-247) +```c +// For 10-bit HDR content (P010), use RGBA half-float to preserve HDR precision +if (format == kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange || + format == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange) { + outputFormat = kCVPixelFormatType_64RGBAHalf; +} +``` + +#### 3. HDR-Safe GPU Compositing (lines 694-695) +```c +CGColorSpaceRef workingColorSpace = CGColorSpaceCreateWithName( + kCGColorSpaceExtendedLinearDisplayP3); +``` + +### The Problem: Metadata Not Attached in Main Code Path + +**Critical Finding:** `copy_hdr_metadata()` is only called during OSD compositing (line 609-610): + +```c +// In composite mode, render OSD and composite onto frame +if (p->composite_osd) { + render_osd(vo, pts); + CVPixelBufferRef composited = composite_frame(vo, pixbuf); + // copy_hdr_metadata() called inside composite_frame() +} +``` + +If `composite_osd` is false (default), **HDR metadata is never attached**. + +### Frame Flow Analysis + +``` +draw_frame() called + │ + ├─► Hardware decoded (IMGFMT_VIDEOTOOLBOX) + │ └─► pixbuf = mpi->planes[3] // Direct from VideoToolbox + │ └─► Metadata SHOULD be attached by decoder, but not verified + │ + └─► Software decoded (NV12, 420P, P010) + └─► upload_software_frame() + └─► Creates new CVPixelBuffer + └─► Copies pixel data only + └─► ❌ NO colorspace metadata attached! + + ▼ + CMVideoFormatDescriptionCreateForImageBuffer(finalBuffer) + └─► Format description created FROM pixel buffer + └─► If buffer lacks HDR metadata, format won't have it + + ▼ + [displayLayer enqueueSampleBuffer:buf] + └─► Sent to display layer without HDR signal +``` + +--- + +## Root Cause Summary + +| Issue | Impact | +|-------|--------| +| `wantsExtendedDynamicRangeContent` unavailable on tvOS | Can't use iOS EDR approach | +| `copy_hdr_metadata()` only runs during OSD compositing | Main playback path skips HDR metadata | +| Software decoded frames get no colorspace attachments | mpv knows colorspace but doesn't pass it to pixel buffer | +| VideoToolbox metadata not verified | May or may not have HDR attachments | + +--- + +## mp_image Colorspace Structures + +mpv uses libplacebo's colorspace structures. Here's how colorspace info flows: + +### Structure Hierarchy + +``` +mp_image (video/mp_image.h) + └─► params: mp_image_params + └─► color: pl_color_space + │ ├─► primaries: pl_color_primaries (BT.2020, etc.) + │ ├─► transfer: pl_color_transfer (PQ, HLG, etc.) + │ └─► hdr: pl_hdr_metadata (MaxCLL, MaxFALL, etc.) + └─► repr: pl_color_repr + ├─► sys: pl_color_system (BT.2100_PQ, etc.) + └─► levels: pl_color_levels (TV/Full range) +``` + +### Key Enums + +#### Color Primaries (`enum pl_color_primaries`) +```c +PL_COLOR_PRIM_UNKNOWN = 0, +PL_COLOR_PRIM_BT_709, // HD/SDR standard +PL_COLOR_PRIM_BT_2020, // UHD/HDR wide gamut ← HDR +PL_COLOR_PRIM_DCI_P3, // DCI P3 (cinema) +PL_COLOR_PRIM_DISPLAY_P3, // Display P3 (Apple) +// ... more +``` + +#### Transfer Functions (`enum pl_color_transfer`) +```c +PL_COLOR_TRC_UNKNOWN = 0, +PL_COLOR_TRC_BT_1886, // SDR gamma +PL_COLOR_TRC_SRGB, // sRGB +PL_COLOR_TRC_PQ, // SMPTE 2084 PQ (HDR10/DolbyVision) ← HDR +PL_COLOR_TRC_HLG, // ITU-R BT.2100 HLG ← HDR +// ... more +``` + +#### Color Systems (`enum pl_color_system`) +```c +PL_COLOR_SYSTEM_BT_709, // HD/SDR +PL_COLOR_SYSTEM_BT_2020_NC, // UHD (non-constant luminance) +PL_COLOR_SYSTEM_BT_2100_PQ, // HDR10 ← HDR +PL_COLOR_SYSTEM_BT_2100_HLG, // HLG ← HDR +PL_COLOR_SYSTEM_DOLBYVISION, // Dolby Vision ← HDR +// ... more +``` + +### HDR Metadata Structure (`struct pl_hdr_metadata`) +```c +struct pl_hdr_metadata { + struct pl_raw_primaries prim; // CIE xy primaries + float min_luma, max_luma; // Luminance range (cd/m²) + float max_cll; // Maximum Content Light Level + float max_fall; // Maximum Frame-Average Light Level + // ... more +}; +``` + +### Accessing Colorspace in vo_avfoundation + +```c +// In draw_frame(): +struct mp_image *mpi = frame->current; + +// Color primaries +enum pl_color_primaries prim = mpi->params.color.primaries; + +// Transfer function +enum pl_color_transfer trc = mpi->params.color.transfer; + +// HDR metadata +struct pl_hdr_metadata hdr = mpi->params.color.hdr; + +// HDR detection +bool is_hdr = (trc == PL_COLOR_TRC_PQ || trc == PL_COLOR_TRC_HLG); +bool is_wide_gamut = (prim == PL_COLOR_PRIM_BT_2020); +``` + +--- + +## The Fix + +### Required Changes in vo_avfoundation + +**File:** `MPVKit/Sources/BuildScripts/patch/libmpv/0004-avfoundation-video-output.patch` + +**Location:** After line 821 in `draw_frame()`, before the sample buffer is created. + +#### Add Required Include +```c +#include "video/csputils.h" // For pl_color_* enums (if not already included) +``` + +#### Add HDR Metadata Attachment Function +```c +// Add after copy_hdr_metadata() function (around line 270) +static void attach_hdr_metadata(struct vo *vo, CVPixelBufferRef pixbuf, + struct mp_image *mpi) +{ + enum pl_color_primaries prim = mpi->params.color.primaries; + enum pl_color_transfer trc = mpi->params.color.transfer; + + // Attach BT.2020 color primaries (HDR wide color gamut) + if (prim == PL_COLOR_PRIM_BT_2020) { + CVBufferSetAttachment(pixbuf, kCVImageBufferColorPrimariesKey, + kCVImageBufferColorPrimaries_ITU_R_2020, + kCVAttachmentMode_ShouldPropagate); + CVBufferSetAttachment(pixbuf, kCVImageBufferYCbCrMatrixKey, + kCVImageBufferYCbCrMatrix_ITU_R_2020, + kCVAttachmentMode_ShouldPropagate); + + MP_VERBOSE(vo, "HDR: Attached BT.2020 color primaries\n"); + } + + // Attach PQ transfer function (HDR10/Dolby Vision) + if (trc == PL_COLOR_TRC_PQ) { + CVBufferSetAttachment(pixbuf, kCVImageBufferTransferFunctionKey, + kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ, + kCVAttachmentMode_ShouldPropagate); + + MP_VERBOSE(vo, "HDR: Attached PQ transfer function (HDR10)\n"); + } + // Attach HLG transfer function + else if (trc == PL_COLOR_TRC_HLG) { + CVBufferSetAttachment(pixbuf, kCVImageBufferTransferFunctionKey, + kCVImageBufferTransferFunction_ITU_R_2100_HLG, + kCVAttachmentMode_ShouldPropagate); + + MP_VERBOSE(vo, "HDR: Attached HLG transfer function\n"); + } + + // Attach HDR static metadata if available + struct pl_hdr_metadata hdr = mpi->params.color.hdr; + if (hdr.max_cll > 0 || hdr.max_fall > 0) { + // ContentLightLevelInfo is a 4-byte structure: + // - 2 bytes: MaxCLL (max content light level) + // - 2 bytes: MaxFALL (max frame-average light level) + uint16_t cll_data[2] = { + (uint16_t)fminf(hdr.max_cll, 65535.0f), + (uint16_t)fminf(hdr.max_fall, 65535.0f) + }; + + CFDataRef cllInfo = CFDataCreate(NULL, (const UInt8 *)cll_data, sizeof(cll_data)); + if (cllInfo) { + CVBufferSetAttachment(pixbuf, kCVImageBufferContentLightLevelInfoKey, + cllInfo, kCVAttachmentMode_ShouldPropagate); + CFRelease(cllInfo); + + MP_VERBOSE(vo, "HDR: Attached CLL metadata (MaxCLL=%d, MaxFALL=%d)\n", + cll_data[0], cll_data[1]); + } + } +} +``` + +#### Call the Function in draw_frame() + +```c +// In draw_frame(), after line 821 (after getting pixbuf), add: + +// Attach HDR colorspace metadata to pixel buffer +// This ensures the display layer receives proper HDR signaling +attach_hdr_metadata(vo, pixbuf, mpi); +``` + +### Complete draw_frame() Modification + +The modified section should look like: + +```c +CVPixelBufferRef pixbuf = NULL; +bool pixbufNeedsRelease = false; + +// Handle different input formats +if (mpi->imgfmt == IMGFMT_VIDEOTOOLBOX) { + // Hardware decoded: zero-copy passthrough + pixbuf = (CVPixelBufferRef)mpi->planes[3]; +} else { + // Software decoded: upload to CVPixelBuffer + pixbuf = upload_software_frame(vo, mpi); + if (!pixbuf) { + MP_ERR(vo, "Failed to upload software frame\n"); + mp_image_unrefp(&mpi); + return false; + } + pixbufNeedsRelease = true; +} + +// >>> NEW: Attach HDR colorspace metadata <<< +attach_hdr_metadata(vo, pixbuf, mpi); + +CVPixelBufferRef finalBuffer = pixbuf; +bool needsRelease = false; +// ... rest of the function +``` + +--- + +## Alternative Solutions + +### Option A: Enable composite_osd Mode (Quick Test) + +Since `copy_hdr_metadata()` works in composite mode, try enabling it: +``` +--avfoundation-composite-osd=yes +``` + +This would trigger the existing HDR metadata path. Downside: OSD compositing has performance overhead. + +### Option B: Full vo_avfoundation Fix (Recommended) + +Modify the driver to always attach colorspace metadata based on `mp_image` params. This is the implementation described above. + +### Option C: Dual Player Approach + +Use AVPlayer for HDR content, mpv for everything else. This is what Swiftfin does. + +--- + +## Implementation Checklist + +- [ ] Clone MPVKit fork +- [ ] Modify `0004-avfoundation-video-output.patch`: + - [ ] Add `attach_hdr_metadata()` function + - [ ] Call it in `draw_frame()` after getting pixbuf + - [ ] Add necessary includes if needed +- [ ] Rebuild MPVKit +- [ ] Test with HDR10 content on tvOS +- [ ] Verify TV shows HDR indicator +- [ ] Test with HLG content +- [ ] Test with Dolby Vision content (may need additional work) + +--- + +## Current Implementation Status + +**What's implemented in Streamyfin:** + +1. **HDR Detection** (`MPVLayerRenderer.swift`) + - Reads `video-params/primaries` and `video-params/gamma` from mpv + - Detects HDR10 (bt.2020 + pq), HLG, Dolby Vision + +2. **AVDisplayCriteria** (`MpvPlayerView.swift`) + - Sets `preferredDisplayCriteria` on tvOS 17.0+ when HDR detected + - Creates CMFormatDescription with HDR color extensions + +3. **target-colorspace-hint** (`MPVLayerRenderer.swift`) + - Added `target-colorspace-hint=yes` for tvOS + +**What's NOT working:** +- TV doesn't show HDR indicator +- Colors appear washed out +- The pixel buffers lack HDR metadata attachments ← **This is what the fix addresses** + +--- + +## Industry Context + +| Project | Player | HDR Status | +|---------|--------|------------| +| [Swiftfin](https://github.com/jellyfin/Swiftfin/issues/811) | VLCKit | Washed out, uses AVPlayer for HDR | +| [Plex](https://freetime.mikeconnelly.com/archives/8360) | mpv | No HDR support | +| Infuse | Custom Metal engine | Works correctly | + +**Key insight:** No mpv-based player has solved HDR on tvOS. This fix could be a first. + +--- + +## Technical References + +### Apple Documentation +- [AVDisplayManager](https://developer.apple.com/documentation/avkit/avdisplaymanager) +- [AVDisplayCriteria](https://developer.apple.com/documentation/avkit/avdisplaycriteria) +- [WWDC22: Display HDR video in EDR](https://developer.apple.com/videos/play/wwdc2022/110565/) + +### CVImageBuffer Keys +- `kCVImageBufferColorPrimariesKey` - Color gamut (BT.709, BT.2020, P3) +- `kCVImageBufferTransferFunctionKey` - Transfer function (sRGB, PQ, HLG) +- `kCVImageBufferYCbCrMatrixKey` - YCbCr conversion matrix +- `kCVImageBufferMasteringDisplayColorVolumeKey` - Mastering display metadata +- `kCVImageBufferContentLightLevelInfoKey` - MaxCLL/MaxFALL + +### mpv/libplacebo Source +- mp_image struct: `video/mp_image.h` +- Colorspace enums: libplacebo `pl_color.h` +- vo_avfoundation: `MPVKit/Sources/BuildScripts/patch/libmpv/0004-avfoundation-video-output.patch` + +### Key Functions in vo_avfoundation +| Function | Line | Purpose | +|----------|------|---------| +| `draw_frame()` | 781 | Main frame rendering | +| `copy_hdr_metadata()` | 253 | Copy HDR metadata between buffers | +| `upload_software_frame()` | 295 | Upload SW frames to CVPixelBuffer | +| `composite_frame()` | 582 | OSD compositing with HDR support | diff --git a/docs/tv-discovery.md b/docs/tv-discovery.md new file mode 100644 index 000000000..b1a551656 --- /dev/null +++ b/docs/tv-discovery.md @@ -0,0 +1,136 @@ +# TV Discovery + +This document explains Streamyfin's platform-specific home screen discovery integrations for Apple TV and Android TV. + +## Overview + +Streamyfin currently publishes the same "Continue and Next Up" content to two different platform surfaces: + +- `tvOS`: Apple TV Top Shelf +- `Android TV`: preview channel recommendations + +Both integrations are fed by the same shared payload builder in [utils/tvDiscovery/payload.ts](../utils/tvDiscovery/payload.ts). + +## Shared Data Flow + +The TV home screen data starts in [components/home/Home.tv.tsx](../components/home/Home.tv.tsx), where the app fetches resume and next-up items and passes them into [utils/tvDiscovery/sync.ts](../utils/tvDiscovery/sync.ts). + +The sync layer: + +- builds a normalized TV discovery payload +- sends it to the tvOS Top Shelf cache writer on Apple TV +- sends it to the Android TV recommendations module on Android TV +- clears published content when server or user state changes + +## Apple TV Top Shelf + +Apple TV uses a Top Shelf extension target, not the main app process. + +Relevant files: + +- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js) +- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift) +- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift) +- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts) + +How it works: + +- the app builds a lightweight JSON payload +- the app stores that payload in the shared app group container +- the tvOS Top Shelf extension reads the cached payload +- the extension renders sections and items for Top Shelf + +Why the API key is stored on tvOS: + +- the Top Shelf extension runs outside the app process +- it may need authenticated image access when loading poster artwork +- the app stores the API key so the extension can build authenticated requests + +## Android TV Recommendations + +Android TV uses the TV provider APIs to publish a preview channel and preview programs. + +Relevant files: + +- [modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt](../modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt) +- [modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsModule.kt](../modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsModule.kt) +- [modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt](../modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt) +- [modules/tv-recommendations/android/src/main/AndroidManifest.xml](../modules/tv-recommendations/android/src/main/AndroidManifest.xml) +- [utils/tvDiscovery/sync.ts](../utils/tvDiscovery/sync.ts) + +How it works: + +- the app builds the shared TV discovery payload +- the Android native module creates or updates a single preview channel +- the module inserts or updates preview programs for each item +- the module stores the last payload in shared preferences +- the `INITIALIZE_PROGRAMS` receiver can replay the cached payload when requested by the system + +Important differences from tvOS: + +- Android TV does not use a separate extension target +- Android TV content is persisted through `TvContractCompat` +- artwork is currently published as poster URLs, not app-proxied local content + +## Logging + +### JavaScript logs + +Look for `TVDiscovery` in Metro or app logs. + +Examples: + +- payload prepared +- Android sync result +- clear operations + +### Native Android logs + +Use `adb logcat | grep TvRecommendations` + +Examples: + +- channel created or updated +- preview programs inserted or updated +- stale programs deleted +- cached payload replayed + +## Verifying Android TV Output + +1. Launch the TV build and let the home screen load. +2. Watch `adb logcat | grep TvRecommendations`. +3. Return to the Android TV / Google TV home screen. +4. Look for the `Continue and Next Up` row. +5. If needed, enable the Streamyfin channel in `Customize home` or `Manage channels`. + +Note: + +- some launchers delay or hide new preview channels +- some devices expose TV provider data per user/profile + +## Build Notes + +This feature does not currently require a fresh `prebuild` to work in the checked-in Android project. + +Why: + +- the Android integration is a local Expo module +- its receiver is declared in the module manifest +- Gradle merges it during normal Android TV builds + +Typical commands: + +- `bun run android:tv` +- `bun run ios:tv` + +## Current Limitations + +- Android TV artwork may fail on authenticated Jellyfin servers because the launcher fetches poster URLs outside the app +- Android TV currently publishes a preview channel only, not Watch Next +- tvOS and Android TV both use the same payload source, so section selection is shared unless explicitly split later + +## Future Improvements + +- add a local image proxy or cache for Android TV artwork +- add Watch Next support for resumable content +- add a native debug dump method for querying TV provider state from inside the app process diff --git a/docs/tv-focus-guide.md b/docs/tv-focus-guide.md new file mode 100644 index 000000000..fc49a93e8 --- /dev/null +++ b/docs/tv-focus-guide.md @@ -0,0 +1,305 @@ +# TV Focus Guide Navigation + +This document explains how to use `TVFocusGuideView` to create reliable focus navigation between non-adjacent sections on Apple TV and Android TV. + +## Platform Differences (CRITICAL) + +### tvOS vs Android TV + +**`nextFocusUp`, `nextFocusDown`, `nextFocusLeft`, `nextFocusRight` props only work on Android TV, NOT tvOS.** + +This is a [known limitation](https://github.com/react-native-tvos/react-native-tvos/issues/490). These props are documented as "only for Android" in React Native. + +```typescript +// ❌ Does NOT work on tvOS (Apple TV) + + ... + + +// ✅ Works on both tvOS and Android TV + + ... + +``` + +**For tvOS, always use `TVFocusGuideView` with the `destinations` prop.** + +## ScrollView vs FlatList for TV + +**Use ScrollView instead of FlatList for horizontal lists on TV when focus navigation is critical.** + +FlatList only renders visible items and manages its own recycling, which can interfere with focus navigation. ScrollView renders all items at once, providing more predictable focus behavior. + +```typescript +// ❌ FlatList can cause focus issues on TV + } +/> + +// ✅ ScrollView provides reliable focus navigation + + {cast.map((person, index) => ( + + ))} + +``` + +**When to use which:** +- **ScrollView**: Small to medium lists (< 20 items) where focus navigation must be reliable +- **FlatList**: Large lists where performance is more important than perfect focus navigation + +## The Problem + +tvOS uses a **geometric focus engine** that draws a ray in the navigation direction and finds the nearest focusable element. This works well for adjacent elements but fails when: + +- Sections are not geometrically aligned (e.g., left-aligned buttons above a horizontally-scrolling list) +- Lists are long and the "nearest" element is in the middle rather than the first item +- There's empty space between focusable sections + +**Symptoms:** +- Focus lands in the middle of a list instead of the first item +- Can't navigate down to a section at all +- Focus jumps to unexpected elements + +## The Solution: TVFocusGuideView with destinations + +`TVFocusGuideView` is a React Native component that creates an invisible focus region. When combined with the `destinations` prop, it redirects focus to specific elements. + +### Basic Pattern + +```typescript +import { TVFocusGuideView, View } from "react-native"; + +// 1. Track the destination element with state (NOT useRef!) +const [targetRef, setTargetRef] = useState(null); + +// 2. Place an invisible focus guide between sections +{targetRef && ( + +)} + +// 3. Pass the state setter as a callback ref to the target + +``` + +### Why useState Instead of useRef? + +The focus guide only updates when it receives a prop change. Using `useRef` won't trigger re-renders when the ref is set, so the focus guide won't know about the destination. **Always use `useState`** to track refs for focus guides. + +```typescript +// ❌ Won't work - useRef doesn't trigger re-renders +const targetRef = useRef(null); + + +// ✅ Works - useState triggers re-render when ref is set +const [targetRef, setTargetRef] = useState(null); + +``` + +## Bidirectional Navigation (CRITICAL PATTERN) + +When you need focus to navigate both UP and DOWN between sections, you must stack both focus guides together AND avoid `hasTVPreferredFocus` on the destination element. + +### The Focus Flickering Problem + +If you use `hasTVPreferredFocus={true}` on an element that is ALSO the destination of a focus guide, you will get **focus flickering** where focus rapidly jumps back and forth between elements. + +```typescript +// ❌ CAUSES FOCUS FLICKERING - destination has hasTVPreferredFocus + + + {items.map((item, index) => ( + + ))} + + +// ✅ CORRECT - destination does NOT have hasTVPreferredFocus + + + {items.map((item, index) => ( + + ))} + +``` + +### Complete Bidirectional Example + +```typescript +const MyScreen: React.FC = () => { + // Track refs for focus navigation + const [playButtonRef, setPlayButtonRef] = useState(null); + const [firstCastCardRef, setFirstCastCardRef] = useState(null); + + return ( + + {/* Action buttons section */} + + + Play + + + + {/* Cast section */} + + Cast + + {/* BOTH focus guides stacked together, above the list */} + {/* Downward: Play button → first cast card */} + {firstCastCardRef && ( + + )} + {/* Upward: cast → Play button */} + {playButtonRef && ( + + )} + + {/* Use ScrollView, not FlatList, for reliable focus */} + + {cast.map((person, index) => ( + + ))} + + + + ); +}; +``` + +### Key Rules for Bidirectional Navigation + +1. **Stack both focus guides together** - Place them adjacent to each other, above the destination list +2. **Do NOT use `hasTVPreferredFocus` on focus guide destinations** - This causes focus flickering +3. **Use ScrollView instead of FlatList** - More reliable focus behavior +4. **Use `useState` for refs, not `useRef`** - Triggers re-renders when refs are set + +## Focus Guide Placement + +The focus guides should be placed **together** above the destination section: + +``` +┌─────────────────────────┐ +│ Action Buttons │ ← Source (going down) +│ [Play] [Request] │ Has hasTVPreferredFocus ✓ +└─────────────────────────┘ + ↓ +┌─────────────────────────┐ +│ TVFocusGuideView │ ← Downward guide +│ destinations=[card1] │ +├─────────────────────────┤ +│ TVFocusGuideView │ ← Upward guide +│ destinations=[playBtn] │ (stacked together) +└─────────────────────────┘ + ↓ +┌─────────────────────────┐ +│ Cast Cards (ScrollView)│ ← First card is destination +│ [👤] [👤] [👤] [👤] │ NO hasTVPreferredFocus ✗ +└─────────────────────────┘ +``` + +## Component Pattern with refSetter + +For components that need to be focus guide destinations, use a `refSetter` callback prop: + +```typescript +interface TVCastCardProps { + person: { id: number; name: string }; + onPress: () => void; + refSetter?: (ref: View | null) => void; +} + +const TVCastCard: React.FC = ({ + person, + onPress, + refSetter, +}) => { + return ( + + {person.name} + + ); +}; + +// Usage + +``` + +## Tips and Gotchas + +1. **Guard against null refs**: Only render the focus guide when the ref is set: + ```typescript + {targetRef && } + ``` + +2. **Style the guide invisibly**: Use `height: 1` or `width: 1` to make it invisible but still functional: + ```typescript + style={{ height: 1, width: "100%" }} + ``` + +3. **Multiple destinations**: You can provide multiple destinations and the focus engine will pick the geometrically closest one: + ```typescript + + ``` + +4. **Focus trapping**: Use `trapFocusUp`, `trapFocusDown`, etc. to prevent focus from leaving a region (useful for modals): + ```typescript + + {/* Modal content */} + + ``` + +5. **Auto focus**: Use `autoFocus` to automatically focus the first focusable child when entering a region: + ```typescript + + {/* First focusable child will receive focus */} + + ``` + + **Warning**: Don't use `autoFocus` on a wrapper when you also have bidirectional focus guides - it can interfere with upward navigation. + +## Common Mistakes + +| Mistake | Result | Fix | +|---------|--------|-----| +| Using `nextFocusUp`/`nextFocusDown` props | Doesn't work on tvOS | Use `TVFocusGuideView` | +| Using FlatList for horizontal lists | Focus navigation unreliable | Use ScrollView | +| `hasTVPreferredFocus` on focus guide destination | Focus flickering loop | Remove `hasTVPreferredFocus` from destination | +| Focus guides placed separately | Focus flickering | Stack both guides together | +| Using `useRef` for focus guide refs | Focus guide doesn't update | Use `useState` | + +## Reference Implementation + +See `components/jellyseerr/tv/TVJellyseerrPage.tsx` for a complete implementation of bidirectional focus navigation between action buttons and a cast list. diff --git a/docs/tv-modal-guide.md b/docs/tv-modal-guide.md new file mode 100644 index 000000000..a1b57e9bd --- /dev/null +++ b/docs/tv-modal-guide.md @@ -0,0 +1,416 @@ +# TV Modal Guide + +This document explains how to implement modals, bottom sheets, and overlays on Apple TV and Android TV in React Native. + +## The Problem + +On TV platforms, modals have unique challenges: +- The hardware back button must work correctly to dismiss modals +- Focus management must be handled explicitly +- React Native's `Modal` component breaks the TV focus chain +- Overlay/absolute-positioned modals don't handle back button correctly + +## Navigation-Based Modal Pattern (Recommended) + +For modals that need proper back button support, use the **navigation-based modal pattern**. This leverages Expo Router's stack navigation with transparent modal presentation. + +### Architecture + +``` +┌─────────────────────────────────────┐ +│ 1. Jotai Atom (state) │ +│ Stores modal data/params │ +├─────────────────────────────────────┤ +│ 2. Hook (trigger) │ +│ Sets atom + calls router.push() │ +├─────────────────────────────────────┤ +│ 3. Page File (UI) │ +│ Reads atom, renders modal │ +│ Clears atom on unmount │ +├─────────────────────────────────────┤ +│ 4. Stack.Screen (config) │ +│ presentation: transparentModal │ +│ animation: fade │ +└─────────────────────────────────────┘ +``` + +### Step 1: Create the Atom + +Create a Jotai atom to store the modal state/data: + +```typescript +// utils/atoms/tvExampleModal.ts +import { atom } from "jotai"; + +export interface TVExampleModalData { + itemId: string; + title: string; + // ... other data the modal needs +} + +export const tvExampleModalAtom = atom(null); +``` + +### Step 2: Create the Hook + +Create a hook that sets the atom and navigates to the modal: + +```typescript +// hooks/useTVExampleModal.ts +import { useSetAtom } from "jotai"; +import { router } from "expo-router"; +import { tvExampleModalAtom, TVExampleModalData } from "@/utils/atoms/tvExampleModal"; + +export const useTVExampleModal = () => { + const setModalData = useSetAtom(tvExampleModalAtom); + + const openModal = (data: TVExampleModalData) => { + setModalData(data); + router.push("/tv-example-modal"); + }; + + return { openModal }; +}; +``` + +### Step 3: Create the Modal Page + +Create a page file that reads the atom and renders the modal UI: + +```typescript +// app/(auth)/tv-example-modal.tsx +import { useEffect } from "react"; +import { View, Pressable, Text } from "react-native"; +import { useAtom } from "jotai"; +import { router } from "expo-router"; +import { BlurView } from "expo-blur"; +import { tvExampleModalAtom } from "@/utils/atoms/tvExampleModal"; + +export default function TVExampleModal() { + const [modalData, setModalData] = useAtom(tvExampleModalAtom); + + // Clear atom on unmount + useEffect(() => { + return () => { + setModalData(null); + }; + }, [setModalData]); + + // Handle case where modal is opened without data + if (!modalData) { + router.back(); + return null; + } + + return ( + + {/* Background overlay */} + router.back()} + /> + + {/* Modal content */} + + + {modalData.title} + + {/* Modal content here */} + + router.back()} + hasTVPreferredFocus + style={({ focused }) => ({ + marginTop: 24, + padding: 16, + borderRadius: 8, + backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)", + })} + > + {({ focused }) => ( + + Close + + )} + + + + ); +} +``` + +### Step 4: Add Stack.Screen Configuration + +Add the modal route to `app/_layout.tsx`: + +```typescript +// In app/_layout.tsx, inside your Stack navigator + +``` + +### Usage + +```typescript +// In any component +import { useTVExampleModal } from "@/hooks/useTVExampleModal"; + +const MyComponent = () => { + const { openModal } = useTVExampleModal(); + + return ( + openModal({ itemId: "123", title: "Example" })} + > + Open Modal + + ); +}; +``` + +### Reference Implementation + +See `useTVRequestModal` + `app/(auth)/tv-request-modal.tsx` for a complete working example. + +--- + +## Bottom Sheet Pattern (Inline Overlays) + +For simpler overlays that don't need back button navigation (like option selectors), use an **inline absolute-positioned overlay**. This pattern is ideal for: +- Dropdown selectors +- Quick action menus +- Option pickers + +### Key Principles + +1. **Use absolute positioning instead of Modal** - React Native's `Modal` breaks the TV focus chain +2. **Horizontal ScrollView for options** - Natural for TV remotes (left/right D-pad) +3. **Disable background focus** - Prevent focus flickering between overlay and background + +### Implementation + +```typescript +import { useState } from "react"; +import { View, ScrollView, Pressable, Text } from "react-native"; +import { BlurView } from "expo-blur"; + +const TVOptionSelector: React.FC<{ + options: { label: string; value: string }[]; + selectedValue: string; + onSelect: (value: string) => void; + isOpen: boolean; + onClose: () => void; +}> = ({ options, selectedValue, onSelect, isOpen, onClose }) => { + if (!isOpen) return null; + + const selectedIndex = options.findIndex(o => o.value === selectedValue); + + return ( + + + + {options.map((option, index) => ( + { + onSelect(option.value); + onClose(); + }} + /> + ))} + + + + ); +}; +``` + +### Option Card Component + +```typescript +import { useState, useRef, useEffect } from "react"; +import { Pressable, Text, Animated } from "react-native"; + +const TVOptionCard: React.FC<{ + label: string; + isSelected: boolean; + hasTVPreferredFocus?: boolean; + onPress: () => void; +}> = ({ label, isSelected, hasTVPreferredFocus, onPress }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (toValue: number) => { + Animated.spring(scale, { + toValue, + useNativeDriver: true, + tension: 50, + friction: 7, + }).start(); + }; + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + + {label} + + + + ); +}; +``` + +### Reference Implementation + +See `TVOptionSelector` and `TVOptionCard` in `components/ItemContent.tv.tsx`. + +--- + +## Focus Management for Overlays + +**CRITICAL**: When displaying overlays on TV, you must explicitly disable focus on all background elements. Without this, the TV focus engine will rapidly switch between overlay and background elements, causing a focus loop. + +### Solution + +Add a `disabled` prop to every focusable component and pass `disabled={isModalOpen}` when an overlay is visible: + +```typescript +// 1. Track modal state +const [openModal, setOpenModal] = useState(null); +const isModalOpen = openModal !== null; + +// 2. Each focusable component accepts disabled prop +const TVFocusableButton: React.FC<{ + onPress: () => void; + disabled?: boolean; +}> = ({ onPress, disabled }) => ( + + {/* content */} + +); + +// 3. Pass disabled to all background components when modal is open + +``` + +### Reference Implementation + +See `settings.tv.tsx` for a complete example with `TVSettingsOptionButton`, `TVSettingsToggle`, `TVSettingsStepper`, etc. + +--- + +## Focus Trapping + +For modals that should trap focus (prevent navigation outside the modal), use `TVFocusGuideView` with trap props: + +```typescript +import { TVFocusGuideView } from "react-native"; + + + {/* Modal content - focus cannot escape */} + +``` + +**Warning**: Don't use `autoFocus` on focus guide wrappers when you also have bidirectional focus guides - it can interfere with navigation. + +--- + +## Common Mistakes + +| Mistake | Result | Fix | +|---------|--------|-----| +| Using React Native `Modal` | Focus chain breaks | Use navigation-based or absolute positioning | +| Overlay without disabling background focus | Focus flickering loop | Add `disabled` prop to all background focusables | +| No `hasTVPreferredFocus` in modal | Focus stuck on background | Set preferred focus on first modal element | +| Missing `presentation: "transparentModal"` | Modal not transparent | Add to Stack.Screen options | +| Not clearing atom on unmount | Stale data on reopen | Clear in useEffect cleanup | + +--- + +## When to Use Which Pattern + +| Scenario | Pattern | +|----------|---------| +| Full-screen modal with back button | Navigation-based modal | +| Confirmation dialogs | Navigation-based modal | +| Option selectors / dropdowns | Bottom sheet (inline) | +| Quick action menus | Bottom sheet (inline) | +| Complex forms | Navigation-based modal | diff --git a/eas.json b/eas.json index 8a9736d31..03f933895 100644 --- a/eas.json +++ b/eas.json @@ -43,16 +43,23 @@ "EXPO_PUBLIC_WRITE_DEBUG": "1" } }, + "preview_tv": { + "distribution": "internal", + "env": { + "EXPO_TV": "1", + "EXPO_PUBLIC_WRITE_DEBUG": "1" + } + }, "production": { "environment": "production", - "channel": "0.52.0", + "channel": "0.54.0", "android": { "image": "latest" } }, "production-apk": { "environment": "production", - "channel": "0.52.0", + "channel": "0.54.0", "android": { "buildType": "apk", "image": "latest" @@ -60,7 +67,7 @@ }, "production-apk-tv": { "environment": "production", - "channel": "0.52.0", + "channel": "0.54.0", "android": { "buildType": "apk", "image": "latest" @@ -68,9 +75,20 @@ "env": { "EXPO_TV": "1" } + }, + "production_tv": { + "environment": "production", + "channel": "0.54.0", + "env": { + "EXPO_TV": "1" + }, + "ios": { + "credentialsSource": "local" + } } }, "submit": { - "production": {} + "production": {}, + "production_tv": {} } } diff --git a/hooks/useDefaultPlaySettings.ts b/hooks/useDefaultPlaySettings.ts index 51b0e9eed..ad292639a 100644 --- a/hooks/useDefaultPlaySettings.ts +++ b/hooks/useDefaultPlaySettings.ts @@ -1,16 +1,27 @@ import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { useMemo } from "react"; import type { Settings } from "@/utils/atoms/settings"; -import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; +import { + getDefaultPlaySettings, + type PlaySettingsOptions, +} from "@/utils/jellyfin/getDefaultPlaySettings"; /** * React hook wrapper for getDefaultPlaySettings. * Used in UI components for initial playback (no previous track state). + * + * @param item - The media item to play + * @param settings - User settings (language preferences, bitrate, etc.) + * @param options - Optional flags to control behavior (e.g., applyLanguagePreferences for TV) */ -const useDefaultPlaySettings = (item: BaseItemDto, settings: Settings | null) => +const useDefaultPlaySettings = ( + item: BaseItemDto | null | undefined, + settings: Settings | null, + options?: PlaySettingsOptions, +) => useMemo(() => { const { mediaSource, audioIndex, subtitleIndex, bitrate } = - getDefaultPlaySettings(item, settings); + getDefaultPlaySettings(item, settings, undefined, options); return { defaultMediaSource: mediaSource, @@ -18,6 +29,6 @@ const useDefaultPlaySettings = (item: BaseItemDto, settings: Settings | null) => defaultSubtitleIndex: subtitleIndex, defaultBitrate: bitrate, }; - }, [item, settings]); + }, [item, settings, options]); export default useDefaultPlaySettings; diff --git a/hooks/useItemQuery.ts b/hooks/useItemQuery.ts index 370b5f35a..d45fe51c6 100644 --- a/hooks/useItemQuery.ts +++ b/hooks/useItemQuery.ts @@ -2,6 +2,7 @@ import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { useAtom } from "jotai"; +import { Platform } from "react-native"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; @@ -12,11 +13,17 @@ export const excludeFields = (fieldsToExclude: ItemFields[]) => { ); }; +type ExtraQueryOptions = { + gcTime?: number; + staleTime?: number; +}; + export const useItemQuery = ( itemId: string | undefined, isOffline?: boolean, fields?: ItemFields[], excludeFields?: ItemFields[], + queryOptions?: ExtraQueryOptions, ) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); @@ -49,9 +56,12 @@ export const useItemQuery = ( return response.data.Items?.[0]; }, enabled: !!itemId, + staleTime: isOffline ? Infinity : 60 * 1000, + refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined, refetchOnMount: true, refetchOnWindowFocus: true, refetchOnReconnect: true, networkMode: "always", + ...queryOptions, }); }; diff --git a/hooks/usePlaybackManager.ts b/hooks/usePlaybackManager.ts index 0c90b73b0..97d148454 100644 --- a/hooks/usePlaybackManager.ts +++ b/hooks/usePlaybackManager.ts @@ -80,7 +80,7 @@ export const usePlaybackManager = ({ const { data: adjacentItems } = useQuery({ queryKey: ["adjacentItems", item?.Id, item?.SeriesId, isOffline], queryFn: async (): Promise => { - if (!item || !item.SeriesId) { + if (!item?.SeriesId) { return null; } diff --git a/hooks/useRefreshLibraryOnFocus.ts b/hooks/useRefreshLibraryOnFocus.ts new file mode 100644 index 000000000..f89ebd58c --- /dev/null +++ b/hooks/useRefreshLibraryOnFocus.ts @@ -0,0 +1,50 @@ +import { useFocusEffect } from "expo-router"; +import { useCallback, useRef } from "react"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; + +// Query keys that depend on the set of library items. Kept in sync with the +// LibraryChanged handler in WebSocketProvider. +const LIBRARY_QUERY_KEYS = [ + ["home"], + ["library-items"], + ["nextUp-all"], + ["nextUp"], + ["resumeItems"], +]; + +/** + * Fallback refresh for newly added/removed content. + * + * The primary path is the server's `LibraryChanged` WebSocket event (handled in + * WebSocketProvider). This hook is a safety net for cases where the socket was + * down or the change happened while the screen was unfocused: when the screen + * regains focus, it invalidates the library-dependent queries so React Query + * refetches the latest content. + * + * Skips the refresh on the very first focus (initial mount already fetches) and + * throttles to avoid refetch storms when quickly switching tabs. + */ +export function useRefreshLibraryOnFocus(throttleMs = 30_000) { + const queryClient = useNetworkAwareQueryClient(); + const hasFocusedOnce = useRef(false); + const lastRefreshRef = useRef(0); + + useFocusEffect( + useCallback(() => { + if (!hasFocusedOnce.current) { + hasFocusedOnce.current = true; + return; + } + + const now = Date.now(); + if (now - lastRefreshRef.current < throttleMs) { + return; + } + lastRefreshRef.current = now; + + for (const queryKey of LIBRARY_QUERY_KEYS) { + queryClient.invalidateQueries({ queryKey }); + } + }, [queryClient, throttleMs]), + ); +} diff --git a/hooks/useRemoteSubtitles.ts b/hooks/useRemoteSubtitles.ts new file mode 100644 index 000000000..b101aeeee --- /dev/null +++ b/hooks/useRemoteSubtitles.ts @@ -0,0 +1,332 @@ +import type { + BaseItemDto, + RemoteSubtitleInfo, +} from "@jellyfin/sdk/lib/generated-client"; +import { getSubtitleApi } from "@jellyfin/sdk/lib/utils/api"; +import { useMutation } from "@tanstack/react-query"; +import { Directory, File, Paths } from "expo-file-system"; +import { useAtomValue } from "jotai"; +import { useCallback, useMemo } from "react"; +import { Platform } from "react-native"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { + addDownloadedSubtitle, + type DownloadedSubtitle, +} from "@/utils/atoms/downloadedSubtitles"; +import { useSettings } from "@/utils/atoms/settings"; +import { + OpenSubtitlesApi, + type OpenSubtitlesResult, +} from "@/utils/opensubtitles/api"; + +export interface SubtitleSearchResult { + id: string; + name: string; + providerName: string; + format: string; + language: string; + communityRating?: number; + downloadCount?: number; + isHashMatch?: boolean; + hearingImpaired?: boolean; + aiTranslated?: boolean; + machineTranslated?: boolean; + /** For OpenSubtitles: file ID to download */ + fileId?: number; + /** Source: 'jellyfin' or 'opensubtitles' */ + source: "jellyfin" | "opensubtitles"; +} + +interface UseRemoteSubtitlesOptions { + itemId: string; + item: BaseItemDto; + mediaSourceId?: string | null; +} + +/** + * Convert Jellyfin RemoteSubtitleInfo to unified SubtitleSearchResult + */ +function jellyfinToResult(sub: RemoteSubtitleInfo): SubtitleSearchResult { + return { + id: sub.Id ?? "", + name: sub.Name ?? "Unknown", + providerName: sub.ProviderName ?? "Unknown", + format: sub.Format ?? "srt", + language: sub.ThreeLetterISOLanguageName ?? "", + communityRating: sub.CommunityRating ?? undefined, + downloadCount: sub.DownloadCount ?? undefined, + isHashMatch: sub.IsHashMatch ?? undefined, + hearingImpaired: sub.HearingImpaired ?? undefined, + aiTranslated: sub.AiTranslated ?? undefined, + machineTranslated: sub.MachineTranslated ?? undefined, + source: "jellyfin", + }; +} + +/** + * Convert OpenSubtitles result to unified SubtitleSearchResult + */ +function openSubtitlesToResult( + sub: OpenSubtitlesResult, +): SubtitleSearchResult | null { + const firstFile = sub.attributes.files[0]; + if (!firstFile) return null; + + return { + id: sub.id, + name: + sub.attributes.release || sub.attributes.files[0]?.file_name || "Unknown", + providerName: "OpenSubtitles", + format: sub.attributes.format || "srt", + language: sub.attributes.language, + communityRating: sub.attributes.ratings, + downloadCount: sub.attributes.download_count, + isHashMatch: false, + hearingImpaired: sub.attributes.hearing_impaired, + aiTranslated: sub.attributes.ai_translated, + machineTranslated: sub.attributes.machine_translated, + fileId: firstFile.file_id, + source: "opensubtitles", + }; +} + +/** + * Hook for searching and downloading remote subtitles + * + * Primary: Uses Jellyfin's subtitle API (server-side OpenSubtitles plugin) + * Fallback: Direct OpenSubtitles API when server has no provider + */ +export function useRemoteSubtitles({ + itemId, + item, + mediaSourceId: _mediaSourceId, +}: UseRemoteSubtitlesOptions) { + const api = useAtomValue(apiAtom); + const { settings } = useSettings(); + const openSubtitlesApiKey = settings.openSubtitlesApiKey; + + // Check if we can use OpenSubtitles fallback + const hasOpenSubtitlesApiKey = Boolean(openSubtitlesApiKey); + + // Create OpenSubtitles API client when API key is available + const openSubtitlesApi = useMemo(() => { + if (!openSubtitlesApiKey) return null; + return new OpenSubtitlesApi(openSubtitlesApiKey); + }, [openSubtitlesApiKey]); + + /** + * Search for subtitles via Jellyfin API + */ + const searchJellyfin = useCallback( + async (language: string): Promise => { + if (!api) throw new Error("API not available"); + + const subtitleApi = getSubtitleApi(api); + const response = await subtitleApi.searchRemoteSubtitles({ + itemId, + language, + }); + + return (response.data || []).map(jellyfinToResult); + }, + [api, itemId], + ); + + /** + * Search for subtitles via OpenSubtitles direct API + */ + const searchOpenSubtitles = useCallback( + async (language: string): Promise => { + if (!openSubtitlesApi) { + throw new Error("OpenSubtitles API key not configured"); + } + + // Get IMDB ID from item if available + const imdbId = item.ProviderIds?.Imdb; + + // Build search params + const params: Parameters[0] = { + languages: language, + }; + + if (imdbId) { + params.imdbId = imdbId; + } else { + // Fall back to title search + params.query = item.Name || ""; + params.year = item.ProductionYear || undefined; + } + + // For TV episodes, add season/episode info + if (item.Type === "Episode") { + params.seasonNumber = item.ParentIndexNumber || undefined; + params.episodeNumber = item.IndexNumber || undefined; + } + + const response = await openSubtitlesApi.search(params); + + return response.data + .map(openSubtitlesToResult) + .filter((r): r is SubtitleSearchResult => r !== null); + }, + [openSubtitlesApi, item], + ); + + /** + * Download subtitle via Jellyfin API (saves to server library) + */ + const downloadJellyfin = useCallback( + async (subtitleId: string): Promise => { + if (!api) throw new Error("API not available"); + + const subtitleApi = getSubtitleApi(api); + await subtitleApi.downloadRemoteSubtitles({ + itemId, + subtitleId, + }); + }, + [api, itemId], + ); + + /** + * Download subtitle via OpenSubtitles API (returns local file path) + * + * On TV: Downloads to cache directory and persists metadata in MMKV + * On mobile: Downloads to cache directory (ephemeral, no persistence) + * + * Uses a flat filename structure with itemId prefix to avoid tvOS permission issues + */ + const downloadOpenSubtitles = useCallback( + async ( + fileId: number, + result: SubtitleSearchResult, + ): Promise<{ path: string; subtitle?: DownloadedSubtitle }> => { + if (!openSubtitlesApi) { + throw new Error("OpenSubtitles API key not configured"); + } + + // Get download link + const response = await openSubtitlesApi.download(fileId); + const originalFileName = response.file_name || `subtitle_${fileId}.srt`; + + // Use cache directory for both platforms (tvOS has permission issues with documents) + // TV: Uses itemId prefix for organization and persists metadata + // Mobile: Simple filename, no persistence + const subtitlesDir = new Directory(Paths.cache, "streamyfin-subtitles"); + + // Ensure directory exists + if (!subtitlesDir.exists) { + subtitlesDir.create(); + } + + // TV: Prefix filename with itemId for organization + // Mobile: Use original filename + const fileName = Platform.isTV + ? `${itemId}_${originalFileName}` + : originalFileName; + + // Create file and download + const destination = new File(subtitlesDir, fileName); + + // Delete existing file if it exists (re-download) + if (destination.exists) { + destination.delete(); + } + + await File.downloadFileAsync(response.link, destination); + + // TV: Persist metadata for future sessions + if (Platform.isTV) { + const subtitleMetadata: DownloadedSubtitle = { + id: result.id, + itemId, + filePath: destination.uri, + name: result.name, + language: result.language, + format: result.format, + source: "opensubtitles", + downloadedAt: Date.now(), + }; + addDownloadedSubtitle(subtitleMetadata); + return { path: destination.uri, subtitle: subtitleMetadata }; + } + + return { path: destination.uri }; + }, + [openSubtitlesApi, itemId], + ); + + /** + * Search mutation - tries Jellyfin first, falls back to OpenSubtitles + */ + const searchMutation = useMutation({ + mutationFn: async ({ + language, + preferOpenSubtitles = false, + }: { + language: string; + preferOpenSubtitles?: boolean; + }) => { + // If user prefers OpenSubtitles and has API key, use it + if (preferOpenSubtitles && hasOpenSubtitlesApiKey) { + return searchOpenSubtitles(language); + } + + // Try Jellyfin first + try { + const results = await searchJellyfin(language); + // If no results and we have OpenSubtitles fallback, try it + if (results.length === 0 && hasOpenSubtitlesApiKey) { + return searchOpenSubtitles(language); + } + return results; + } catch (error) { + // If Jellyfin fails (no provider configured) and we have fallback, use it + if (hasOpenSubtitlesApiKey) { + return searchOpenSubtitles(language); + } + throw error; + } + }, + }); + + /** + * Download mutation + */ + const downloadMutation = useMutation({ + mutationFn: async (result: SubtitleSearchResult) => { + if (result.source === "jellyfin") { + await downloadJellyfin(result.id); + return { type: "server" as const }; + } + if (result.fileId) { + const { path, subtitle } = await downloadOpenSubtitles( + result.fileId, + result, + ); + return { type: "local" as const, path, subtitle }; + } + throw new Error("Invalid subtitle result"); + }, + }); + + return { + // State + hasOpenSubtitlesApiKey, + isSearching: searchMutation.isPending, + isDownloading: downloadMutation.isPending, + searchError: searchMutation.error, + downloadError: downloadMutation.error, + searchResults: searchMutation.data, + + // Actions + search: searchMutation.mutate, + searchAsync: searchMutation.mutateAsync, + download: downloadMutation.mutate, + downloadAsync: downloadMutation.mutateAsync, + reset: () => { + searchMutation.reset(); + downloadMutation.reset(); + }, + }; +} diff --git a/hooks/useSessions.ts b/hooks/useSessions.ts index 5aba65159..108441c0e 100644 --- a/hooks/useSessions.ts +++ b/hooks/useSessions.ts @@ -21,7 +21,7 @@ export const useSessions = ({ const { data, isLoading } = useQuery({ queryKey: ["sessions"], queryFn: async () => { - if (!api || !user || !user.Policy?.IsAdministrator) { + if (!api || !user?.Policy?.IsAdministrator) { return []; } const response = await getSessionApi(api).getSessions({ @@ -55,7 +55,7 @@ export const useAllSessions = ({ const { data, isLoading } = useQuery({ queryKey: ["allSessions"], queryFn: async () => { - if (!api || !user || !user.Policy?.IsAdministrator) { + if (!api || !user?.Policy?.IsAdministrator) { return []; } const response = await getSessionApi(api).getSessions({ diff --git a/hooks/useTVAccountActionModal.ts b/hooks/useTVAccountActionModal.ts new file mode 100644 index 000000000..97db7ac50 --- /dev/null +++ b/hooks/useTVAccountActionModal.ts @@ -0,0 +1,34 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { tvAccountActionModalAtom } from "@/utils/atoms/tvAccountActionModal"; +import type { + SavedServer, + SavedServerAccount, +} from "@/utils/secureCredentials"; +import { store } from "@/utils/store"; + +interface ShowAccountActionModalParams { + server: SavedServer; + account: SavedServerAccount; + onLogin: () => void; + onDelete: () => void; +} + +export const useTVAccountActionModal = () => { + const router = useRouter(); + + const showAccountActionModal = useCallback( + (params: ShowAccountActionModalParams) => { + store.set(tvAccountActionModalAtom, { + server: params.server, + account: params.account, + onLogin: params.onLogin, + onDelete: params.onDelete, + }); + router.push("/tv-account-action-modal"); + }, + [router], + ); + + return { showAccountActionModal }; +}; diff --git a/hooks/useTVAccountSelectModal.ts b/hooks/useTVAccountSelectModal.ts new file mode 100644 index 000000000..3bc61ed77 --- /dev/null +++ b/hooks/useTVAccountSelectModal.ts @@ -0,0 +1,34 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { tvAccountSelectModalAtom } from "@/utils/atoms/tvAccountSelectModal"; +import type { + SavedServer, + SavedServerAccount, +} from "@/utils/secureCredentials"; +import { store } from "@/utils/store"; + +interface ShowAccountSelectModalParams { + server: SavedServer; + onAccountAction: (account: SavedServerAccount) => void; + onAddAccount: () => void; + onDeleteServer: () => void; +} + +export const useTVAccountSelectModal = () => { + const router = useRouter(); + + const showAccountSelectModal = useCallback( + (params: ShowAccountSelectModalParams) => { + store.set(tvAccountSelectModalAtom, { + server: params.server, + onAccountAction: params.onAccountAction, + onAddAccount: params.onAddAccount, + onDeleteServer: params.onDeleteServer, + }); + router.push("/tv-account-select-modal"); + }, + [router], + ); + + return { showAccountSelectModal }; +}; diff --git a/hooks/useTVBackHandler.ts b/hooks/useTVBackHandler.ts new file mode 100644 index 000000000..8277d0a79 --- /dev/null +++ b/hooks/useTVBackHandler.ts @@ -0,0 +1,67 @@ +import { useSegments } from "expo-router"; +import { useEffect } from "react"; +import { Platform } from "react-native"; +import { + disableTVMenuKeyInterception, + enableTVMenuKeyInterception, +} from "./useTVBackPress"; + +export { enableTVMenuKeyInterception } from "./useTVBackPress"; + +/** + * Check if we're at the root of a tab + */ +function isAtTabRoot(segments: string[]): boolean { + const lastSegment = segments[segments.length - 1]; + const tabNames = [ + "(home)", + "(search)", + "(favorites)", + "(libraries)", + "(watchlists)", + "(settings)", + "(custom-links)", + ]; + return tabNames.includes(lastSegment) || lastSegment === "index"; +} + +/** + * Get the current tab name from segments + */ +function getCurrentTab(segments: string[]): string | undefined { + return segments.find( + (s) => + s === "(home)" || + s === "(search)" || + s === "(favorites)" || + s === "(libraries)" || + s === "(watchlists)" || + s === "(settings)" || + s === "(custom-links)", + ); +} + +/** + * Keeps tvOS menu key interception disabled on the home tab root so the system + * can apply its native app-exit behavior. Other routes can opt into + * interception when they need JS-owned back handling. + */ +export function useTVHomeBackHandler() { + const segments = useSegments(); + + // Get current state + const currentTab = getCurrentTab(segments); + const atTabRoot = isAtTabRoot(segments); + const isOnHomeRoot = atTabRoot && currentTab === "(home)"; + + useEffect(() => { + if (!Platform.isTV) return; + + if (isOnHomeRoot) { + disableTVMenuKeyInterception(); + return; + } + + enableTVMenuKeyInterception(); + }, [isOnHomeRoot]); +} diff --git a/hooks/useTVBackPress.ts b/hooks/useTVBackPress.ts new file mode 100644 index 000000000..2631cdab5 --- /dev/null +++ b/hooks/useTVBackPress.ts @@ -0,0 +1,72 @@ +import { type DependencyList, useEffect } from "react"; +import { BackHandler, Platform } from "react-native"; + +type TVBackPressHandler = () => boolean | null | undefined; + +let TVEventControl: { + enableTVMenuKey: () => void; + disableTVMenuKey: () => void; +} | null = null; + +if (Platform.isTV) { + try { + TVEventControl = require("react-native").TVEventControl; + } catch { + TVEventControl = null; + } +} + +export function enableTVMenuKeyInterception() { + if (Platform.isTV && TVEventControl) { + TVEventControl.enableTVMenuKey(); + } +} + +export function disableTVMenuKeyInterception() { + if (Platform.isTV && TVEventControl) { + TVEventControl.disableTVMenuKey(); + } +} + +export function useTVMenuKeyInterception(enabled = true) { + useEffect(() => { + if (!Platform.isTV) return; + + if (enabled) { + enableTVMenuKeyInterception(); + return; + } + + disableTVMenuKeyInterception(); + }, [enabled]); +} + +/** + * Subscribe to TV back presses through React Native's BackHandler. + * + * On Android TV this handles the hardware back button. On tvOS, + * react-native-tvos maps the Apple TV menu button to the same API when menu key + * interception is enabled. + * + * @see https://reactnative.dev/docs/backhandler + */ +export function useTVBackPress( + handler: TVBackPressHandler, + deps: DependencyList, +) { + useEffect(() => { + if (!Platform.isTV) return; + + // BackHandler is the shared back/menu surface for TV platforms: + // Android TV sends hardware back here, and react-native-tvos sends menu + // here when menu key interception is enabled. + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + handler, + ); + + return () => { + subscription.remove(); + }; + }, deps); +} diff --git a/hooks/useTVEventHandler.ts b/hooks/useTVEventHandler.ts new file mode 100644 index 000000000..d92011b75 --- /dev/null +++ b/hooks/useTVEventHandler.ts @@ -0,0 +1,17 @@ +import type { HWEvent } from "react-native"; +import { Platform } from "react-native"; + +type UseTVEventHandler = (callback: (evt: HWEvent) => void) => void; + +let tvEventHandler: UseTVEventHandler = () => {}; + +if (Platform.isTV) { + try { + tvEventHandler = require("react-native") + .useTVEventHandler as UseTVEventHandler; + } catch { + tvEventHandler = () => {}; + } +} + +export const useTVEventHandler = tvEventHandler; diff --git a/hooks/useTVItemActionModal.ts b/hooks/useTVItemActionModal.ts new file mode 100644 index 000000000..3c547c0d6 --- /dev/null +++ b/hooks/useTVItemActionModal.ts @@ -0,0 +1,82 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { Alert } from "react-native"; +import { usePlaybackManager } from "@/hooks/usePlaybackManager"; +import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; + +export const useTVItemActionModal = () => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const { markItemPlayed, markItemUnplayed } = usePlaybackManager(); + const invalidatePlaybackProgressCache = useInvalidatePlaybackProgressCache(); + + const showItemActions = useCallback( + (item: BaseItemDto) => { + const isPlayed = item.UserData?.Played ?? false; + const itemTitle = + item.Type === "Episode" + ? `${item.SeriesName} - ${item.Name}` + : (item.Name ?? ""); + + const actionLabel = isPlayed + ? t("item_card.mark_unplayed") + : t("item_card.mark_played"); + + Alert.alert(itemTitle, undefined, [ + { text: t("common.cancel"), style: "cancel" }, + { + text: actionLabel, + onPress: async () => { + if (!item.Id) return; + + // Optimistic update + queryClient.setQueriesData( + { queryKey: ["item", item.Id] }, + (old) => { + if (!old) return old; + return { + ...old, + UserData: { + ...old.UserData, + Played: !isPlayed, + PlaybackPositionTicks: 0, + PlayedPercentage: 0, + }, + }; + }, + ); + + try { + if (!isPlayed) { + await markItemPlayed(item.Id); + } else { + await markItemUnplayed(item.Id); + } + } catch { + // Revert on failure + queryClient.invalidateQueries({ + queryKey: ["item", item.Id], + }); + } finally { + await invalidatePlaybackProgressCache(); + queryClient.invalidateQueries({ + queryKey: ["item", item.Id], + }); + } + }, + }, + ]); + }, + [ + t, + queryClient, + markItemPlayed, + markItemUnplayed, + invalidatePlaybackProgressCache, + ], + ); + + return { showItemActions }; +}; diff --git a/hooks/useTVOptionModal.ts b/hooks/useTVOptionModal.ts new file mode 100644 index 000000000..c6acffe83 --- /dev/null +++ b/hooks/useTVOptionModal.ts @@ -0,0 +1,36 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { + type TVOptionItem, + tvOptionModalAtom, +} from "@/utils/atoms/tvOptionModal"; +import { store } from "@/utils/store"; + +interface ShowOptionsParams { + title: string; + options: TVOptionItem[]; + onSelect: (value: T) => void; + cardWidth?: number; + cardHeight?: number; +} + +export const useTVOptionModal = () => { + const router = useRouter(); + + const showOptions = useCallback( + (params: ShowOptionsParams) => { + // Use store.set for synchronous update before navigation + store.set(tvOptionModalAtom, { + title: params.title, + options: params.options, + onSelect: params.onSelect, + cardWidth: params.cardWidth, + cardHeight: params.cardHeight, + }); + router.push("/(auth)/tv-option-modal"); + }, + [router], + ); + + return { showOptions }; +}; diff --git a/hooks/useTVRequestModal.ts b/hooks/useTVRequestModal.ts new file mode 100644 index 000000000..0c096bb46 --- /dev/null +++ b/hooks/useTVRequestModal.ts @@ -0,0 +1,34 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal"; +import type { MediaType } from "@/utils/jellyseerr/server/constants/media"; +import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; +import { store } from "@/utils/store"; + +interface ShowRequestModalParams { + requestBody: MediaRequestBody; + title: string; + id: number; + mediaType: MediaType; + onRequested: () => void; +} + +export const useTVRequestModal = () => { + const router = useRouter(); + + const showRequestModal = useCallback( + (params: ShowRequestModalParams) => { + store.set(tvRequestModalAtom, { + requestBody: params.requestBody, + title: params.title, + id: params.id, + mediaType: params.mediaType, + onRequested: params.onRequested, + }); + router.push("/(auth)/tv-request-modal"); + }, + [router], + ); + + return { showRequestModal }; +}; diff --git a/hooks/useTVSeasonSelectModal.ts b/hooks/useTVSeasonSelectModal.ts new file mode 100644 index 000000000..7b2f4f201 --- /dev/null +++ b/hooks/useTVSeasonSelectModal.ts @@ -0,0 +1,23 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { + type TVSeasonSelectModalState, + tvSeasonSelectModalAtom, +} from "@/utils/atoms/tvSeasonSelectModal"; +import { store } from "@/utils/store"; + +type ShowSeasonSelectModalParams = NonNullable; + +export const useTVSeasonSelectModal = () => { + const router = useRouter(); + + const showSeasonSelectModal = useCallback( + (params: ShowSeasonSelectModalParams) => { + store.set(tvSeasonSelectModalAtom, params); + router.push("/(auth)/tv-season-select-modal"); + }, + [router], + ); + + return { showSeasonSelectModal }; +}; diff --git a/hooks/useTVSeriesSeasonModal.ts b/hooks/useTVSeriesSeasonModal.ts new file mode 100644 index 000000000..dcd5d4784 --- /dev/null +++ b/hooks/useTVSeriesSeasonModal.ts @@ -0,0 +1,34 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal"; +import { store } from "@/utils/store"; + +interface ShowSeasonModalParams { + seasons: Array<{ + label: string; + value: number; + selected: boolean; + }>; + selectedSeasonIndex: number | string; + itemId: string; + onSeasonSelect: (seasonIndex: number) => void; +} + +export const useTVSeriesSeasonModal = () => { + const router = useRouter(); + + const showSeasonModal = useCallback( + (params: ShowSeasonModalParams) => { + store.set(tvSeriesSeasonModalAtom, { + seasons: params.seasons, + selectedSeasonIndex: params.selectedSeasonIndex, + itemId: params.itemId, + onSeasonSelect: params.onSeasonSelect, + }); + router.push("/(auth)/tv-series-season-modal"); + }, + [router], + ); + + return { showSeasonModal }; +}; diff --git a/hooks/useTVSubtitleModal.ts b/hooks/useTVSubtitleModal.ts new file mode 100644 index 000000000..38d442239 --- /dev/null +++ b/hooks/useTVSubtitleModal.ts @@ -0,0 +1,40 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { useCallback } from "react"; +import type { Track } from "@/components/video-player/controls/types"; +import useRouter from "@/hooks/useAppRouter"; +import { tvSubtitleModalAtom } from "@/utils/atoms/tvSubtitleModal"; +import { store } from "@/utils/store"; + +interface ShowSubtitleModalParams { + item: BaseItemDto; + mediaSourceId?: string | null; + subtitleTracks: Track[]; + currentSubtitleIndex: number; + onDisableSubtitles?: () => void; + onServerSubtitleDownloaded?: () => void; + onLocalSubtitleDownloaded?: (path: string) => void; + refreshSubtitleTracks?: () => Promise; +} + +export const useTVSubtitleModal = () => { + const router = useRouter(); + + const showSubtitleModal = useCallback( + (params: ShowSubtitleModalParams) => { + store.set(tvSubtitleModalAtom, { + item: params.item, + mediaSourceId: params.mediaSourceId, + subtitleTracks: params.subtitleTracks, + currentSubtitleIndex: params.currentSubtitleIndex, + onDisableSubtitles: params.onDisableSubtitles, + onServerSubtitleDownloaded: params.onServerSubtitleDownloaded, + onLocalSubtitleDownloaded: params.onLocalSubtitleDownloaded, + refreshSubtitleTracks: params.refreshSubtitleTracks, + }); + router.push("/(auth)/tv-subtitle-modal"); + }, + [router], + ); + + return { showSubtitleModal }; +}; diff --git a/hooks/useTVThemeMusic.ts b/hooks/useTVThemeMusic.ts new file mode 100644 index 000000000..13a2d5cf5 --- /dev/null +++ b/hooks/useTVThemeMusic.ts @@ -0,0 +1,225 @@ +import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { + type AudioPlayer, + createAudioPlayer, + setAudioModeAsync, +} from "expo-audio"; +import { useAtom } from "jotai"; +import { useEffect } from "react"; +import { Platform } from "react-native"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; + +const TARGET_VOLUME = 0.3; +const FADE_IN_DURATION = 2000; +const FADE_OUT_DURATION = 1000; +const FADE_STEP_MS = 50; + +/** + * Smoothly transitions audio volume from `from` to `to` over `duration` ms. + * Returns a cleanup function that cancels the fade. + */ +function fadeVolume( + player: AudioPlayer, + from: number, + to: number, + duration: number, +): { promise: Promise; cancel: () => void } { + let cancelled = false; + const cancel = () => { + cancelled = true; + }; + + const steps = Math.max(1, Math.floor(duration / FADE_STEP_MS)); + const delta = (to - from) / steps; + + const promise = new Promise((resolve) => { + let current = from; + let step = 0; + + const tick = () => { + if (cancelled || step >= steps) { + if (!cancelled) { + player.volume = to; + } + resolve(); + return; + } + step++; + current += delta; + player.volume = Math.max(0, Math.min(1, current)); + if (!cancelled) { + setTimeout(tick, FADE_STEP_MS); + } else { + resolve(); + } + }; + + tick(); + }); + + return { promise, cancel }; +} + +// --- Module-level singleton state --- +let sharedPlayer: AudioPlayer | null = null; +let currentSongId: string | null = null; +let ownerCount = 0; +let activeFade: { cancel: () => void } | null = null; +let cleanupPromise: Promise | null = null; + +/** Fade out, stop, and release the shared player. */ +async function teardownSharedPlayer(): Promise { + const player = sharedPlayer; + if (!player) return; + + activeFade?.cancel(); + activeFade = null; + + try { + if (player.isLoaded) { + const currentVolume = player.volume ?? TARGET_VOLUME; + const fade = fadeVolume(player, currentVolume, 0, FADE_OUT_DURATION); + activeFade = fade; + await fade.promise; + activeFade = null; + player.pause(); + } + } catch { + // ignore + } + + if (sharedPlayer === player) { + sharedPlayer = null; + currentSongId = null; + } +} + +/** Begin cleanup idempotently; returns the shared promise. */ +function beginCleanup(): Promise { + if (!cleanupPromise) { + cleanupPromise = teardownSharedPlayer().finally(() => { + cleanupPromise = null; + }); + } + return cleanupPromise; +} + +export function useTVThemeMusic(itemId: string | undefined) { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const { settings } = useSettings(); + + const enabled = + Platform.isTV && + !!api && + !!user?.Id && + !!itemId && + settings.tvThemeMusicEnabled; + + // Fetch theme songs + const { data: themeSongs } = useQuery({ + queryKey: ["themeSongs", itemId], + queryFn: async () => { + const result = await getLibraryApi(api!).getThemeSongs({ + itemId: itemId!, + userId: user!.Id!, + inheritFromParent: true, + }); + return result.data; + }, + enabled, + staleTime: 5 * 60 * 1000, + }); + + // Load and play audio when theme songs are available and enabled + useEffect(() => { + if (!enabled || !themeSongs?.Items?.length || !api) { + return; + } + + const themeItem = themeSongs.Items[0]; + const songId = themeItem.Id!; + + ownerCount++; + let mounted = true; + + const startPlayback = async () => { + // If the same song is already playing, keep it going + if (currentSongId === songId && sharedPlayer) { + return; + } + + // If a different song is playing (or cleanup is in progress), tear it down first + if (sharedPlayer || cleanupPromise) { + activeFade?.cancel(); + activeFade = null; + await beginCleanup(); + } + + if (!mounted) return; + + const player = createAudioPlayer(null); + sharedPlayer = player; + currentSongId = songId; + + try { + await setAudioModeAsync({ + playsInSilentMode: true, + shouldPlayInBackground: false, + }); + + const params = new URLSearchParams({ + UserId: user!.Id!, + DeviceId: api.deviceInfo.id ?? "", + MaxStreamingBitrate: "140000000", + Container: "mp3,aac,m4a|aac,m4b|aac,flac,wav", + TranscodingContainer: "mp4", + TranscodingProtocol: "http", + AudioCodec: "aac", + ApiKey: api.accessToken ?? "", + EnableRedirection: "true", + EnableRemoteMedia: "false", + }); + const url = `${api.basePath}/Audio/${themeItem.Id}/universal?${params.toString()}`; + player.replace({ uri: url }); + + if (!mounted || sharedPlayer !== player) { + player.pause(); + return; + } + + player.loop = true; + player.volume = 0; + player.play(); + + if (mounted && sharedPlayer === player) { + const fade = fadeVolume(player, 0, TARGET_VOLUME, FADE_IN_DURATION); + activeFade = fade; + await fade.promise; + activeFade = null; + } + } catch (e) { + console.warn("Theme music playback error:", e); + } + }; + + startPlayback(); + + // Cleanup: decrement owner count, defer teardown check + return () => { + mounted = false; + ownerCount--; + + // Defer the check so React can finish processing both unmount + mount + // in the same commit. If another instance mounts (same song), ownerCount + // will be back to >0 and we skip teardown entirely. + setTimeout(() => { + if (ownerCount === 0) { + beginCleanup(); + } + }, 0); + }; + }, [enabled, themeSongs, api]); +} diff --git a/hooks/useTVUserSwitchModal.ts b/hooks/useTVUserSwitchModal.ts new file mode 100644 index 000000000..a0b0a9441 --- /dev/null +++ b/hooks/useTVUserSwitchModal.ts @@ -0,0 +1,42 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { tvUserSwitchModalAtom } from "@/utils/atoms/tvUserSwitchModal"; +import type { + SavedServer, + SavedServerAccount, +} from "@/utils/secureCredentials"; +import { store } from "@/utils/store"; + +interface UseTVUserSwitchModalOptions { + onAccountSelect: (account: SavedServerAccount) => void; +} + +export function useTVUserSwitchModal() { + const router = useRouter(); + + const showUserSwitchModal = useCallback( + ( + server: SavedServer, + currentUserId: string, + options: UseTVUserSwitchModalOptions, + ) => { + // Need at least 2 accounts (current + at least one other) + if (server.accounts.length < 2) { + return; + } + + store.set(tvUserSwitchModalAtom, { + serverUrl: server.address, + serverName: server.name || server.address, + accounts: server.accounts, + currentUserId, + onAccountSelect: options.onAccountSelect, + }); + + router.push("/(auth)/tv-user-switch-modal"); + }, + [router], + ); + + return { showUserSwitchModal }; +} diff --git a/hooks/useWatchlistMutations.ts b/hooks/useWatchlistMutations.ts index e3e39ef96..5e65ebf99 100644 --- a/hooks/useWatchlistMutations.ts +++ b/hooks/useWatchlistMutations.ts @@ -177,6 +177,9 @@ export const useAddToWatchlist = () => { } }, onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ["streamystats", "watchlists"], + }); queryClient.invalidateQueries({ queryKey: ["streamystats", "watchlist", variables.watchlistId], }); @@ -235,6 +238,9 @@ export const useRemoveFromWatchlist = () => { } }, onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ["streamystats", "watchlists"], + }); queryClient.invalidateQueries({ queryKey: ["streamystats", "watchlist", variables.watchlistId], }); diff --git a/hooks/useWifiSSID.ts b/hooks/useWifiSSID.ts index 05005cfe6..2b442a582 100644 --- a/hooks/useWifiSSID.ts +++ b/hooks/useWifiSSID.ts @@ -1,5 +1,5 @@ -import * as Location from "expo-location"; import { useCallback, useEffect, useState } from "react"; +import { Platform } from "react-native"; import { getSSID } from "@/modules/wifi-ssid"; export type PermissionStatus = @@ -15,13 +15,28 @@ export interface UseWifiSSIDReturn { isLoading: boolean; } -function mapLocationStatus( - status: Location.PermissionStatus, -): PermissionStatus { +// WiFi SSID is not available on tvOS +if (Platform.isTV) { + // Export a stub hook for tvOS + module.exports = { + useWifiSSID: (): UseWifiSSIDReturn => ({ + ssid: null, + permissionStatus: "unavailable" as PermissionStatus, + requestPermission: async () => false, + isLoading: false, + }), + }; +} + +// Only import Location on non-TV platforms +const Location = Platform.isTV ? null : require("expo-location"); + +function mapLocationStatus(status: number | undefined): PermissionStatus { + if (!Location) return "unavailable"; switch (status) { - case Location.PermissionStatus.GRANTED: + case Location.PermissionStatus?.GRANTED: return "granted"; - case Location.PermissionStatus.DENIED: + case Location.PermissionStatus?.DENIED: return "denied"; default: return "undetermined"; @@ -30,17 +45,24 @@ function mapLocationStatus( export function useWifiSSID(): UseWifiSSIDReturn { const [ssid, setSSID] = useState(null); - const [permissionStatus, setPermissionStatus] = - useState("undetermined"); - const [isLoading, setIsLoading] = useState(true); + const [permissionStatus, setPermissionStatus] = useState( + Platform.isTV ? "unavailable" : "undetermined", + ); + const [isLoading, setIsLoading] = useState(!Platform.isTV); const fetchSSID = useCallback(async () => { + if (Platform.isTV) return; const result = await getSSID(); console.log("[WiFi Debug] Native module SSID:", result); setSSID(result); }, []); const requestPermission = useCallback(async (): Promise => { + if (Platform.isTV || !Location) { + setPermissionStatus("unavailable"); + return false; + } + try { const { status } = await Location.requestForegroundPermissionsAsync(); const newStatus = mapLocationStatus(status); @@ -58,6 +80,11 @@ export function useWifiSSID(): UseWifiSSIDReturn { }, [fetchSSID]); useEffect(() => { + if (Platform.isTV || !Location) { + setIsLoading(false); + return; + } + async function initialize() { setIsLoading(true); try { @@ -79,6 +106,8 @@ export function useWifiSSID(): UseWifiSSIDReturn { // Refresh SSID when permission status changes to granted useEffect(() => { + if (Platform.isTV) return; + if (permissionStatus === "granted") { fetchSSID(); diff --git a/i18n.ts b/i18n.ts index d462efdfd..0eb92a068 100644 --- a/i18n.ts +++ b/i18n.ts @@ -29,7 +29,7 @@ import vi from "./translations/vi.json"; import zhCN from "./translations/zh-CN.json"; import zhTW from "./translations/zh-TW.json"; -export const APP_LANGUAGES = [ +const _APP_LANGUAGES = [ { label: "Catalan", value: "ca" }, { label: "العربية", value: "ar" }, { label: "Dansk", value: "da" }, @@ -57,7 +57,9 @@ export const APP_LANGUAGES = [ { label: "简体中文", value: "zh-CN" }, { label: "繁體中文", value: "zh-TW" }, { label: "Tiếng Việt", value: "vi" }, -]; +].sort((a, b) => a.label.localeCompare(b.label)); + +export const APP_LANGUAGES = _APP_LANGUAGES; i18n.use(initReactI18next).init({ compatibilityJSON: "v4", diff --git a/index.js b/index.js index dbc9139f3..7a0294a3e 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,10 @@ import "react-native-url-polyfill/auto"; -import TrackPlayer from "react-native-track-player"; -import { PlaybackService } from "./services/PlaybackService"; +import { Platform } from "react-native"; import "expo-router/entry"; -TrackPlayer.registerPlaybackService(() => PlaybackService); +// TrackPlayer is not supported on tvOS +if (!Platform.isTV) { + const TrackPlayer = require("react-native-track-player").default; + const { PlaybackService } = require("./services/PlaybackService"); + TrackPlayer.registerPlaybackService(() => PlaybackService); +} diff --git a/modules/glass-poster/expo-module.config.json b/modules/glass-poster/expo-module.config.json new file mode 100644 index 000000000..9c325f521 --- /dev/null +++ b/modules/glass-poster/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["GlassPosterModule"] + } +} diff --git a/modules/glass-poster/index.ts b/modules/glass-poster/index.ts new file mode 100644 index 000000000..f448ad096 --- /dev/null +++ b/modules/glass-poster/index.ts @@ -0,0 +1,8 @@ +// Glass Poster - Native SwiftUI glass effect for tvOS 26+ + +export * from "./src/GlassPoster.types"; +export { + default as GlassPosterModule, + isGlassEffectAvailable, +} from "./src/GlassPosterModule"; +export { default as GlassPosterView } from "./src/GlassPosterView"; diff --git a/modules/glass-poster/ios/GlassPoster.podspec b/modules/glass-poster/ios/GlassPoster.podspec new file mode 100644 index 000000000..60e5af697 --- /dev/null +++ b/modules/glass-poster/ios/GlassPoster.podspec @@ -0,0 +1,23 @@ +Pod::Spec.new do |s| + s.name = 'GlassPoster' + s.version = '1.0.0' + s.summary = 'Native SwiftUI glass effect poster for tvOS' + s.description = 'Provides Liquid Glass effect poster cards for tvOS 26+' + s.author = 'Streamyfin' + s.homepage = 'https://github.com/streamyfin/streamyfin' + s.platforms = { + :ios => '15.1', + :tvos => '15.1' + } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_VERSION' => '5.9' + } + + s.source_files = "*.{h,m,mm,swift}" +end diff --git a/modules/glass-poster/ios/GlassPosterExpoView.swift b/modules/glass-poster/ios/GlassPosterExpoView.swift new file mode 100644 index 000000000..d2d654c58 --- /dev/null +++ b/modules/glass-poster/ios/GlassPosterExpoView.swift @@ -0,0 +1,94 @@ +import ExpoModulesCore +import SwiftUI +import UIKit +import Combine + +/// Observable state that SwiftUI can watch for changes without rebuilding the entire view +class GlassPosterState: ObservableObject { + @Published var imageUrl: String? = nil + @Published var aspectRatio: Double = 10.0 / 15.0 + @Published var cornerRadius: Double = 24 + @Published var progress: Double = 0 + @Published var showWatchedIndicator: Bool = false + @Published var isFocused: Bool = false + @Published var width: Double = 260 +} + +/// ExpoView wrapper that hosts the SwiftUI GlassPosterView +class GlassPosterExpoView: ExpoView { + private var hostingController: UIHostingController? + private let state = GlassPosterState() + + // Stored dimensions for intrinsic content size + private var posterWidth: CGFloat = 260 + private var posterAspectRatio: CGFloat = 10.0 / 15.0 + + // Event dispatchers + let onLoad = EventDispatcher() + let onError = EventDispatcher() + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + setupHostingController() + } + + private func setupHostingController() { + let wrapper = GlassPosterViewWrapper(state: state) + let hostingController = UIHostingController(rootView: wrapper) + hostingController.view.backgroundColor = .clear + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + + addSubview(hostingController.view) + + NSLayoutConstraint.activate([ + hostingController.view.topAnchor.constraint(equalTo: topAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: trailingAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + self.hostingController = hostingController + } + + // Override intrinsic content size for proper React Native layout + override var intrinsicContentSize: CGSize { + let height = posterWidth / posterAspectRatio + return CGSize(width: posterWidth, height: height) + } + + // MARK: - Property Setters + // These now update the observable state object directly. + // SwiftUI observes state changes and only re-renders affected views. + + func setImageUrl(_ url: String?) { + state.imageUrl = url + } + + func setAspectRatio(_ ratio: Double) { + state.aspectRatio = ratio + posterAspectRatio = CGFloat(ratio) + invalidateIntrinsicContentSize() + } + + func setWidth(_ width: Double) { + state.width = width + posterWidth = CGFloat(width) + invalidateIntrinsicContentSize() + } + + func setCornerRadius(_ radius: Double) { + state.cornerRadius = radius + } + + func setProgress(_ progress: Double) { + state.progress = progress + } + + func setShowWatchedIndicator(_ show: Bool) { + state.showWatchedIndicator = show + } + + func setIsFocused(_ focused: Bool) { + state.isFocused = focused + } +} diff --git a/modules/glass-poster/ios/GlassPosterModule.swift b/modules/glass-poster/ios/GlassPosterModule.swift new file mode 100644 index 000000000..3b9b9b194 --- /dev/null +++ b/modules/glass-poster/ios/GlassPosterModule.swift @@ -0,0 +1,50 @@ +import ExpoModulesCore + +public class GlassPosterModule: Module { + public func definition() -> ModuleDefinition { + Name("GlassPoster") + + // Check if glass effect is available (tvOS 26+) + Function("isGlassEffectAvailable") { () -> Bool in + #if os(tvOS) + if #available(tvOS 26.0, *) { + return true + } + #endif + return false + } + + // Native view component + View(GlassPosterExpoView.self) { + Prop("imageUrl") { (view: GlassPosterExpoView, url: String?) in + view.setImageUrl(url) + } + + Prop("aspectRatio") { (view: GlassPosterExpoView, ratio: Double) in + view.setAspectRatio(ratio) + } + + Prop("cornerRadius") { (view: GlassPosterExpoView, radius: Double) in + view.setCornerRadius(radius) + } + + Prop("progress") { (view: GlassPosterExpoView, progress: Double) in + view.setProgress(progress) + } + + Prop("showWatchedIndicator") { (view: GlassPosterExpoView, show: Bool) in + view.setShowWatchedIndicator(show) + } + + Prop("isFocused") { (view: GlassPosterExpoView, focused: Bool) in + view.setIsFocused(focused) + } + + Prop("width") { (view: GlassPosterExpoView, width: Double) in + view.setWidth(width) + } + + Events("onLoad", "onError") + } + } +} diff --git a/modules/glass-poster/ios/GlassPosterView.swift b/modules/glass-poster/ios/GlassPosterView.swift new file mode 100644 index 000000000..8c8e4f5f1 --- /dev/null +++ b/modules/glass-poster/ios/GlassPosterView.swift @@ -0,0 +1,219 @@ +import SwiftUI + +/// Wrapper view that observes state changes from GlassPosterState +/// This allows SwiftUI to efficiently update only the changed properties +/// instead of rebuilding the entire view hierarchy on every prop change. +struct GlassPosterViewWrapper: View { + @ObservedObject var state: GlassPosterState + + var body: some View { + GlassPosterView( + imageUrl: state.imageUrl, + aspectRatio: state.aspectRatio, + cornerRadius: state.cornerRadius, + progress: state.progress, + showWatchedIndicator: state.showWatchedIndicator, + isFocused: state.isFocused, + width: state.width + ) + } +} + +/// SwiftUI view with tvOS 26 Liquid Glass effect +struct GlassPosterView: View { + var imageUrl: String? = nil + var aspectRatio: Double = 10.0 / 15.0 + var cornerRadius: Double = 24 + var progress: Double = 0 + var showWatchedIndicator: Bool = false + var isFocused: Bool = false + var width: Double = 260 + + // Internal focus state for tvOS + @FocusState private var isInternallyFocused: Bool + + // Combined focus state (external prop OR internal focus) + private var isCurrentlyFocused: Bool { + isFocused || isInternallyFocused + } + + // Calculated height based on width and aspect ratio + private var height: Double { + width / aspectRatio + } + + var body: some View { + #if os(tvOS) + if #available(tvOS 26.0, *) { + glassContent + } else { + fallbackContent + } + #else + fallbackContent + #endif + } + + // MARK: - tvOS 26+ Content (glass effect disabled for now) + + #if os(tvOS) + @available(tvOS 26.0, *) + private var glassContent: some View { + return ZStack { + // Image content + imageContent + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + + // White border on focus + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(Color.white, lineWidth: isCurrentlyFocused ? 4 : 0) + + // Progress bar overlay + if progress > 0 { + progressOverlay + } + + // Watched indicator + if showWatchedIndicator { + watchedIndicatorOverlay + } + } + .frame(width: width, height: height) + .focusable() + .focused($isInternallyFocused) + .scaleEffect(isCurrentlyFocused ? 1.05 : 1.0) + .animation(.easeOut(duration: 0.15), value: isCurrentlyFocused) + } + #endif + + // MARK: - Fallback for older tvOS versions + + private var fallbackContent: some View { + ZStack { + // Main image + imageContent + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + + // White border on focus + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(Color.white, lineWidth: isFocused ? 4 : 0) + + // Progress bar overlay + if progress > 0 { + progressOverlay + } + + // Watched indicator + if showWatchedIndicator { + watchedIndicatorOverlay + } + } + .frame(width: width, height: height) + .scaleEffect(isFocused ? 1.05 : 1.0) + .animation(.easeOut(duration: 0.15), value: isFocused) + } + + // MARK: - Shared Components + + private var imageContent: some View { + Group { + if let urlString = imageUrl, let url = URL(string: urlString) { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + placeholderView + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: width, height: height) + .clipped() + case .failure: + placeholderView + @unknown default: + placeholderView + } + } + } else { + placeholderView + } + } + } + + private var placeholderView: some View { + Rectangle() + .fill(Color.gray.opacity(0.3)) + } + + private var progressOverlay: some View { + VStack { + Spacer() + GeometryReader { geometry in + ZStack(alignment: .leading) { + // Background track + Rectangle() + .fill(Color.white.opacity(0.3)) + .frame(height: 4) + + // Progress fill + Rectangle() + .fill(Color.white) + .frame(width: geometry.size.width * CGFloat(progress / 100), height: 4) + } + } + .frame(height: 4) + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + } + + private var watchedIndicatorOverlay: some View { + VStack { + HStack { + Spacer() + ZStack { + Circle() + .fill(Color.white.opacity(0.9)) + .frame(width: 28, height: 28) + + Image(systemName: "checkmark") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.black) + } + .padding(8) + } + Spacer() + } + } +} + +// MARK: - Preview + +#if DEBUG +struct GlassPosterView_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 20) { + GlassPosterView( + imageUrl: "https://image.tmdb.org/t/p/w500/example.jpg", + aspectRatio: 10.0 / 15.0, + cornerRadius: 24, + progress: 45, + showWatchedIndicator: false, + isFocused: true, + width: 260 + ) + + GlassPosterView( + imageUrl: "https://image.tmdb.org/t/p/w500/example.jpg", + aspectRatio: 16.0 / 9.0, + cornerRadius: 24, + progress: 75, + showWatchedIndicator: true, + isFocused: false, + width: 400 + ) + } + .padding() + .background(Color.black) + } +} +#endif diff --git a/modules/glass-poster/src/GlassPoster.types.ts b/modules/glass-poster/src/GlassPoster.types.ts new file mode 100644 index 000000000..8878779b1 --- /dev/null +++ b/modules/glass-poster/src/GlassPoster.types.ts @@ -0,0 +1,26 @@ +import type { StyleProp, ViewStyle } from "react-native"; + +export interface GlassPosterViewProps { + /** URL of the image to display */ + imageUrl: string | null; + /** Aspect ratio of the poster (width/height). Default: 10/15 for portrait, 16/9 for landscape */ + aspectRatio: number; + /** Corner radius in points. Default: 24 */ + cornerRadius: number; + /** Progress percentage (0-100). Shows progress bar at bottom when > 0 */ + progress: number; + /** Whether to show the watched checkmark indicator */ + showWatchedIndicator: boolean; + /** Whether the poster is currently focused (for scale animation) */ + isFocused: boolean; + /** Width of the poster in points. Required for proper sizing. */ + width: number; + /** Style for the container view */ + style?: StyleProp; + /** Called when the image loads successfully */ + onLoad?: () => void; + /** Called when image loading fails */ + onError?: (error: string) => void; +} + +export type GlassPosterModuleEvents = Record; diff --git a/modules/glass-poster/src/GlassPosterModule.ts b/modules/glass-poster/src/GlassPosterModule.ts new file mode 100644 index 000000000..20c2714f3 --- /dev/null +++ b/modules/glass-poster/src/GlassPosterModule.ts @@ -0,0 +1,43 @@ +import { NativeModule, requireNativeModule } from "expo"; +import { Platform } from "react-native"; + +import type { GlassPosterModuleEvents } from "./GlassPoster.types"; + +declare class GlassPosterModuleType extends NativeModule { + isGlassEffectAvailable(): boolean; +} + +// Only load the native module on tvOS +let GlassPosterNativeModule: GlassPosterModuleType | null = null; + +if (Platform.OS === "ios" && Platform.isTV) { + try { + GlassPosterNativeModule = + requireNativeModule("GlassPoster"); + } catch { + // Module not available, will use fallback + } +} + +/** + * Check if the native glass effect is available (tvOS 26+) + * NOTE: Glass effect is currently disabled for performance reasons. + * The native module rebuilds views on every focus change which causes lag. + * Re-enable by uncommenting the native module check below. + */ +export function isGlassEffectAvailable(): boolean { + // Glass effect disabled - using JS-based focus effects instead + return false; + + // Original implementation (re-enable when glass effect is optimized): + // if (!GlassPosterNativeModule) { + // return false; + // } + // try { + // return GlassPosterNativeModule.isGlassEffectAvailable(); + // } catch { + // return false; + // } +} + +export default GlassPosterNativeModule; diff --git a/modules/glass-poster/src/GlassPosterView.tsx b/modules/glass-poster/src/GlassPosterView.tsx new file mode 100644 index 000000000..0ec104f5c --- /dev/null +++ b/modules/glass-poster/src/GlassPosterView.tsx @@ -0,0 +1,46 @@ +import { requireNativeView } from "expo"; +import * as React from "react"; +import { Platform, View } from "react-native"; + +import type { GlassPosterViewProps } from "./GlassPoster.types"; +import { isGlassEffectAvailable } from "./GlassPosterModule"; + +// Only require the native view on tvOS +let NativeGlassPosterView: React.ComponentType | null = + null; + +if (Platform.OS === "ios" && Platform.isTV) { + try { + NativeGlassPosterView = + requireNativeView("GlassPoster"); + } catch { + // Module not available + } +} + +/** + * GlassPosterView - Native SwiftUI glass effect poster for tvOS 26+ + * + * On tvOS 26+: Renders with native Liquid Glass effect + * On older tvOS: Renders with subtle glass-like material effect + * On other platforms: Returns null (use existing poster components) + */ +const GlassPosterView: React.FC = (props) => { + // Only render on tvOS + if (!Platform.isTV || Platform.OS !== "ios") { + return null; + } + + // Use native view if available + if (NativeGlassPosterView) { + return ; + } + + // Fallback: return empty view (caller should handle this) + return ; +}; + +export default GlassPosterView; + +// Re-export availability check for convenience +export { isGlassEffectAvailable }; diff --git a/modules/glass-poster/src/index.ts b/modules/glass-poster/src/index.ts new file mode 100644 index 000000000..eee2be164 --- /dev/null +++ b/modules/glass-poster/src/index.ts @@ -0,0 +1,6 @@ +export * from "./GlassPoster.types"; +export { + default as GlassPosterModule, + isGlassEffectAvailable, +} from "./GlassPosterModule"; +export { default as GlassPosterView } from "./GlassPosterView"; diff --git a/modules/index.ts b/modules/index.ts index e026be73b..1f2b458f0 100644 --- a/modules/index.ts +++ b/modules/index.ts @@ -7,7 +7,9 @@ export type { DownloadStartedEvent, } from "./background-downloader"; export { default as BackgroundDownloader } from "./background-downloader"; - +// Glass Poster (tvOS 26+) +export type { GlassPosterViewProps } from "./glass-poster"; +export { GlassPosterView, isGlassEffectAvailable } from "./glass-poster"; // MPV Player (iOS + Android) export type { AudioTrack as MpvAudioTrack, @@ -22,3 +24,16 @@ export type { VideoSource as MpvVideoSource, } from "./mpv-player"; export { MpvPlayerView } from "./mpv-player"; +// Top Shelf cache (tvOS) +export type { + TopShelfCacheItem, + TopShelfCachePayload, + TopShelfCacheSection, +} from "./top-shelf-cache"; +export { clearTopShelfCache, writeTopShelfCache } from "./top-shelf-cache"; +// TV recommendations (Android TV) +export { + clearTvRecommendations, + refreshTvRecommendations, + syncTvRecommendations, +} from "./tv-recommendations"; diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt index 38c55625c..753bfb28f 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt @@ -1,6 +1,8 @@ package expo.modules.mpvplayer +import android.app.UiModeManager import android.content.Context +import android.content.res.Configuration import android.content.res.AssetManager import android.os.Handler import android.os.Looper @@ -27,7 +29,12 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { const val MPV_FORMAT_DOUBLE = 5 const val MPV_FORMAT_NODE = 6 } - + + private fun isTvDevice(): Boolean { + val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager + return uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION + } + interface Delegate { fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) fun onPauseChanged(isPaused: Boolean) @@ -98,7 +105,12 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { val duration: Double get() = cachedDuration - fun start() { + /** + * The VO driver to use. Stored so attachSurface can re-enable the same driver. + */ + private var voDriver: String = "gpu-next" + + fun start(voDriver: String = "gpu-next") { if (isRunning) return try { @@ -152,12 +164,21 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { MPVLib.setOptionString("config-dir", mpvDir.path) // Configure mpv options before initialization (based on Findroid) - MPVLib.setOptionString("vo", "gpu") + this.voDriver = voDriver + MPVLib.setOptionString("vo", voDriver) MPVLib.setOptionString("gpu-context", "android") MPVLib.setOptionString("opengl-es", "yes") // Hardware video decoding - MPVLib.setOptionString("hwdec", "mediacodec-copy") + // TV: zero-copy (mediacodec) for better performance on low-power devices + // Mobile: copy mode (mediacodec-copy) for better compatibility + val isTV = isTvDevice() + if (isTV) { + MPVLib.setOptionString("hwdec", "mediacodec") + MPVLib.setOptionString("profile", "fast") + } else { + MPVLib.setOptionString("hwdec", "mediacodec-copy") + } MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1") // Cache settings for better network streaming @@ -215,37 +236,43 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { } /** - * Attach surface and re-enable video output. - * Based on Findroid's implementation. + * Attach surface and ensure video output is active. + * + * During PiP transitions, the surface is destroyed and recreated by Android. + * We keep the VO pipeline alive (not killed with vo=null) so that rendering + * resumes immediately when the new surface is attached — avoiding the black + * screen that occurs when the VO is fully re-initialized via setOptionString. */ fun attachSurface(surface: Surface) { this.surface = surface + Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}") if (isRunning) { MPVLib.attachSurface(surface) - // Re-enable video output after attaching surface (Findroid approach) MPVLib.setOptionString("force-window", "yes") - MPVLib.setOptionString("vo", "gpu") - Log.i(TAG, "Surface attached, video output re-enabled") + // Read back vo to confirm it's still active + val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null } + Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo") } } - + /** - * Detach surface and disable video output. - * Based on Findroid's implementation. + * Detach surface without killing the VO pipeline. + * + * The previous approach (vo=null / force-window=no) destroyed the entire video + * output pipeline on every surface transition. During PiP mode, the rapid + * destroy/recreate cycle caused a black screen because setOptionString("vo", ...) + * did not properly re-initialize rendering into the new PiP surface. + * + * By keeping the VO alive, frames are simply dropped while no surface is + * attached, and rendering resumes immediately when the new surface arrives. */ fun detachSurface() { this.surface = null + Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver") if (isRunning) { - try { - // Disable video output before detaching surface (Findroid approach) - MPVLib.setOptionString("vo", "null") - MPVLib.setOptionString("force-window", "no") - Log.i(TAG, "Video output disabled before surface detach") - } catch (e: Exception) { - Log.e(TAG, "Failed to disable video output: ${e.message}") - } - MPVLib.detachSurface() + val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null } + Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)") } } @@ -256,7 +283,24 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { fun updateSurfaceSize(width: Int, height: Int) { if (isRunning) { MPVLib.setPropertyString("android-surface-size", "${width}x$height") - Log.i(TAG, "Surface size updated: ${width}x$height") + Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}") + } else { + Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running") + } + } + + /** + * Force mpv to render a frame to the current surface. + * Steps forward one frame then seeks back to the original position. + * Used after PiP entry to work around mpv stopping pixel output. + */ + fun forceRedraw() { + if (!isRunning) return + val pos = cachedPosition + Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos") + MPVLib.command(arrayOf("frame-step")) + if (pos > 0) { + MPVLib.command(arrayOf("seek", pos.toString(), "absolute")) } } @@ -447,7 +491,19 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { fun setSubtitleFontSize(size: Int) { MPVLib.setPropertyInt("sub-font-size", size) } - + + fun setSubtitleBorderStyle(style: String) { + MPVLib.setPropertyString("sub-border-style", style) + } + + fun setSubtitleBackgroundColor(color: String) { + MPVLib.setPropertyString("sub-back-color", color) + } + + fun setSubtitleAssOverride(mode: String) { + MPVLib.setPropertyString("sub-ass-override", mode) + } + // MARK: - Audio Track Controls fun getAudioTracks(): List> { @@ -546,6 +602,16 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { info["droppedFrames"] = it } + // Active video output driver (read from MPV to confirm what's actually applied) + MPVLib.getPropertyString("vo")?.let { + info["voDriver"] = it + } + + // Active hardware decoder + MPVLib.getPropertyString("hwdec-active")?.let { + info["hwdec"] = it + } + return info } @@ -641,11 +707,19 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { MPVLib.command(arrayOf("sub-add", subUrl, "auto")) } pendingExternalSubtitles = emptyList() - - // Set subtitle after external subs are added - initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles() } - + + // Apply the initial audio/subtitle selection now that the file's + // tracks are enumerated. Setting sid/aid before `loadfile` does not + // reliably stick for embedded tracks (the selection is silently + // dropped), so we (re)apply here for embedded and external alike. + // This is what makes a carried-over subtitle show up on the next + // episode without a manual re-selection. + if (initialAudioId != null && initialAudioId > 0) { + setAudioTrack(initialAudioId) + } + initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles() + if (!isReadyToSeek) { isReadyToSeek = true mainHandler.post { delegate?.onReadyToSeek() } diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt index 1735c14c9..2d1cfddd5 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt @@ -37,7 +37,8 @@ class MpvPlayerModule : Module() { startPosition = (source["startPosition"] as? Number)?.toDouble(), autoplay = (source["autoplay"] as? Boolean) ?: true, initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(), - initialAudioId = (source["initialAudioId"] as? Number)?.toInt() + initialAudioId = (source["initialAudioId"] as? Number)?.toInt(), + voDriver = source["voDriver"] as? String ) view.loadVideo(config) @@ -157,6 +158,18 @@ class MpvPlayerModule : Module() { view.setSubtitleFontSize(size) } + AsyncFunction("setSubtitleBorderStyle") { view: MpvPlayerView, style: String -> + view.setSubtitleBorderStyle(style) + } + + AsyncFunction("setSubtitleBackgroundColor") { view: MpvPlayerView, color: String -> + view.setSubtitleBackgroundColor(color) + } + + AsyncFunction("setSubtitleAssOverride") { view: MpvPlayerView, mode: String -> + view.setSubtitleAssOverride(mode) + } + // Audio track functions AsyncFunction("getAudioTracks") { view: MpvPlayerView -> view.getAudioTracks() @@ -185,7 +198,7 @@ class MpvPlayerModule : Module() { } // Defines events that the view can send to JavaScript - Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady") + Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange") } } } diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt index 5b8e2dd3f..4df7fe0b3 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt @@ -2,11 +2,15 @@ package expo.modules.mpvplayer import android.content.Context import android.graphics.Color -import android.os.Build +import android.graphics.Rect +import android.graphics.SurfaceTexture +import android.os.Handler +import android.os.Looper import android.util.Log -import android.view.SurfaceHolder -import android.view.SurfaceView -import android.widget.FrameLayout +import android.view.Surface +import android.view.TextureView +import android.view.View +import android.view.ViewGroup import expo.modules.kotlin.AppContext import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.views.ExpoView @@ -21,107 +25,159 @@ data class VideoLoadConfig( val startPosition: Double? = null, val autoplay: Boolean = true, val initialSubtitleId: Int? = null, - val initialAudioId: Int? = null + val initialAudioId: Int? = null, + val voDriver: String? = null ) /** * MpvPlayerView - ExpoView that hosts the MPV player. - * This mirrors the iOS MpvPlayerView implementation. + * Uses TextureView for reliable Picture-in-Picture support. */ -class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), - MPVLayerRenderer.Delegate, SurfaceHolder.Callback { - +class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), + MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener { + companion object { private const val TAG = "MpvPlayerView" } - + // Event dispatchers val onLoad by EventDispatcher() val onPlaybackStateChange by EventDispatcher() val onProgress by EventDispatcher() val onError by EventDispatcher() val onTracksReady by EventDispatcher() - - private var surfaceView: SurfaceView + val onPictureInPictureChange by EventDispatcher() + + private var textureView: TextureView private var renderer: MPVLayerRenderer? = null private var pipController: PiPController? = null - + private var currentUrl: String? = null private var cachedPosition: Double = 0.0 private var cachedDuration: Double = 0.0 private var intendedPlayState: Boolean = false private var surfaceReady: Boolean = false private var pendingConfig: VideoLoadConfig? = null - + private var rendererStarted: Boolean = false + private var pendingSurface: Surface? = null + private var surfaceTexture: SurfaceTexture? = null + + // PiP state tracking + private var isWaitingForPiPTransition: Boolean = false + private var isPiPSurfaceForced: Boolean = false + private val pipHandler = Handler(Looper.getMainLooper()) + init { setBackgroundColor(Color.BLACK) - - // Create SurfaceView for video rendering - surfaceView = SurfaceView(context).apply { - layoutParams = FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT + + // Create TextureView for video rendering (composites into app window for PiP support) + textureView = TextureView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT ) - holder.addCallback(this@MpvPlayerView) + surfaceTextureListener = this@MpvPlayerView } - addView(surfaceView) - - // Initialize renderer - renderer = MPVLayerRenderer(context) - renderer?.delegate = this - + addView(textureView) + // Initialize PiP controller with Expo's AppContext for proper activity access pipController = PiPController(context, appContext) - pipController?.setPlayerView(surfaceView) + pipController?.setPlayerView(textureView) pipController?.delegate = object : PiPController.Delegate { override fun onPlay() { play() } - + override fun onPause() { pause() } - + override fun onSeekBy(seconds: Double) { seekBy(seconds) } + + override fun onPictureInPictureModeChanged(isInPiP: Boolean) { + if (isInPiP) { + if (!isWaitingForPiPTransition) { + isWaitingForPiPTransition = true + pipHandler.removeCallbacksAndMessages(null) + for (delay in longArrayOf(500, 1000, 1500, 2000)) { + pipHandler.postDelayed({ forcePiPBufferSize() }, delay) + } + } + } else { + isWaitingForPiPTransition = false + pipHandler.removeCallbacksAndMessages(null) + restoreFromPiP() + } + onPictureInPictureChange(mapOf("isActive" to isInPiP)) + } } - - // Start the renderer + + // Renderer is created lazily in loadVideo once we have the voDriver setting + renderer = MPVLayerRenderer(context) + renderer?.delegate = this + } + + /** + * Start the renderer with the given VO driver. + * Called lazily on first loadVideo so the voDriver setting is available. + */ + private fun ensureRendererStarted(voDriver: String?) { + if (rendererStarted) return + try { - renderer?.start() + renderer?.start(voDriver ?: "gpu-next") + rendererStarted = true + + pendingSurface?.let { surface -> + renderer?.attachSurface(surface) + pendingSurface = null + } } catch (e: Exception) { Log.e(TAG, "Failed to start renderer: ${e.message}") onError(mapOf("error" to "Failed to start renderer: ${e.message}")) } } - - // MARK: - SurfaceHolder.Callback - - override fun surfaceCreated(holder: SurfaceHolder) { - Log.i(TAG, "Surface created") + + // MARK: - TextureView.SurfaceTextureListener + + override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) { + this.surfaceTexture = surfaceTexture + val surface = Surface(surfaceTexture) + surfaceTexture.setDefaultBufferSize(width, height) surfaceReady = true - renderer?.attachSurface(holder.surface) - + + if (rendererStarted) { + renderer?.attachSurface(surface) + } else { + pendingSurface = surface + } + // If we have a pending load, execute it now pendingConfig?.let { config -> + ensureRendererStarted(config.voDriver) loadVideoInternal(config) pendingConfig = null } } - - override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { - Log.i(TAG, "Surface changed: ${width}x${height}") - // Update MPV with the new surface size (Findroid approach) + + override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) { + surfaceTexture.setDefaultBufferSize(width, height) renderer?.updateSurfaceSize(width, height) } - - override fun surfaceDestroyed(holder: SurfaceHolder) { - Log.i(TAG, "Surface destroyed") + + override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { + this.surfaceTexture = null surfaceReady = false renderer?.detachSurface() + return false // mpv manages the SurfaceTexture } - + + override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) { + // Called every frame — no action needed, mpv drives rendering directly + } + // MARK: - Video Loading fun loadVideo(config: VideoLoadConfig) { @@ -136,12 +192,15 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context return } + // Ensure renderer is started with the configured VO driver + ensureRendererStarted(config.voDriver) + loadVideoInternal(config) } - + private fun loadVideoInternal(config: VideoLoadConfig) { currentUrl = config.url - + renderer?.load( url = config.url, headers = config.headers, @@ -150,138 +209,199 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context initialSubtitleId = config.initialSubtitleId, initialAudioId = config.initialAudioId ) - + if (config.autoplay) { play() } - + onLoad(mapOf("url" to config.url)) } - + // Convenience method for simple loads fun loadVideo(url: String, headers: Map? = null) { loadVideo(VideoLoadConfig(url = url, headers = headers)) } - + // MARK: - Playback Controls - + fun play() { intendedPlayState = true renderer?.play() pipController?.setPlaybackRate(1.0) } - + fun pause() { intendedPlayState = false renderer?.pause() pipController?.setPlaybackRate(0.0) } - + fun seekTo(position: Double) { renderer?.seekTo(position) } - + fun seekBy(offset: Double) { renderer?.seekBy(offset) } - + fun setSpeed(speed: Double) { renderer?.setSpeed(speed) } - + fun getSpeed(): Double { return renderer?.getSpeed() ?: 1.0 } - + fun isPaused(): Boolean { return renderer?.isPausedState ?: true } - + fun getCurrentPosition(): Double { return cachedPosition } - + fun getDuration(): Double { return cachedDuration } - + // MARK: - Picture in Picture - + fun startPictureInPicture() { - Log.i(TAG, "startPictureInPicture called") + isWaitingForPiPTransition = true pipController?.startPictureInPicture() + + // Resize buffer to match PiP window after animation settles + pipHandler.removeCallbacksAndMessages(null) + for (delay in longArrayOf(500, 1000, 1500, 2000)) { + pipHandler.postDelayed({ forcePiPBufferSize() }, delay) + } } - + + /** + * Resize the SurfaceTexture buffer AND TextureView layout to match the PiP + * visible rect so mpv renders at the PiP window's actual dimensions. + */ + private fun forcePiPBufferSize() { + if (!isWaitingForPiPTransition || !surfaceReady) return + + val rect = Rect() + textureView.getGlobalVisibleRect(rect) + val visW = rect.width() + val visH = rect.height() + val vw = textureView.width + val vh = textureView.height + + if (visW <= 0 || visH <= 0 || (vw == visW && vh == visH)) return + + surfaceTexture?.setDefaultBufferSize(visW, visH) + renderer?.updateSurfaceSize(visW, visH) + + // Force TextureView layout to match PiP visible area. + // layoutParams alone doesn't work during PiP because the parent + // never re-lays out its children. + textureView.measure( + View.MeasureSpec.makeMeasureSpec(visW, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(visH, View.MeasureSpec.EXACTLY) + ) + textureView.layout(0, 0, visW, visH) + isPiPSurfaceForced = true + } + + private fun restoreFromPiP() { + if (!isPiPSurfaceForced) return + isPiPSurfaceForced = false + + val lp = textureView.layoutParams + lp.width = ViewGroup.LayoutParams.MATCH_PARENT + lp.height = ViewGroup.LayoutParams.MATCH_PARENT + textureView.layoutParams = lp + textureView.requestLayout() + } + fun stopPictureInPicture() { + isWaitingForPiPTransition = false + pipHandler.removeCallbacksAndMessages(null) pipController?.stopPictureInPicture() } - + fun isPictureInPictureSupported(): Boolean { return pipController?.isPictureInPictureSupported() ?: false } - + fun isPictureInPictureActive(): Boolean { return pipController?.isPictureInPictureActive() ?: false } - + // MARK: - Subtitle Controls - + fun getSubtitleTracks(): List> { return renderer?.getSubtitleTracks() ?: emptyList() } - + fun setSubtitleTrack(trackId: Int) { renderer?.setSubtitleTrack(trackId) } - + fun disableSubtitles() { renderer?.disableSubtitles() } - + fun getCurrentSubtitleTrack(): Int { return renderer?.getCurrentSubtitleTrack() ?: 0 } - + fun addSubtitleFile(url: String, select: Boolean = true) { renderer?.addSubtitleFile(url, select) } - + // MARK: - Subtitle Positioning - + fun setSubtitlePosition(position: Int) { renderer?.setSubtitlePosition(position) } - + fun setSubtitleScale(scale: Double) { renderer?.setSubtitleScale(scale) } - + fun setSubtitleMarginY(margin: Int) { renderer?.setSubtitleMarginY(margin) } - + fun setSubtitleAlignX(alignment: String) { renderer?.setSubtitleAlignX(alignment) } - + fun setSubtitleAlignY(alignment: String) { renderer?.setSubtitleAlignY(alignment) } - + fun setSubtitleFontSize(size: Int) { renderer?.setSubtitleFontSize(size) } - + + fun setSubtitleBorderStyle(style: String) { + renderer?.setSubtitleBorderStyle(style) + } + + fun setSubtitleBackgroundColor(color: String) { + renderer?.setSubtitleBackgroundColor(color) + } + + fun setSubtitleAssOverride(mode: String) { + renderer?.setSubtitleAssOverride(mode) + } + // MARK: - Audio Track Controls - + fun getAudioTracks(): List> { return renderer?.getAudioTracks() ?: emptyList() } - + fun setAudioTrack(trackId: Int) { renderer?.setAudioTrack(trackId) } - + fun getCurrentAudioTrack(): Int { return renderer?.getCurrentAudioTrack() ?: 0 } @@ -306,16 +426,16 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context } // MARK: - MPVLayerRenderer.Delegate - + override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) { cachedPosition = position cachedDuration = duration - + // Update PiP progress if (pipController?.isPictureInPictureActive() == true) { pipController?.setCurrentTime(position, duration) } - + onProgress(mapOf( "position" to position, "duration" to duration, @@ -323,50 +443,51 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context "cacheSeconds" to cacheSeconds )) } - + override fun onPauseChanged(isPaused: Boolean) { - // Sync PiP playback rate pipController?.setPlaybackRate(if (isPaused) 0.0 else 1.0) - + onPlaybackStateChange(mapOf( "isPaused" to isPaused, "isPlaying" to !isPaused )) } - + override fun onLoadingChanged(isLoading: Boolean) { onPlaybackStateChange(mapOf( "isLoading" to isLoading )) } - + override fun onReadyToSeek() { onPlaybackStateChange(mapOf( "isReadyToSeek" to true )) } - + override fun onTracksReady() { onTracksReady(emptyMap()) } - + override fun onVideoDimensionsChanged(width: Int, height: Int) { - // Update PiP controller with video dimensions for proper aspect ratio pipController?.setVideoDimensions(width, height) } - + override fun onError(message: String) { onError(mapOf("error" to message)) } - + // MARK: - Cleanup - + fun cleanup() { + isWaitingForPiPTransition = false + pipHandler.removeCallbacksAndMessages(null) pipController?.stopPictureInPicture() renderer?.stop() - surfaceView.holder.removeCallback(this) + surfaceTexture = null + surfaceReady = false } - + override fun onDetachedFromWindow() { super.onDetachedFromWindow() cleanup() diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt index 438ccaa1f..2a24440bf 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt @@ -1,51 +1,62 @@ package expo.modules.mpvplayer import android.app.Activity +import android.app.Application import android.app.PictureInPictureParams +import android.app.RemoteAction +import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager +import android.graphics.drawable.Icon import android.graphics.Rect import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.util.Log import android.util.Rational import android.view.View import androidx.annotation.RequiresApi import expo.modules.kotlin.AppContext -/** - * Picture-in-Picture controller for Android. - * This mirrors the iOS PiPController implementation. - */ class PiPController(private val context: Context, private val appContext: AppContext? = null) { - + companion object { private const val TAG = "PiPController" private const val DEFAULT_ASPECT_WIDTH = 16 private const val DEFAULT_ASPECT_HEIGHT = 9 + private const val ACTION_PIP_PLAY_PAUSE = "expo.modules.mpvplayer.PIP_PLAY_PAUSE" + private const val ACTION_PIP_SKIP_FORWARD = "expo.modules.mpvplayer.PIP_SKIP_FORWARD" + private const val ACTION_PIP_SKIP_BACKWARD = "expo.modules.mpvplayer.PIP_SKIP_BACKWARD" } - + interface Delegate { fun onPlay() fun onPause() fun onSeekBy(seconds: Double) + fun onPictureInPictureModeChanged(isInPiP: Boolean) } - + var delegate: Delegate? = null - + private var currentPosition: Double = 0.0 private var currentDuration: Double = 0.0 private var playbackRate: Double = 1.0 - - // Video dimensions for proper aspect ratio + private var videoWidth: Int = 0 private var videoHeight: Int = 0 - - // Reference to the player view for source rect private var playerView: View? = null - - /** - * Check if Picture-in-Picture is supported on this device - */ + + // PiP state tracking + private var isInPiPMode: Boolean = false + private var pipEntryNotified: Boolean = false + private val pipHandler = Handler(Looper.getMainLooper()) + private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null + private var lifecycleRegistered = false + private var pipBroadcastReceiver: BroadcastReceiver? = null + fun isPictureInPictureSupported(): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) @@ -53,10 +64,7 @@ class PiPController(private val context: Context, private val appContext: AppCon false } } - - /** - * Check if Picture-in-Picture is currently active - */ + fun isPictureInPictureActive(): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val activity = getActivity() @@ -64,69 +72,69 @@ class PiPController(private val context: Context, private val appContext: AppCon } return false } - - /** - * Start Picture-in-Picture mode - */ + fun startPictureInPicture() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val activity = getActivity() - if (activity == null) { - Log.e(TAG, "Cannot start PiP: no activity found") + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + val activity = getActivity() ?: run { + Log.e(TAG, "Cannot start PiP: no activity") + return + } + + if (!isPictureInPictureSupported()) { + Log.e(TAG, "PiP not supported on this device") + return + } + + try { + val params = buildPiPParams(forEntering = true) + val result = activity.enterPictureInPictureMode(params) + + if (!result) { + Log.e(TAG, "enterPictureInPictureMode rejected by system") + isInPiPMode = false return } - - if (!isPictureInPictureSupported()) { - Log.e(TAG, "PiP not supported on this device") - return - } - - try { - val params = buildPiPParams(forEntering = true) - activity.enterPictureInPictureMode(params) - Log.i(TAG, "Entered PiP mode") - } catch (e: Exception) { - Log.e(TAG, "Failed to enter PiP: ${e.message}") - } - } else { - Log.w(TAG, "PiP requires Android O or higher") + + isInPiPMode = true + pipEntryNotified = true + delegate?.onPictureInPictureModeChanged(true) + registerLifecycleCallbacks() + } catch (e: Exception) { + Log.e(TAG, "Failed to enter PiP: ${e.message}") } } - - /** - * Stop Picture-in-Picture mode - */ + fun stopPictureInPicture() { - // On Android, exiting PiP is typically done by the user - // or by finishing the activity. We can request to move task to back. + isInPiPMode = false + pipEntryNotified = false + unregisterLifecycleCallbacks() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val activity = getActivity() if (activity?.isInPictureInPictureMode == true) { - // Move task to back which will exit PiP activity.moveTaskToBack(false) } } } - - /** - * Update the current playback position and duration - * Note: We don't update PiP params here as we're not using progress in PiP controls - */ + + fun isCurrentlyInPiP(): Boolean = isInPiPMode + fun setCurrentTime(position: Double, duration: Double) { currentPosition = position currentDuration = duration } - - /** - * Set the playback rate (0.0 for paused, 1.0 for playing) - */ + fun setPlaybackRate(rate: Double) { playbackRate = rate - - // Update PiP params to reflect play/pause state + + if (rate > 0) { + registerLifecycleCallbacks() + } + + // Update PiP params so autoEnterEnabled and action icons track play/pause state if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val activity = getActivity() - if (activity?.isInPictureInPictureMode == true) { + if (activity != null) { try { activity.setPictureInPictureParams(buildPiPParams()) } catch (e: Exception) { @@ -135,28 +143,19 @@ class PiPController(private val context: Context, private val appContext: AppCon } } } - - /** - * Set the video dimensions for proper aspect ratio calculation - */ + fun setVideoDimensions(width: Int, height: Int) { if (width > 0 && height > 0) { videoWidth = width videoHeight = height - Log.i(TAG, "Video dimensions set: ${width}x${height}") - - // Update PiP params if active updatePiPParamsIfNeeded() } } - - /** - * Set the player view reference for source rect hint - */ + fun setPlayerView(view: View?) { playerView = view } - + private fun updatePiPParamsIfNeeded() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val activity = getActivity() @@ -169,23 +168,16 @@ class PiPController(private val context: Context, private val appContext: AppCon } } } - - /** - * Build Picture-in-Picture params for the current player state. - * Calculates proper aspect ratio and source rect based on video and view dimensions. - */ + @RequiresApi(Build.VERSION_CODES.O) private fun buildPiPParams(forEntering: Boolean = false): PictureInPictureParams { val view = playerView val viewWidth = view?.width ?: 0 val viewHeight = view?.height ?: 0 - - // Display aspect ratio from view (exactly like Findroid) + val displayAspectRatio = Rational(viewWidth.coerceAtLeast(1), viewHeight.coerceAtLeast(1)) - - // Video aspect ratio with 2.39:1 clamping (exactly like Findroid) - // Findroid: Rational(it.width.coerceAtMost((it.height * 2.39f).toInt()), - // it.height.coerceAtMost((it.width * 2.39f).toInt())) + + // Video aspect ratio with 2.39:1 clamping val aspectRatio = if (videoWidth > 0 && videoHeight > 0) { Rational( videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()), @@ -194,70 +186,235 @@ class PiPController(private val context: Context, private val appContext: AppCon } else { Rational(DEFAULT_ASPECT_WIDTH, DEFAULT_ASPECT_HEIGHT) } - - // Source rect hint calculation (exactly like Findroid) + val sourceRectHint = if (viewWidth > 0 && viewHeight > 0 && videoWidth > 0 && videoHeight > 0) { if (displayAspectRatio < aspectRatio) { - // Letterboxing - black bars top/bottom val space = ((viewHeight - (viewWidth.toFloat() / aspectRatio.toFloat())) / 2).toInt() - Rect( - 0, - space, - viewWidth, - (viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space - ) + Rect(0, space, viewWidth, (viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space) } else { - // Pillarboxing - black bars left/right val space = ((viewWidth - (viewHeight.toFloat() * aspectRatio.toFloat())) / 2).toInt() - Rect( - space, - 0, - (viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space, - viewHeight - ) + Rect(space, 0, (viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space, viewHeight) } } else { null } - + val builder = PictureInPictureParams.Builder() .setAspectRatio(aspectRatio) - + sourceRectHint?.let { builder.setSourceRectHint(it) } - - // On Android 12+, enable auto-enter (like Findroid) + + ensurePiPReceiverRegistered() + builder.setActions(buildPiPActions()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - builder.setAutoEnterEnabled(true) + builder.setAutoEnterEnabled(forEntering || playbackRate > 0) } - + return builder.build() } - + private fun getActivity(): Activity? { - // First try Expo's AppContext (preferred in React Native) appContext?.currentActivity?.let { return it } - - // Fallback: Try to get from context wrapper chain + var ctx = context while (ctx is android.content.ContextWrapper) { - if (ctx is Activity) { - return ctx - } + if (ctx is Activity) return ctx ctx = ctx.baseContext } return null } - - /** - * Handle PiP action (called from activity when user taps PiP controls) - */ - fun handlePiPAction(action: String) { - when (action) { - "play" -> delegate?.onPlay() - "pause" -> delegate?.onPause() - "skip_forward" -> delegate?.onSeekBy(10.0) - "skip_backward" -> delegate?.onSeekBy(-10.0) + + // MARK: - Lifecycle-based PiP Detection + + private fun registerLifecycleCallbacks() { + if (lifecycleRegistered) return + + val app = context.applicationContext as? Application ?: run { + Log.w(TAG, "Cannot access Application for lifecycle callbacks, falling back to polling") + startFallbackPolling() + return } + + lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} + override fun onActivityStarted(activity: Activity) {} + + override fun onActivityResumed(activity: Activity) { + if (!isInPiPMode) return + if (!activity.isInPictureInPictureMode) { + isInPiPMode = false + pipEntryNotified = false + delegate?.onPictureInPictureModeChanged(false) + } + } + + override fun onActivityPaused(activity: Activity) { + // Proactively hide controls when user leaves while playing, + // before the PiP window captures the UI. onActivityStopped + // will restore if PiP didn't actually enter. + if (playbackRate > 0 && !isInPiPMode) { + isInPiPMode = true + pipEntryNotified = true + delegate?.onPictureInPictureModeChanged(true) + } + } + + override fun onActivityStopped(activity: Activity) { + pipHandler.postDelayed({ + val inPip = activity.isInPictureInPictureMode + + if (inPip && !isInPiPMode) { + isInPiPMode = true + pipEntryNotified = true + delegate?.onPictureInPictureModeChanged(true) + return@postDelayed + } + + if (!isInPiPMode) return@postDelayed + if (inPip) return@postDelayed + + // Not in PiP after 1s — check again to avoid false positive during transition + pipHandler.postDelayed({ + if (!isInPiPMode) return@postDelayed + if (!activity.isInPictureInPictureMode) { + isInPiPMode = false + pipEntryNotified = false + delegate?.onPictureInPictureModeChanged(false) + } + }, 1500) + }, 1000) + } + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + + override fun onActivityDestroyed(activity: Activity) { + isInPiPMode = false + } + } + + app.registerActivityLifecycleCallbacks(lifecycleCallbacks) + lifecycleRegistered = true + } + + private fun unregisterLifecycleCallbacks() { + if (!lifecycleRegistered) return + lifecycleCallbacks?.let { + (context.applicationContext as? Application) + ?.unregisterActivityLifecycleCallbacks(it) + } + lifecycleCallbacks = null + lifecycleRegistered = false + pipHandler.removeCallbacksAndMessages(null) + unregisterPiPBroadcastReceiver() + } + + private fun startFallbackPolling() { + var falseReadCount = 0 + pipHandler.removeCallbacksAndMessages(null) + pipHandler.postDelayed(object : Runnable { + override fun run() { + if (!isInPiPMode) return + + var ctx = context + var activity: Activity? = null + while (ctx is android.content.ContextWrapper) { + if (ctx is Activity) { activity = ctx; break } + ctx = ctx.baseContext + } + + val stillInPip = activity?.isInPictureInPictureMode == true + + if (!stillInPip) { + falseReadCount++ + if (falseReadCount >= 3) { + isInPiPMode = false + delegate?.onPictureInPictureModeChanged(false) + return + } + pipHandler.postDelayed(this, 500) + return + } + + falseReadCount = 0 + pipHandler.postDelayed(this, 1000) + } + }, 3000) + } + + // MARK: - PiP Remote Actions + + private fun ensurePiPReceiverRegistered() { + if (pipBroadcastReceiver != null) return + + pipBroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + ACTION_PIP_PLAY_PAUSE -> { + if (playbackRate > 0) delegate?.onPause() else delegate?.onPlay() + } + ACTION_PIP_SKIP_FORWARD -> delegate?.onSeekBy(10.0) + ACTION_PIP_SKIP_BACKWARD -> delegate?.onSeekBy(-10.0) + } + } + } + + val filter = IntentFilter().apply { + addAction(ACTION_PIP_PLAY_PAUSE) + addAction(ACTION_PIP_SKIP_FORWARD) + addAction(ACTION_PIP_SKIP_BACKWARD) + } + val registerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Context.RECEIVER_EXPORTED + } else { + 0 + } + context.applicationContext.registerReceiver(pipBroadcastReceiver, filter, registerFlags) + } + + private fun unregisterPiPBroadcastReceiver() { + pipBroadcastReceiver?.let { + try { + context.applicationContext.unregisterReceiver(it) + } catch (_: Exception) {} + } + pipBroadcastReceiver = null + } + + private fun buildPiPActions(): List { + val isPlaying = playbackRate > 0 + + return listOf( + RemoteAction( + Icon.createWithResource(context, android.R.drawable.ic_media_rew), + "Rewind", "Skip backward 10 seconds", + createPiPPendingIntent(ACTION_PIP_SKIP_BACKWARD) + ), + RemoteAction( + Icon.createWithResource( + context, + if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play + ), + if (isPlaying) "Pause" else "Play", + if (isPlaying) "Pause playback" else "Resume playback", + createPiPPendingIntent(ACTION_PIP_PLAY_PAUSE) + ), + RemoteAction( + Icon.createWithResource(context, android.R.drawable.ic_media_ff), + "Fast Forward", "Skip forward 10 seconds", + createPiPPendingIntent(ACTION_PIP_SKIP_FORWARD) + ) + ) + } + + private fun createPiPPendingIntent(action: String): android.app.PendingIntent { + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + android.app.PendingIntent.FLAG_IMMUTABLE + } else { + 0 + } + return android.app.PendingIntent.getBroadcast( + context.applicationContext, 0, Intent(action), flags + ) } } - diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift index 9cb5540c2..ebd072f7c 100644 --- a/modules/mpv-player/ios/MPVLayerRenderer.swift +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -4,12 +4,21 @@ import CoreMedia import CoreVideo import AVFoundation +/// HDR mode detected from video properties +enum HDRMode { + case sdr + case hdr10 + case dolbyVision + case hlg +} + protocol MPVLayerRendererDelegate: AnyObject { func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) func renderer(_ renderer: MPVLayerRenderer, didChangePause isPaused: Bool) func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool) func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool) func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool) + func renderer(_ renderer: MPVLayerRenderer, didDetectHDRMode mode: HDRMode, fps: Double) func renderer(_ renderer: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) } @@ -22,8 +31,11 @@ final class MPVLayerRenderer { } private let displayLayer: AVSampleBufferDisplayLayer - private let queue = DispatchQueue(label: "mpv.avfoundation", qos: .userInitiated) + private let queue: DispatchQueue private let stateQueue = DispatchQueue(label: "mpv.avfoundation.state", attributes: .concurrent) + + // Key to identify if we're on the mpv queue (to avoid deadlock in stop()) + private static let queueKey = DispatchSpecificKey() private var mpv: OpaquePointer? @@ -34,8 +46,18 @@ final class MPVLayerRenderer { private var initialSubtitleId: Int? private var initialAudioId: Int? - private var isRunning = false - private var isStopping = false + private var _isRunning = false + private var _isStopping = false + + private var isRunning: Bool { + get { stateQueue.sync { _isRunning } } + set { stateQueue.async(flags: .barrier) { self._isRunning = newValue } } + } + + private var isStopping: Bool { + get { stateQueue.sync { _isStopping } } + set { stateQueue.sync(flags: .barrier) { _isStopping = newValue } } // Must be sync for stop() to work + } // KVO observation for display layer status private var statusObservation: NSKeyValueObservation? @@ -108,6 +130,8 @@ final class MPVLayerRenderer { init(displayLayer: AVSampleBufferDisplayLayer) { self.displayLayer = displayLayer + self.queue = DispatchQueue(label: "mpv.avfoundation", qos: .userInitiated) + queue.setSpecific(key: Self.queueKey, value: true) observeDisplayLayerStatus() } @@ -168,10 +192,17 @@ final class MPVLayerRenderer { // Use AVFoundation video output - required for PiP support checkError(mpv_set_option_string(handle, "vo", "avfoundation")) - // Enable composite OSD mode - renders subtitles directly onto video frames using GPU - // This is better for PiP as subtitles are baked into the video - // NOTE: Must be set BEFORE the #if targetEnvironment check or tvOS will freeze on player exit + // Composite OSD mode - renders subtitles directly onto video frames using GPU. + // CRITICAL: Must be set immediately after vo=avfoundation, before hwdec options. + // Moving this elsewhere causes tvOS to freeze when exiting the player. + // tvOS: "no" (breaks subtitle rendering; note: subtitle styling won't work). + // Simulator: "no" (no VideoToolbox support). + // iOS device: "yes" for PiP subtitle support. + #if os(tvOS) || targetEnvironment(simulator) + checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "no")) + #else checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes")) + #endif // Hardware decoding with VideoToolbox // On simulator, use software decoding since VideoToolbox is not available @@ -184,7 +215,15 @@ final class MPVLayerRenderer { checkError(mpv_set_option_string(handle, "hwdec-codecs", "all")) checkError(mpv_set_option_string(handle, "hwdec-software-fallback", "yes")) + // HDR passthrough - signal content colorspace to display system + // This prevents tone-mapping and allows HDR content to pass through + #if os(tvOS) + checkError(mpv_set_option_string(handle, "target-colorspace-hint", "yes")) + #endif + // Subtitle and audio settings + checkError(mpv_set_option_string(mpv, "sub-scale-with-window", "no")) + checkError(mpv_set_option_string(mpv, "sub-use-margins", "no")) checkError(mpv_set_option_string(mpv, "subs-match-os-language", "yes")) checkError(mpv_set_option_string(mpv, "subs-fallback", "yes")) @@ -211,28 +250,50 @@ final class MPVLayerRenderer { if !isRunning, mpv == nil { return } isRunning = false isStopping = true - + // Stop observing display layer status statusObservation?.invalidate() statusObservation = nil - - queue.sync { [weak self] in - guard let self, let handle = self.mpv else { return } - + + // Clear wakeup callback first to stop event processing + if let handle = mpv { mpv_set_wakeup_callback(handle, nil, nil) - mpv_terminate_destroy(handle) - self.mpv = nil + + // Send quit command and drain events on the mpv queue + queue.sync { [weak self] in + guard let self, let handle = self.mpv else { return } + self.commandSync(handle, ["quit"]) + + // Drain any remaining events after quit + var drainCount = 0 + let maxDrain = 100 + while drainCount < maxDrain, let event = mpv_wait_event(handle, 0.1)?.pointee { + if event.event_id == MPV_EVENT_NONE || event.event_id == MPV_EVENT_SHUTDOWN { + break + } + drainCount += 1 + } + } + + // Call mpv_terminate_destroy on a background thread to avoid blocking main + // mpv_terminate_destroy may need main thread for AVFoundation cleanup, + // so we can't call it while blocking main with queue.sync + let handleToDestroy = handle + mpv = nil // Clear immediately so nothing else uses it + DispatchQueue.global(qos: .userInitiated).async { + mpv_terminate_destroy(handleToDestroy) + } } - + DispatchQueue.main.async { [weak self] in guard let self else { return } - if #available(iOS 18.0, *) { + if #available(iOS 18.0, tvOS 17.0, *) { self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil) } else { self.displayLayer.flushAndRemoveImage() } } - + isStopping = false } @@ -243,7 +304,11 @@ final class MPVLayerRenderer { startPosition: Double? = nil, externalSubtitles: [String]? = nil, initialSubtitleId: Int? = nil, - initialAudioId: Int? = nil + initialAudioId: Int? = nil, + cacheEnabled: String? = nil, + cacheSeconds: Int? = nil, + demuxerMaxBytes: Int? = nil, + demuxerMaxBackBytes: Int? = nil ) { currentPreset = preset currentURL = url @@ -266,6 +331,21 @@ final class MPVLayerRenderer { // Stop previous playback before loading new file self.command(handle, ["stop"]) self.updateHTTPHeaders(headers) + + // Apply cache/buffer settings + if let cacheMode = cacheEnabled { + self.setProperty(name: "cache", value: cacheMode) + } + if let cacheSecs = cacheSeconds { + self.setProperty(name: "cache-secs", value: String(cacheSecs)) + } + if let maxBytes = demuxerMaxBytes { + self.setProperty(name: "demuxer-max-bytes", value: "\(maxBytes)MiB") + } + if let maxBackBytes = demuxerMaxBackBytes { + self.setProperty(name: "demuxer-max-back-bytes", value: "\(maxBackBytes)MiB") + } + // Set start position if let startPos = startPosition, startPos > 0 { self.setProperty(name: "start", value: String(format: "%.2f", startPos)) @@ -389,7 +469,7 @@ final class MPVLayerRenderer { private func processEvents() { queue.async { [weak self] in guard let self else { return } - + while self.mpv != nil && !self.isStopping { guard let handle = self.mpv, let eventPointer = mpv_wait_event(handle, 0) else { return } @@ -405,8 +485,7 @@ final class MPVLayerRenderer { switch event.event_id { case MPV_EVENT_FILE_LOADED: // Add external subtitles now that the file is loaded - let hadExternalSubs = !pendingExternalSubtitles.isEmpty - if hadExternalSubs, let handle = mpv { + if !pendingExternalSubtitles.isEmpty, let handle = mpv { for (index, subUrl) in pendingExternalSubtitles.enumerated() { print("🔧 Adding external subtitle [\(index)]: \(subUrl)") // Use commandSync to ensure subs are added in exact order (not async) @@ -414,12 +493,20 @@ final class MPVLayerRenderer { commandSync(handle, ["sub-add", subUrl, "auto"]) } pendingExternalSubtitles = [] - // Set subtitle after external subs are added - if let subId = initialSubtitleId { - setSubtitleTrack(subId) - } else { - disableSubtitles() - } + } + // Apply the initial audio/subtitle selection now that the file's + // tracks are enumerated. Setting sid/aid before `loadfile` does not + // reliably stick for embedded tracks (the selection is silently + // dropped), so we (re)apply here for embedded and external alike. + // This is what makes a carried-over subtitle show up on the next + // episode without a manual re-selection. + if let audioId = initialAudioId, audioId > 0 { + setAudioTrack(audioId) + } + if let subId = initialSubtitleId { + setSubtitleTrack(subId) + } else { + disableSubtitles() } if !isReadyToSeek { isReadyToSeek = true @@ -436,7 +523,10 @@ final class MPVLayerRenderer { self.delegate?.renderer(self, didChangeLoading: false) } } - + + // Detect HDR mode for tvOS display switching + detectHDRMode() + case MPV_EVENT_SEEK: // Seek started - show loading indicator and enable immediate progress updates isSeeking = true @@ -749,7 +839,22 @@ final class MPVLayerRenderer { func setSubtitleFontSize(_ size: Int) { setProperty(name: "sub-font-size", value: String(size)) } - + + func setSubtitleBackgroundColor(_ color: String) { + setProperty(name: "sub-back-color", value: color) + } + + func setSubtitleBorderStyle(_ style: String) { + // "outline-and-shadow" (default) or "background-box" (enables background color) + setProperty(name: "sub-border-style", value: style) + } + + func setSubtitleAssOverride(_ mode: String) { + // Controls whether to override ASS subtitle styles + // "no" = keep ASS styles, "force" = override with user settings + setProperty(name: "sub-ass-override", value: mode) + } + // MARK: - Audio Track Controls func getAudioTracks() -> [[String: Any]] { @@ -817,6 +922,53 @@ final class MPVLayerRenderer { return Int(aid) } + // MARK: - HDR Detection + + /// Detects the HDR mode of the currently playing video by reading mpv properties + private func detectHDRMode() { + guard let handle = mpv else { return } + + // Get video color properties + let primaries = getStringProperty(handle: handle, name: "video-params/primaries") + let gamma = getStringProperty(handle: handle, name: "video-params/gamma") + + // Get FPS for display criteria + var fps: Double = 24.0 + getProperty(handle: handle, name: "container-fps", format: MPV_FORMAT_DOUBLE, value: &fps) + if fps <= 0 { fps = 24.0 } + + Logger.shared.log("HDR Detection - primaries: \(primaries ?? "nil"), gamma: \(gamma ?? "nil"), fps: \(fps)", type: "Info") + + // Determine HDR mode based on color properties + // bt.2020 primaries with PQ gamma = HDR10 or Dolby Vision + // bt.2020 primaries with HLG gamma = HLG + // Otherwise SDR + let hdrMode: HDRMode + + if primaries == "bt.2020" || primaries == "bt.2020-ncl" { + if gamma == "pq" { + // PQ gamma indicates HDR10 or Dolby Vision + // We'll use hdr10 as the base, Dolby Vision detection would need codec inspection + // For DV Profile 8.1, HDR10 fallback should work + hdrMode = .hdr10 + } else if gamma == "hlg" { + hdrMode = .hlg + } else { + // bt.2020 without HDR gamma - still request HDR mode for wide color + hdrMode = .hdr10 + } + } else { + hdrMode = .sdr + } + + Logger.shared.log("HDR Detection - detected mode: \(hdrMode)", type: "Info") + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didDetectHDRMode: hdrMode, fps: fps) + } + } + // MARK: - Technical Info func getTechnicalInfo() -> [String: Any] { diff --git a/modules/mpv-player/ios/MpvPlayer.podspec b/modules/mpv-player/ios/MpvPlayer.podspec index 2a6c2ed66..4aad64440 100644 --- a/modules/mpv-player/ios/MpvPlayer.podspec +++ b/modules/mpv-player/ios/MpvPlayer.podspec @@ -1,32 +1,19 @@ Pod::Spec.new do |s| - s.name = 'MpvPlayer' - s.version = '1.0.0' - s.summary = 'MPVKit for Expo' - s.description = 'MPVKit for Expo' - s.author = 'mpvkit' - s.homepage = 'https://github.com/mpvkit/MPVKit' - s.platforms = { - :ios => '15.1', - :tvos => '15.1' - } - s.source = { git: 'https://github.com/mpvkit/MPVKit.git' } + s.name = 'MpvPlayer' + s.version = '1.0.0' + s.summary = 'MPV-based video player for Streamyfin (Expo module)' + s.author = 'Streamyfin' + s.homepage = 'https://github.com/streamyfin/streamyfin' + s.platforms = { :ios => '15.1', :tvos => '15.1' } + s.source = { git: '' } s.static_framework = true s.dependency 'ExpoModulesCore' - s.dependency 'MPVKit-GPL' + s.dependency 'MPVKit' - # Swift/Objective-C compatibility s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', - 'VALID_ARCHS' => 'arm64', - 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', - 'DEBUG_INFORMATION_FORMAT' => 'dwarf', - 'STRIP_INSTALLED_PRODUCT' => 'YES', - 'DEPLOYMENT_POSTPROCESSING' => 'YES', - } - - s.user_target_xcconfig = { - 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' + 'SWIFT_COMPILATION_MODE' => 'wholemodule' } s.source_files = "*.{h,m,mm,swift,hpp,cpp}" diff --git a/modules/mpv-player/ios/MpvPlayerModule.swift b/modules/mpv-player/ios/MpvPlayerModule.swift index feaf27f66..fe43d8968 100644 --- a/modules/mpv-player/ios/MpvPlayerModule.swift +++ b/modules/mpv-player/ios/MpvPlayerModule.swift @@ -29,7 +29,10 @@ public class MpvPlayerModule: Module { guard let source = source, let urlString = source["url"] as? String, let videoURL = URL(string: urlString) else { return } - + + // Parse cache config if provided + let cacheConfig = source["cacheConfig"] as? [String: Any] + let config = VideoLoadConfig( url: videoURL, headers: source["headers"] as? [String: String], @@ -37,9 +40,13 @@ public class MpvPlayerModule: Module { startPosition: source["startPosition"] as? Double, autoplay: (source["autoplay"] as? Bool) ?? true, initialSubtitleId: source["initialSubtitleId"] as? Int, - initialAudioId: source["initialAudioId"] as? Int + initialAudioId: source["initialAudioId"] as? Int, + cacheEnabled: cacheConfig?["enabled"] as? String, + cacheSeconds: cacheConfig?["cacheSeconds"] as? Int, + demuxerMaxBytes: cacheConfig?["maxBytes"] as? Int, + demuxerMaxBackBytes: cacheConfig?["maxBackBytes"] as? Int ) - + view.loadVideo(config: config) } @@ -165,7 +172,19 @@ public class MpvPlayerModule: Module { AsyncFunction("setSubtitleFontSize") { (view: MpvPlayerView, size: Int) in view.setSubtitleFontSize(size) } - + + AsyncFunction("setSubtitleBackgroundColor") { (view: MpvPlayerView, color: String) in + view.setSubtitleBackgroundColor(color) + } + + AsyncFunction("setSubtitleBorderStyle") { (view: MpvPlayerView, style: String) in + view.setSubtitleBorderStyle(style) + } + + AsyncFunction("setSubtitleAssOverride") { (view: MpvPlayerView, mode: String) in + view.setSubtitleAssOverride(mode) + } + // Audio track functions AsyncFunction("getAudioTracks") { (view: MpvPlayerView) -> [[String: Any]] in return view.getAudioTracks() diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift index 35e0d19b9..0b3158e76 100644 --- a/modules/mpv-player/ios/MpvPlayerView.swift +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -1,3 +1,4 @@ +import AVFAudio import AVFoundation import CoreMedia import ExpoModulesCore @@ -15,7 +16,12 @@ struct VideoLoadConfig { var initialSubtitleId: Int? /// MPV audio track ID to select on start (1-based, nil to use default) var initialAudioId: Int? - + /// Cache/buffer settings + var cacheEnabled: String? // "auto", "yes", or "no" + var cacheSeconds: Int? // Seconds of video to buffer + var demuxerMaxBytes: Int? // Max cache size in MB + var demuxerMaxBackBytes: Int? // Max backward cache size in MB + init( url: URL, headers: [String: String]? = nil, @@ -23,7 +29,11 @@ struct VideoLoadConfig { startPosition: Double? = nil, autoplay: Bool = true, initialSubtitleId: Int? = nil, - initialAudioId: Int? = nil + initialAudioId: Int? = nil, + cacheEnabled: String? = nil, + cacheSeconds: Int? = nil, + demuxerMaxBytes: Int? = nil, + demuxerMaxBackBytes: Int? = nil ) { self.url = url self.headers = headers @@ -32,6 +42,10 @@ struct VideoLoadConfig { self.autoplay = autoplay self.initialSubtitleId = initialSubtitleId self.initialAudioId = initialAudioId + self.cacheEnabled = cacheEnabled + self.cacheSeconds = cacheSeconds + self.demuxerMaxBytes = demuxerMaxBytes + self.demuxerMaxBackBytes = demuxerMaxBackBytes } } @@ -53,6 +67,7 @@ class MpvPlayerView: ExpoView { private var cachedDuration: Double = 0 private var intendedPlayState: Bool = false private var _isZoomedToFill: Bool = false + private var appStateObserver: NSObjectProtocol? // Reference to now playing manager private let nowPlayingManager = MPVNowPlayingManager.shared @@ -66,6 +81,7 @@ class MpvPlayerView: ExpoView { private func setupView() { clipsToBounds = true backgroundColor = .black + configureAudioSession() videoContainer = UIView() videoContainer.translatesAutoresizingMaskIntoConstraints = false @@ -75,9 +91,11 @@ class MpvPlayerView: ExpoView { displayLayer.frame = bounds displayLayer.videoGravity = .resizeAspect + #if !os(tvOS) if #available(iOS 17.0, *) { displayLayer.wantsExtendedDynamicRangeContent = true } + #endif displayLayer.backgroundColor = UIColor.black.cgColor videoContainer.layer.addSublayer(displayLayer) @@ -100,6 +118,17 @@ class MpvPlayerView: ExpoView { } catch { onError(["error": "Failed to start renderer: \(error.localizedDescription)"]) } + + // Pause playback when app enters background on tvOS + #if os(tvOS) + appStateObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.pause() + } + #endif } override func layoutSubviews() { @@ -112,6 +141,20 @@ class MpvPlayerView: ExpoView { CATransaction.commit() } + private func configureAudioSession() { + let audioSession = AVAudioSession.sharedInstance() + do { + try audioSession.setCategory( + .playback, + mode: .moviePlayback, + policy: .longFormAudio, + options: [] + ) + try audioSession.setActive(true) + } catch { + print("Failed to configure audio session: \(error)") + } + } // MARK: - Audio Session & Notifications private func setupNotifications() { @@ -206,13 +249,17 @@ class MpvPlayerView: ExpoView { startPosition: config.startPosition, externalSubtitles: config.externalSubtitles, initialSubtitleId: config.initialSubtitleId, - initialAudioId: config.initialAudioId + initialAudioId: config.initialAudioId, + cacheEnabled: config.cacheEnabled, + cacheSeconds: config.cacheSeconds, + demuxerMaxBytes: config.demuxerMaxBytes, + demuxerMaxBackBytes: config.demuxerMaxBackBytes ) - + if config.autoplay { play() } - + onLoad(["url": config.url.absoluteString]) } @@ -353,6 +400,18 @@ class MpvPlayerView: ExpoView { renderer?.setSubtitleFontSize(size) } + func setSubtitleBackgroundColor(_ color: String) { + renderer?.setSubtitleBackgroundColor(color) + } + + func setSubtitleBorderStyle(_ style: String) { + renderer?.setSubtitleBorderStyle(style) + } + + func setSubtitleAssOverride(_ mode: String) { + renderer?.setSubtitleAssOverride(mode) + } + // MARK: - Video Scaling func setZoomedToFill(_ zoomed: Bool) { @@ -371,6 +430,12 @@ class MpvPlayerView: ExpoView { } deinit { + if let observer = appStateObserver { + NotificationCenter.default.removeObserver(observer) + } + #if os(tvOS) + resetDisplayCriteria() + #endif pipController?.stopPictureInPicture() renderer?.stop() displayLayer.removeFromSuperlayer() @@ -447,7 +512,12 @@ extension MpvPlayerView: MPVLayerRendererDelegate { self.onTracksReady([:]) } } - + func renderer(_: MPVLayerRenderer, didDetectHDRMode mode: HDRMode, fps: Double) { + #if os(tvOS) + setDisplayCriteria(for: mode, fps: Float(fps)) + #endif + } + func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) { // Audio output is now active - this is the right time to activate audio session and set Now Playing print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing") @@ -456,6 +526,93 @@ extension MpvPlayerView: MPVLayerRendererDelegate { } } +// MARK: - tvOS HDR Display Criteria + +#if os(tvOS) +import AVKit +import CoreMedia + +extension MpvPlayerView { + /// Creates a CMFormatDescription with HDR metadata for display criteria + private func createHDRFormatDescription(hdrMode: HDRMode) -> CMFormatDescription? { + var formatDescription: CMFormatDescription? + + // Build extensions dictionary for HDR color properties + var extensions: [String: Any] = [ + kCMFormatDescriptionExtension_FullRangeVideo as String: true + ] + + switch hdrMode { + case .hdr10, .dolbyVision: + // HDR10 and Dolby Vision use BT.2020 primaries with PQ transfer function + extensions[kCMFormatDescriptionExtension_ColorPrimaries as String] = kCMFormatDescriptionColorPrimaries_ITU_R_2020 + extensions[kCMFormatDescriptionExtension_TransferFunction as String] = kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ + extensions[kCMFormatDescriptionExtension_YCbCrMatrix as String] = kCMFormatDescriptionYCbCrMatrix_ITU_R_2020 + case .hlg: + // HLG uses BT.2020 primaries with HLG transfer function + extensions[kCMFormatDescriptionExtension_ColorPrimaries as String] = kCMFormatDescriptionColorPrimaries_ITU_R_2020 + extensions[kCMFormatDescriptionExtension_TransferFunction as String] = kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG + extensions[kCMFormatDescriptionExtension_YCbCrMatrix as String] = kCMFormatDescriptionYCbCrMatrix_ITU_R_2020 + case .sdr: + return nil + } + + // Create a video format description with HDR extensions + // Using HEVC codec type and 4K resolution as typical HDR parameters + let status = CMVideoFormatDescriptionCreate( + allocator: kCFAllocatorDefault, + codecType: kCMVideoCodecType_HEVC, + width: 3840, + height: 2160, + extensions: extensions as CFDictionary, + formatDescriptionOut: &formatDescription + ) + + return status == noErr ? formatDescription : nil + } + + /// Sets the preferred display criteria for HDR content on tvOS + func setDisplayCriteria(for hdrMode: HDRMode, fps: Float) { + guard #available(tvOS 17.0, *) else { + print("🎬 HDR: AVDisplayCriteria requires tvOS 17.0+") + return + } + + guard let window = self.window else { + print("🎬 HDR: No window available for display criteria") + return + } + + let manager = window.avDisplayManager + + if hdrMode == .sdr { + print("🎬 HDR: Setting display criteria to SDR (nil)") + manager.preferredDisplayCriteria = nil + return + } + + guard let formatDescription = createHDRFormatDescription(hdrMode: hdrMode) else { + print("🎬 HDR: Failed to create format description for \(hdrMode)") + return + } + + print("🎬 HDR: Setting display criteria to \(hdrMode), fps: \(fps)") + manager.preferredDisplayCriteria = AVDisplayCriteria( + refreshRate: fps, + formatDescription: formatDescription + ) + } + + /// Resets display criteria when playback ends + func resetDisplayCriteria() { + guard #available(tvOS 17.0, *) else { return } + guard let window = self.window else { return } + print("🎬 HDR: Resetting display criteria") + window.avDisplayManager.preferredDisplayCriteria = nil + } +} +#endif + // MARK: - PiPControllerDelegate extension MpvPlayerView: PiPControllerDelegate { diff --git a/modules/mpv-player/ios/PiPController.swift b/modules/mpv-player/ios/PiPController.swift index 7a58cb38e..6ad0bec51 100644 --- a/modules/mpv-player/ios/PiPController.swift +++ b/modules/mpv-player/ios/PiPController.swift @@ -150,6 +150,16 @@ final class PiPController: NSObject { CMTimebaseSetRate(tb, rate: Float64(rate)) } } + + deinit { + if let tb = timebase { + CMTimebaseSetRate(tb, rate: 0) + } + sampleBufferDisplayLayer?.controlTimebase = nil + timebase = nil + pipController?.delegate = nil + pipController = nil + } } // MARK: - AVPictureInPictureControllerDelegate diff --git a/modules/mpv-player/src/MpvPlayer.types.ts b/modules/mpv-player/src/MpvPlayer.types.ts index 8f42c8d97..b6bd04711 100644 --- a/modules/mpv-player/src/MpvPlayer.types.ts +++ b/modules/mpv-player/src/MpvPlayer.types.ts @@ -25,6 +25,10 @@ export type OnErrorEventPayload = { export type OnTracksReadyEventPayload = Record; +export type OnPictureInPictureChangePayload = { + isActive: boolean; +}; + export type NowPlayingMetadata = { title?: string; artist?: string; @@ -50,6 +54,19 @@ export type VideoSource = { initialSubtitleId?: number; /** MPV audio track ID to select on start (1-based) */ initialAudioId?: number; + /** MPV cache/buffer configuration */ + cacheConfig?: { + /** Whether caching is enabled: "auto" (default), "yes", or "no" */ + enabled?: "auto" | "yes" | "no"; + /** Seconds of video to buffer (default: 10, range: 5-120) */ + cacheSeconds?: number; + /** Maximum cache size in MB (default: 150, range: 50-500) */ + maxBytes?: number; + /** Maximum backward cache size in MB (default: 50, range: 25-200) */ + maxBackBytes?: number; + }; + /** MPV video output driver (Android only) */ + voDriver?: "gpu-next" | "gpu"; }; export type MpvPlayerViewProps = { @@ -64,6 +81,9 @@ export type MpvPlayerViewProps = { onProgress?: (event: { nativeEvent: OnProgressEventPayload }) => void; onError?: (event: { nativeEvent: OnErrorEventPayload }) => void; onTracksReady?: (event: { nativeEvent: OnTracksReadyEventPayload }) => void; + onPictureInPictureChange?: (event: { + nativeEvent: OnPictureInPictureChangePayload; + }) => void; }; export interface MpvPlayerViewRef { @@ -93,6 +113,11 @@ export interface MpvPlayerViewRef { setSubtitleAlignX: (alignment: "left" | "center" | "right") => Promise; setSubtitleAlignY: (alignment: "top" | "center" | "bottom") => Promise; setSubtitleFontSize: (size: number) => Promise; + setSubtitleBackgroundColor: (color: string) => Promise; + setSubtitleBorderStyle: ( + style: "outline-and-shadow" | "background-box", + ) => Promise; + setSubtitleAssOverride: (mode: "no" | "force") => Promise; // Audio controls getAudioTracks: () => Promise; setAudioTrack: (trackId: number) => Promise; @@ -130,4 +155,8 @@ export type TechnicalInfo = { audioBitrate?: number; cacheSeconds?: number; droppedFrames?: number; + /** Active video output driver (read from MPV at runtime) */ + voDriver?: string; + /** Active hardware decoder (read from MPV at runtime) */ + hwdec?: string; }; diff --git a/modules/mpv-player/src/MpvPlayerView.tsx b/modules/mpv-player/src/MpvPlayerView.tsx index ad3fcdfa4..1e1c80659 100644 --- a/modules/mpv-player/src/MpvPlayerView.tsx +++ b/modules/mpv-player/src/MpvPlayerView.tsx @@ -7,6 +7,8 @@ import { MpvPlayerViewProps, MpvPlayerViewRef } from "./MpvPlayer.types"; const NativeView: React.ComponentType = requireNativeView("MpvPlayer"); +const PIP_LOG = "[PiP] MpvPlayerView.tsx:"; + export default React.forwardRef( function MpvPlayerView(props, ref) { const nativeRef = useRef(null); @@ -40,16 +42,24 @@ export default React.forwardRef( return await nativeRef.current?.getDuration(); }, startPictureInPicture: async () => { + console.log(PIP_LOG, "startPictureInPicture → native"); await nativeRef.current?.startPictureInPicture(); + console.log(PIP_LOG, "startPictureInPicture ← native returned"); }, stopPictureInPicture: async () => { + console.log(PIP_LOG, "stopPictureInPicture → native"); await nativeRef.current?.stopPictureInPicture(); + console.log(PIP_LOG, "stopPictureInPicture ← native returned"); }, isPictureInPictureSupported: async () => { - return await nativeRef.current?.isPictureInPictureSupported(); + const result = await nativeRef.current?.isPictureInPictureSupported(); + console.log(PIP_LOG, "isPictureInPictureSupported =", result); + return result; }, isPictureInPictureActive: async () => { - return await nativeRef.current?.isPictureInPictureActive(); + const result = await nativeRef.current?.isPictureInPictureActive(); + console.log(PIP_LOG, "isPictureInPictureActive =", result); + return result; }, getSubtitleTracks: async () => { return await nativeRef.current?.getSubtitleTracks(); @@ -84,6 +94,17 @@ export default React.forwardRef( setSubtitleFontSize: async (size: number) => { await nativeRef.current?.setSubtitleFontSize(size); }, + setSubtitleBackgroundColor: async (color: string) => { + await nativeRef.current?.setSubtitleBackgroundColor(color); + }, + setSubtitleBorderStyle: async ( + style: "outline-and-shadow" | "background-box", + ) => { + await nativeRef.current?.setSubtitleBorderStyle(style); + }, + setSubtitleAssOverride: async (mode: "no" | "force") => { + await nativeRef.current?.setSubtitleAssOverride(mode); + }, // Audio controls getAudioTracks: async () => { return await nativeRef.current?.getAudioTracks(); diff --git a/modules/top-shelf-cache/expo-module.config.json b/modules/top-shelf-cache/expo-module.config.json new file mode 100644 index 000000000..2c34c1f35 --- /dev/null +++ b/modules/top-shelf-cache/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["TopShelfCacheModule"] + } +} diff --git a/modules/top-shelf-cache/index.ts b/modules/top-shelf-cache/index.ts new file mode 100644 index 000000000..3bd16e178 --- /dev/null +++ b/modules/top-shelf-cache/index.ts @@ -0,0 +1 @@ +export * from "./src"; diff --git a/modules/top-shelf-cache/ios/TopShelfCache.podspec b/modules/top-shelf-cache/ios/TopShelfCache.podspec new file mode 100644 index 000000000..aac202417 --- /dev/null +++ b/modules/top-shelf-cache/ios/TopShelfCache.podspec @@ -0,0 +1,23 @@ +Pod::Spec.new do |s| + s.name = 'TopShelfCache' + s.version = '1.0.0' + s.summary = 'Shared Top Shelf cache writer for Streamyfin tvOS' + s.description = 'Writes lightweight Top Shelf cache payloads to an App Group container for the tvOS extension.' + s.author = 'Streamyfin' + s.homepage = 'https://github.com/streamyfin/streamyfin' + s.platforms = { + :ios => '15.1', + :tvos => '15.1' + } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_VERSION' => '5.9' + } + + s.source_files = "*.{h,m,mm,swift}" +end diff --git a/modules/top-shelf-cache/ios/TopShelfCacheModule.swift b/modules/top-shelf-cache/ios/TopShelfCacheModule.swift new file mode 100644 index 000000000..12e339321 --- /dev/null +++ b/modules/top-shelf-cache/ios/TopShelfCacheModule.swift @@ -0,0 +1,112 @@ +import ExpoModulesCore +import Foundation +import Security +#if canImport(TVServices) +import TVServices +#endif + +public class TopShelfCacheModule: Module { + private let appGroupInfoPlistKey = "StreamyfinAppGroupIdentifier" + private let keychainAccessGroupInfoPlistKey = "StreamyfinKeychainAccessGroupIdentifier" + private let cacheKey = "TopShelfCache" + private let apiKeyService = "StreamyfinTopShelf" + private let apiKeyAccount = "JellyfinApiKey" + private var appGroupIdentifier: String? { + if let appGroupIdentifier = Bundle.main.object( + forInfoDictionaryKey: appGroupInfoPlistKey + ) as? String { + return appGroupIdentifier + } + + guard let bundleIdentifier = Bundle.main.bundleIdentifier else { + return nil + } + + return "group.\(bundleIdentifier)" + } + private var keychainAccessGroupIdentifier: String? { + Bundle.main.object(forInfoDictionaryKey: keychainAccessGroupInfoPlistKey) as? String + } + + public func definition() -> ModuleDefinition { + Name("TopShelfCache") + + Function("writeCache") { (json: String, apiKey: String?) -> Bool in + guard + let appGroupIdentifier = appGroupIdentifier, + let defaults = UserDefaults(suiteName: appGroupIdentifier) + else { + return false + } + + defaults.set(json, forKey: cacheKey) + defaults.set(Date().timeIntervalSince1970, forKey: "\(cacheKey)UpdatedAt") + defaults.synchronize() + let didSaveAPIKey = saveAPIKey(apiKey) + + #if canImport(TVServices) + TVTopShelfContentProvider.topShelfContentDidChange() + #endif + + return didSaveAPIKey + } + + Function("clearCache") { () -> Bool in + guard + let appGroupIdentifier = appGroupIdentifier, + let defaults = UserDefaults(suiteName: appGroupIdentifier) + else { + return false + } + + defaults.removeObject(forKey: cacheKey) + defaults.removeObject(forKey: "\(cacheKey)UpdatedAt") + defaults.synchronize() + let didDeleteAPIKey = deleteAPIKey() + + #if canImport(TVServices) + TVTopShelfContentProvider.topShelfContentDidChange() + #endif + + return didDeleteAPIKey + } + } + + private func baseAPIKeyQuery() -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: apiKeyService, + kSecAttrAccount as String: apiKeyAccount + ] + + if let keychainAccessGroupIdentifier { + query[kSecAttrAccessGroup as String] = keychainAccessGroupIdentifier + } + + return query + } + + private func saveAPIKey(_ apiKey: String?) -> Bool { + guard deleteAPIKey() else { + return false + } + + guard + let apiKey, + !apiKey.isEmpty, + let data = apiKey.data(using: .utf8) + else { + return true + } + + var query = baseAPIKeyQuery() + query[kSecValueData as String] = data + let status = SecItemAdd(query as CFDictionary, nil) + return status == errSecSuccess + } + + private func deleteAPIKey() -> Bool { + let status = SecItemDelete(baseAPIKeyQuery() as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } +} diff --git a/modules/top-shelf-cache/src/TopShelfCache.types.ts b/modules/top-shelf-cache/src/TopShelfCache.types.ts new file mode 100644 index 000000000..7888d7b95 --- /dev/null +++ b/modules/top-shelf-cache/src/TopShelfCache.types.ts @@ -0,0 +1,21 @@ +export type TopShelfCacheModuleEvents = Record; + +export interface TopShelfCacheItem { + id: string; + title: string; + subtitle?: string; + imageUrl?: string; + route: string; + playRoute?: string; +} + +export interface TopShelfCacheSection { + title: string; + items: TopShelfCacheItem[]; +} + +export interface TopShelfCachePayload { + version: 1; + updatedAt: string; + sections: TopShelfCacheSection[]; +} diff --git a/modules/top-shelf-cache/src/TopShelfCacheModule.ts b/modules/top-shelf-cache/src/TopShelfCacheModule.ts new file mode 100644 index 000000000..c6692c030 --- /dev/null +++ b/modules/top-shelf-cache/src/TopShelfCacheModule.ts @@ -0,0 +1,37 @@ +import { NativeModule, requireNativeModule } from "expo"; +import { Platform } from "react-native"; +import type { TopShelfCacheModuleEvents } from "./TopShelfCache.types"; + +declare class TopShelfCacheModuleType extends NativeModule { + writeCache(json: string, apiKey?: string): boolean; + clearCache(): boolean; +} + +let TopShelfCacheNativeModule: TopShelfCacheModuleType | null = null; + +if (Platform.OS === "ios" && Platform.isTV) { + try { + TopShelfCacheNativeModule = + requireNativeModule("TopShelfCache"); + } catch { + TopShelfCacheNativeModule = null; + } +} + +export function writeTopShelfCache(json: string, apiKey?: string): boolean { + if (!TopShelfCacheNativeModule) return false; + + try { + return TopShelfCacheNativeModule.writeCache(json, apiKey); + } catch { + try { + return TopShelfCacheNativeModule.writeCache(json); + } catch { + return false; + } + } +} + +export function clearTopShelfCache(): boolean { + return TopShelfCacheNativeModule?.clearCache() ?? false; +} diff --git a/modules/top-shelf-cache/src/index.ts b/modules/top-shelf-cache/src/index.ts new file mode 100644 index 000000000..9f180ab71 --- /dev/null +++ b/modules/top-shelf-cache/src/index.ts @@ -0,0 +1,2 @@ +export * from "./TopShelfCache.types"; +export { clearTopShelfCache, writeTopShelfCache } from "./TopShelfCacheModule"; diff --git a/modules/tv-recommendations/android/build.gradle b/modules/tv-recommendations/android/build.gradle new file mode 100644 index 000000000..b9692ba0a --- /dev/null +++ b/modules/tv-recommendations/android/build.gradle @@ -0,0 +1,46 @@ +plugins { + id 'com.android.library' + id 'kotlin-android' +} + +group = 'expo.modules.tvrecommendations' +version = '1.0.0' + +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25' + +apply from: expoModulesCorePlugin + +applyKotlinExpoModulesCorePlugin() +useDefaultAndroidSdkVersions() +useCoreDependencies() +useExpoPublishing() + +android { + namespace "expo.modules.tvrecommendations" + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + lintOptions { + abortOnError false + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" + implementation "androidx.tvprovider:tvprovider:1.1.0" + implementation "androidx.core:core-ktx:1.13.1" +} + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions { + jvmTarget = "17" + } +} diff --git a/modules/tv-recommendations/android/src/main/AndroidManifest.xml b/modules/tv-recommendations/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..87a7944c0 --- /dev/null +++ b/modules/tv-recommendations/android/src/main/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsModule.kt b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsModule.kt new file mode 100644 index 000000000..11b29c397 --- /dev/null +++ b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsModule.kt @@ -0,0 +1,25 @@ +package expo.modules.tvrecommendations + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class TvRecommendationsModule : Module() { + override fun definition() = ModuleDefinition { + Name("TvRecommendations") + + Function("syncRecommendations") { json: String -> + val context = appContext.reactContext ?: return@Function false + TvRecommendationsPublisher.sync(context, json) + } + + Function("clearRecommendations") { + val context = appContext.reactContext ?: return@Function false + TvRecommendationsPublisher.clear(context) + } + + Function("refreshRecommendations") { + val context = appContext.reactContext ?: return@Function false + TvRecommendationsPublisher.refreshFromCache(context) + } + } +} diff --git a/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt new file mode 100644 index 000000000..7946648e8 --- /dev/null +++ b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt @@ -0,0 +1,379 @@ +package expo.modules.tvrecommendations + +import android.content.ContentUris +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.net.Uri +import android.util.Log +import androidx.tvprovider.media.tv.Channel +import androidx.tvprovider.media.tv.PreviewProgram +import androidx.tvprovider.media.tv.TvContractCompat +import org.json.JSONArray +import org.json.JSONObject + +internal object TvRecommendationsPublisher { + private const val TAG = "TvRecommendations" + private const val PREFS_NAME = "StreamyfinTvRecommendations" + private const val KEY_PAYLOAD = "payload" + private const val KEY_CHANNEL_ID = "channelId" + private const val KEY_PROGRAM_IDS = "programIds" + private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up" + + fun sync(context: Context, payloadJson: String): Boolean { + val payload = try { + JSONObject(payloadJson) + } catch (error: Exception) { + Log.e(TAG, "Failed to parse recommendations payload", error) + return false + } + + val sectionCount = payload.optJSONArray("sections")?.length() ?: 0 + Log.d(TAG, "sync(): received payload with $sectionCount section(s)") + + preferences(context) + .edit() + .putString(KEY_PAYLOAD, payloadJson) + .apply() + + return synchronize(context, payload) + } + + fun refreshFromCache(context: Context): Boolean { + val payloadJson = preferences(context).getString(KEY_PAYLOAD, null) ?: return false + val payload = try { + JSONObject(payloadJson) + } catch (error: Exception) { + Log.e(TAG, "Failed to parse cached recommendations payload", error) + return false + } + + val sectionCount = payload.optJSONArray("sections")?.length() ?: 0 + Log.d(TAG, "refreshFromCache(): replaying cached payload with $sectionCount section(s)") + + return synchronize(context, payload) + } + + fun clear(context: Context): Boolean { + val prefs = preferences(context) + val channelId = prefs.getLong(KEY_CHANNEL_ID, -1L) + val programIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject) + val contentResolver = context.contentResolver + + if (programIds != null) { + var deletedPrograms = 0 + val keys = programIds.keys() + while (keys.hasNext()) { + val key = keys.next() + val programId = programIds.optLong(key, -1L) + if (programId > 0L) { + contentResolver.delete( + TvContractCompat.buildPreviewProgramUri(programId), + null, + null + ) + deletedPrograms += 1 + } + } + Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)") + } + + if (channelId > 0L) { + contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null) + Log.d(TAG, "clear(): notified channel $channelId") + } + + prefs.edit() + .remove(KEY_PAYLOAD) + .remove(KEY_PROGRAM_IDS) + .apply() + + return true + } + + private fun synchronize(context: Context, payload: JSONObject): Boolean { + val sections = payload.optJSONArray("sections") ?: JSONArray() + val firstSection = if (sections.length() > 0) sections.optJSONObject(0) else null + val sectionTitle = firstSection?.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME + val items = firstSection?.optJSONArray("items") ?: JSONArray() + + Log.d( + TAG, + "synchronize(): using section \"$sectionTitle\" with ${items.length()} item(s)" + ) + + val channelId = getOrCreateChannel(context, sectionTitle) + if (channelId <= 0L) { + Log.w(TAG, "synchronize(): failed to get or create preview channel") + return false + } + + Log.d(TAG, "synchronize(): publishing into channelId=$channelId") + + val previousProgramIds = preferences(context) + .getString(KEY_PROGRAM_IDS, null) + ?.let(::JSONObject) + ?: JSONObject() + val nextProgramIds = JSONObject() + val activeProviderIds = mutableSetOf() + + for (index in 0 until items.length()) { + val item = items.optJSONObject(index) ?: continue + val providerId = item.optString("id") + if (providerId.isBlank()) continue + + val programId = upsertPreviewProgram( + context = context, + channelId = channelId, + item = item, + previousProgramId = previousProgramIds.optLong(providerId, -1L), + weight = index + ) + + if (programId > 0L) { + activeProviderIds += providerId + nextProgramIds.put(providerId, programId) + Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId") + } + } + + var deletedPrograms = 0 + val previousKeys = previousProgramIds.keys() + while (previousKeys.hasNext()) { + val providerId = previousKeys.next() + if (activeProviderIds.contains(providerId)) continue + + val programId = previousProgramIds.optLong(providerId, -1L) + if (programId > 0L) { + context.contentResolver.delete( + TvContractCompat.buildPreviewProgramUri(programId), + null, + null + ) + deletedPrograms += 1 + Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId") + } + } + + preferences(context) + .edit() + .putLong(KEY_CHANNEL_ID, channelId) + .putString(KEY_PROGRAM_IDS, nextProgramIds.toString()) + .apply() + + logProviderState(context, channelId) + + Log.d( + TAG, + "synchronize(): completed with ${activeProviderIds.size} active program(s), deleted $deletedPrograms stale program(s)" + ) + + return true + } + + private fun getOrCreateChannel(context: Context, displayName: String): Long { + val prefs = preferences(context) + val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L) + val contentResolver = context.contentResolver + + if (existingChannelId > 0L) { + val updated = Channel.Builder() + .setType(TvContractCompat.Channels.TYPE_PREVIEW) + .setDisplayName(displayName) + .setAppLinkIntentUri(buildIntentUri(context, "streamyfin://")) + .build() + + val updatedRows = contentResolver.update( + TvContractCompat.buildChannelUri(existingChannelId), + updated.toContentValues(), + null, + null + ) + + if (updatedRows > 0) { + TvContractCompat.requestChannelBrowsable(context, existingChannelId) + storeChannelLogo(context, existingChannelId) + Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable") + return existingChannelId + } + + Log.w(TAG, "getOrCreateChannel(): stored channelId=$existingChannelId was stale, recreating") + prefs.edit().remove(KEY_CHANNEL_ID).apply() + } + + val channel = Channel.Builder() + .setType(TvContractCompat.Channels.TYPE_PREVIEW) + .setDisplayName(displayName) + .setAppLinkIntentUri(buildIntentUri(context, "streamyfin://")) + .build() + + val channelUri = contentResolver.insert( + TvContractCompat.Channels.CONTENT_URI, + channel.toContentValues() + ) ?: return -1L + + val channelId = ContentUris.parseId(channelUri) + TvContractCompat.requestChannelBrowsable(context, channelId) + storeChannelLogo(context, channelId) + Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"") + + return channelId + } + + private fun upsertPreviewProgram( + context: Context, + channelId: Long, + item: JSONObject, + previousProgramId: Long, + weight: Int + ): Long { + val providerId = item.optString("id") + val imageUrl = item.optString("imageUrl") + + val builder = PreviewProgram.Builder() + .setChannelId(channelId) + .setType(programTypeFor(item.optString("itemType"))) + .setTitle(item.optString("title")) + .setInternalProviderId(providerId) + .setContentId(providerId) + .setIntentUri(buildIntentUri(context, item.optString("playRoute").ifBlank { item.optString("route") })) + .setWeight(weight) + .setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9) + + item.optString("subtitle").takeIf { it.isNotBlank() }?.let { + builder.setDescription(it) + } + + imageUrl.takeIf { it.isNotBlank() }?.let { + val imageUri = Uri.parse(it) + builder.setPosterArtUri(imageUri) + builder.setThumbnailUri(imageUri) + } + + + val contentValues = builder.build().toContentValues() + val contentResolver = context.contentResolver + + if (previousProgramId > 0L) { + val updatedRows = contentResolver.update( + TvContractCompat.buildPreviewProgramUri(previousProgramId), + contentValues, + null, + null + ) + + if (updatedRows > 0) { + Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId") + return previousProgramId + } + + Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row") + } + + val insertedUri = contentResolver.insert( + TvContractCompat.PreviewPrograms.CONTENT_URI, + contentValues + ) ?: return -1L + + val programId = ContentUris.parseId(insertedUri) + Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId") + return programId + } + + private fun buildIntentUri(context: Context, deepLink: String): Uri { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(deepLink) + `package` = context.packageName + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + + return Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME)) + } + + private fun programTypeFor(itemType: String): Int { + return when (itemType) { + "Movie" -> TvContractCompat.PreviewPrograms.TYPE_MOVIE + "Episode" -> TvContractCompat.PreviewPrograms.TYPE_TV_EPISODE + "Series" -> TvContractCompat.PreviewPrograms.TYPE_TV_SERIES + else -> TvContractCompat.PreviewPrograms.TYPE_CLIP + } + } + + private fun storeChannelLogo(context: Context, channelId: Long) { + val bitmap = applicationIconBitmap(context) ?: return + val outputStream = context.contentResolver.openOutputStream( + TvContractCompat.buildChannelLogoUri(channelId) + ) ?: return + + outputStream.use { stream -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) + stream.flush() + } + } + + private fun applicationIconBitmap(context: Context): Bitmap? { + val drawable = try { + context.packageManager.getApplicationIcon(context.packageName) + } catch (error: PackageManager.NameNotFoundException) { + Log.w(TAG, "Unable to load application icon", error) + return null + } + + return drawable.toBitmap() + } + + private fun Drawable.toBitmap(): Bitmap { + if (this is BitmapDrawable && bitmap != null) { + return bitmap + } + + val width = intrinsicWidth.takeIf { it > 0 } ?: 256 + val height = intrinsicHeight.takeIf { it > 0 } ?: 256 + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + setBounds(0, 0, canvas.width, canvas.height) + draw(canvas) + return bitmap + } + + private fun preferences(context: Context): SharedPreferences { + return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + private fun logProviderState(context: Context, channelId: Long) { + val contentResolver = context.contentResolver + + try { + contentResolver.query( + TvContractCompat.buildChannelUri(channelId), + null, + null, + null, + null + )?.use { cursor -> + if (cursor.moveToFirst()) { + val browsableIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_BROWSABLE) + val packageNameIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_PACKAGE_NAME) + val displayNameIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_DISPLAY_NAME) + + val browsable = if (browsableIndex >= 0) cursor.getInt(browsableIndex) else -1 + val packageName = if (packageNameIndex >= 0) cursor.getString(packageNameIndex) else "unknown" + val displayName = if (displayNameIndex >= 0) cursor.getString(displayNameIndex) else "unknown" + + Log.d( + TAG, + "logProviderState(): channelId=$channelId exists=true browsable=$browsable packageName=$packageName displayName=$displayName" + ) + } else { + Log.w(TAG, "logProviderState(): channelId=$channelId exists=false") + } + } ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId") + } catch (error: Exception) { + Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error) + } + } +} diff --git a/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt new file mode 100644 index 000000000..1fde77c7e --- /dev/null +++ b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt @@ -0,0 +1,18 @@ +package expo.modules.tvrecommendations + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.tvprovider.media.tv.TvContractCompat + +class TvRecommendationsReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != TvContractCompat.ACTION_INITIALIZE_PROGRAMS) { + return + } + + Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast") + TvRecommendationsPublisher.refreshFromCache(context) + } +} diff --git a/modules/tv-recommendations/expo-module.config.json b/modules/tv-recommendations/expo-module.config.json new file mode 100644 index 000000000..e017f17fd --- /dev/null +++ b/modules/tv-recommendations/expo-module.config.json @@ -0,0 +1,8 @@ +{ + "name": "tv-recommendations", + "version": "1.0.0", + "platforms": ["android"], + "android": { + "modules": ["expo.modules.tvrecommendations.TvRecommendationsModule"] + } +} diff --git a/modules/tv-recommendations/index.ts b/modules/tv-recommendations/index.ts new file mode 100644 index 000000000..3bd16e178 --- /dev/null +++ b/modules/tv-recommendations/index.ts @@ -0,0 +1 @@ +export * from "./src"; diff --git a/modules/tv-recommendations/src/TvRecommendations.types.ts b/modules/tv-recommendations/src/TvRecommendations.types.ts new file mode 100644 index 000000000..1b21e5edd --- /dev/null +++ b/modules/tv-recommendations/src/TvRecommendations.types.ts @@ -0,0 +1,5 @@ +export interface TvRecommendationsModuleType { + syncRecommendations(json: string): boolean; + clearRecommendations(): boolean; + refreshRecommendations(): boolean; +} diff --git a/modules/tv-recommendations/src/TvRecommendationsModule.ts b/modules/tv-recommendations/src/TvRecommendationsModule.ts new file mode 100644 index 000000000..625e34da8 --- /dev/null +++ b/modules/tv-recommendations/src/TvRecommendationsModule.ts @@ -0,0 +1,26 @@ +import { requireNativeModule } from "expo-modules-core"; +import { Platform } from "react-native"; +import type { TvRecommendationsModuleType } from "./TvRecommendations.types"; + +let TvRecommendationsModule: TvRecommendationsModuleType | null = null; + +if (Platform.OS === "android" && Platform.isTV) { + try { + TvRecommendationsModule = + requireNativeModule("TvRecommendations"); + } catch { + TvRecommendationsModule = null; + } +} + +export function syncTvRecommendations(json: string): boolean { + return TvRecommendationsModule?.syncRecommendations(json) ?? false; +} + +export function clearTvRecommendations(): boolean { + return TvRecommendationsModule?.clearRecommendations() ?? false; +} + +export function refreshTvRecommendations(): boolean { + return TvRecommendationsModule?.refreshRecommendations() ?? false; +} diff --git a/modules/tv-recommendations/src/index.ts b/modules/tv-recommendations/src/index.ts new file mode 100644 index 000000000..c1798ae06 --- /dev/null +++ b/modules/tv-recommendations/src/index.ts @@ -0,0 +1,6 @@ +export type { TvRecommendationsModuleType } from "./TvRecommendations.types"; +export { + clearTvRecommendations, + refreshTvRecommendations, + syncTvRecommendations, +} from "./TvRecommendationsModule"; diff --git a/modules/tv-search/expo-module.config.json b/modules/tv-search/expo-module.config.json new file mode 100644 index 000000000..b73df1517 --- /dev/null +++ b/modules/tv-search/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["TvSearchModule"] + } +} diff --git a/modules/tv-search/index.ts b/modules/tv-search/index.ts new file mode 100644 index 000000000..5184d1129 --- /dev/null +++ b/modules/tv-search/index.ts @@ -0,0 +1,2 @@ +export { default as TvSearchView } from "./src/TvSearchView"; +export * from "./src/TvSearchView.types"; diff --git a/modules/tv-search/ios/TvSearch.podspec b/modules/tv-search/ios/TvSearch.podspec new file mode 100644 index 000000000..db0bfefa4 --- /dev/null +++ b/modules/tv-search/ios/TvSearch.podspec @@ -0,0 +1,22 @@ +Pod::Spec.new do |s| + s.name = 'TvSearch' + s.version = '1.0.0' + s.summary = 'Native tvOS search field with text change events' + s.description = 'Hosts SwiftUI .searchable inside a UIHostingController so React Native can render its own results grid while using the native tvOS search bar and grid keyboard.' + s.author = '' + s.homepage = 'https://docs.expo.dev/modules/' + s.platforms = { + :tvos => '15.1' + } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + s.source_files = "**/*.{h,m,mm,swift}" +end diff --git a/modules/tv-search/ios/TvSearchModule.swift b/modules/tv-search/ios/TvSearchModule.swift new file mode 100644 index 000000000..65b026c8d --- /dev/null +++ b/modules/tv-search/ios/TvSearchModule.swift @@ -0,0 +1,15 @@ +import ExpoModulesCore + +public class TvSearchModule: Module { + public func definition() -> ModuleDefinition { + Name("TvSearchModule") + + View(TvSearchView.self) { + Events("onChangeText") + + Prop("placeholder") { (view: TvSearchView, value: String?) in + view.setPlaceholder(value ?? "") + } + } + } +} diff --git a/modules/tv-search/ios/TvSearchView.swift b/modules/tv-search/ios/TvSearchView.swift new file mode 100644 index 000000000..7fd44f718 --- /dev/null +++ b/modules/tv-search/ios/TvSearchView.swift @@ -0,0 +1,206 @@ +import ExpoModulesCore +import SwiftUI + +// React Native tvOS notification names for controlling gesture handler behavior. +// These match the constants in RCTTVRemoteHandler.h and are what make keyboard +// input actually reach the native search field on tvOS. +private let RCTTVDisableGestureHandlersCancelTouchesNotification = Notification.Name( + "RCTTVDisableGestureHandlersCancelTouchesNotification") +private let RCTTVEnableGestureHandlersCancelTouchesNotification = Notification.Name( + "RCTTVEnableGestureHandlersCancelTouchesNotification") + +#if os(tvOS) + + /// Holds the search state. ObservableObject so we can update placeholder/text + /// without recreating the SwiftUI hierarchy. + class TvSearchViewModel: ObservableObject { + @Published var searchText: String = "" + @Published var placeholder: String = "Search..." + @Published var accentColor: Color = .white + var onSearch: ((String) -> Void)? + } + + /// SwiftUI content hosting `.searchable`. This mirrors expo-tvos-search's + /// structure — `.searchable` attached inside a `NavigationView` (REQUIRED: + /// `.searchable` only renders a search bar in a navigation context) — but with + /// the results grid REMOVED. The body is just transparent filler so the search + /// field + native grid keyboard render; results are drawn by React Native + /// below this native view instead. + struct TvSearchContentView: View { + @ObservedObject var viewModel: TvSearchViewModel + + var body: some View { + NavigationView { + // Transparent filler gives `.searchable` something to attach to and + // lets the native search bar/keyboard own the space. + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + .searchable(text: $viewModel.searchText, prompt: viewModel.placeholder) + .onChange(of: viewModel.searchText) { newValue in + viewModel.onSearch?(newValue) + } + } + .tint(viewModel.accentColor) + } + } + + class TvSearchView: ExpoView { + private var hostingController: UIHostingController? + private let viewModel = TvSearchViewModel() + private var gestureHandlersDisabled = false + private var disabledGestureRecognizers: [UIGestureRecognizer] = [] + + let onChangeText = EventDispatcher() + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + setupView() + } + + deinit { + NotificationCenter.default.removeObserver(self) + hostingController?.willMove(toParent: nil) + hostingController?.removeFromParent() + #if !targetEnvironment(simulator) + enableParentGestureRecognizers() + #endif + if gestureHandlersDisabled { + NotificationCenter.default.post( + name: RCTTVEnableGestureHandlersCancelTouchesNotification, object: nil) + } + } + + func setPlaceholder(_ value: String) { + viewModel.placeholder = value + } + + private func setupView() { + viewModel.onSearch = { [weak self] query in + self?.onChangeText(["text": query]) + } + + let controller = UIHostingController(rootView: TvSearchContentView(viewModel: viewModel)) + controller.view.backgroundColor = .clear + hostingController = controller + + addSubview(controller.view) + controller.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + controller.view.topAnchor.constraint(equalTo: topAnchor), + controller.view.bottomAnchor.constraint(equalTo: bottomAnchor), + controller.view.leadingAnchor.constraint(equalTo: leadingAnchor), + controller.view.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + + if let parentVC = parentViewController() { + parentVC.addChild(controller) + controller.didMove(toParent: parentVC) + } + + // Detect when the search keyboard becomes active so we can release RN's + // remote gesture handling (otherwise keystrokes never reach the field). + NotificationCenter.default.addObserver( + self, selector: #selector(handleTextFieldDidBeginEditing), + name: UITextField.textDidBeginEditingNotification, object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(handleTextFieldDidEndEditing), + name: UITextField.textDidEndEditingNotification, object: nil) + } + + // MARK: - View controller containment + + /// SwiftUI needs proper appearance lifecycle events for `.searchable` to + /// register with tvOS's focus system, so we manage child VC containment as + /// the view enters/leaves the window. + override func didMoveToWindow() { + super.didMoveToWindow() + guard let controller = hostingController else { return } + if window != nil { + if controller.parent == nil, let parentVC = parentViewController() { + parentVC.addChild(controller) + controller.didMove(toParent: parentVC) + } + } else { + controller.willMove(toParent: nil) + controller.removeFromParent() + } + } + + private func parentViewController() -> UIViewController? { + var responder: UIResponder? = self + while let next = responder?.next { + if let vc = next as? UIViewController { return vc } + responder = next + } + return nil + } + + // MARK: - Keyboard / gesture handling + + @objc private func handleTextFieldDidBeginEditing(_ notification: Notification) { + guard let textField = notification.object as? UITextField, + let hostingView = hostingController?.view, + textField.isDescendant(of: hostingView) + else { return } + + guard !gestureHandlersDisabled else { return } + gestureHandlersDisabled = true + + NotificationCenter.default.post( + name: RCTTVDisableGestureHandlersCancelTouchesNotification, object: nil) + + #if !targetEnvironment(simulator) + disableParentGestureRecognizers() + #endif + } + + @objc private func handleTextFieldDidEndEditing(_ notification: Notification) { + guard let textField = notification.object as? UITextField, + let hostingView = hostingController?.view, + textField.isDescendant(of: hostingView) + else { return } + + guard gestureHandlersDisabled else { return } + gestureHandlersDisabled = false + + #if !targetEnvironment(simulator) + enableParentGestureRecognizers() + #endif + + NotificationCenter.default.post( + name: RCTTVEnableGestureHandlersCancelTouchesNotification, object: nil) + } + + private func disableParentGestureRecognizers() { + disabledGestureRecognizers.removeAll() + var currentView: UIView? = superview + while let view = currentView { + for recognizer in view.gestureRecognizers ?? [] { + let isTapOrPress = + recognizer is UITapGestureRecognizer || recognizer is UILongPressGestureRecognizer + if isTapOrPress && recognizer.isEnabled { + recognizer.isEnabled = false + disabledGestureRecognizers.append(recognizer) + } + } + currentView = view.superview + } + } + + private func enableParentGestureRecognizers() { + for recognizer in disabledGestureRecognizers { + recognizer.isEnabled = true + } + disabledGestureRecognizers.removeAll() + } + } + +#else + + // Fallback for non-tvOS platforms (iOS). + class TvSearchView: ExpoView { + let onChangeText = EventDispatcher() + func setPlaceholder(_ value: String) {} + } + +#endif diff --git a/modules/tv-search/package.json b/modules/tv-search/package.json new file mode 100644 index 000000000..c4ac53271 --- /dev/null +++ b/modules/tv-search/package.json @@ -0,0 +1,10 @@ +{ + "name": "tv-search", + "version": "0.1.0", + "description": "Native tvOS search field (SwiftUI .searchable) emitting typed text to React Native", + "main": "index.ts", + "platforms": [ + "apple" + ], + "devDependencies": {} +} diff --git a/modules/tv-search/src/TvSearchView.tsx b/modules/tv-search/src/TvSearchView.tsx new file mode 100644 index 000000000..aa1a81d29 --- /dev/null +++ b/modules/tv-search/src/TvSearchView.tsx @@ -0,0 +1,22 @@ +import { requireNativeView } from "expo"; +import * as React from "react"; +import type { View } from "react-native"; + +import type { TvSearchViewProps } from "./TvSearchView.types"; + +const NativeView: React.ComponentType< + TvSearchViewProps & React.RefAttributes +> = requireNativeView("TvSearchModule"); + +/** + * Forwards its ref to the underlying native view so it can be used as a + * `TVFocusGuideView` `destinations` target for routing focus into the native + * search bar. + */ +const TvSearchView = React.forwardRef((props, ref) => { + return ; +}); + +TvSearchView.displayName = "TvSearchView"; + +export default TvSearchView; diff --git a/modules/tv-search/src/TvSearchView.types.ts b/modules/tv-search/src/TvSearchView.types.ts new file mode 100644 index 000000000..011dbbcd0 --- /dev/null +++ b/modules/tv-search/src/TvSearchView.types.ts @@ -0,0 +1,12 @@ +import type { ViewProps } from "react-native"; + +export interface TvSearchTextChangeEvent { + nativeEvent: { text: string }; +} + +export interface TvSearchViewProps extends ViewProps { + /** Placeholder shown in the native search bar. */ + placeholder?: string; + /** Fired as the user types in the native search bar. */ + onChangeText?: (event: TvSearchTextChangeEvent) => void; +} diff --git a/modules/tv-user-profile/expo-module.config.json b/modules/tv-user-profile/expo-module.config.json new file mode 100644 index 000000000..6b34d7939 --- /dev/null +++ b/modules/tv-user-profile/expo-module.config.json @@ -0,0 +1,8 @@ +{ + "name": "tv-user-profile", + "version": "1.0.0", + "platforms": ["apple"], + "apple": { + "modules": ["TvUserProfileModule"] + } +} diff --git a/modules/tv-user-profile/index.ts b/modules/tv-user-profile/index.ts new file mode 100644 index 000000000..b67897825 --- /dev/null +++ b/modules/tv-user-profile/index.ts @@ -0,0 +1,84 @@ +import type { EventSubscription } from "expo-modules-core"; +import { Platform, requireNativeModule } from "expo-modules-core"; + +interface TvUserProfileModuleEvents { + onProfileChange: (event: { profileId: string | null }) => void; +} + +interface TvUserProfileModuleType { + getCurrentProfileId(): string | null; + isProfileSwitchingSupported(): boolean; + addListener( + eventName: K, + listener: TvUserProfileModuleEvents[K], + ): EventSubscription; +} + +// Only load the native module on Apple platforms +const TvUserProfileModule: TvUserProfileModuleType | null = + Platform.OS === "ios" + ? requireNativeModule("TvUserProfile") + : null; + +/** + * Get the current tvOS profile identifier. + * Returns null on non-tvOS platforms or if no profile is active. + */ +export function getCurrentProfileId(): string | null { + if (!TvUserProfileModule) { + return null; + } + + try { + return TvUserProfileModule.getCurrentProfileId() ?? null; + } catch (error) { + console.error("[TvUserProfile] Error getting profile ID:", error); + return null; + } +} + +/** + * Check if tvOS profile switching is supported on this device. + * Returns true only on tvOS. + */ +export function isProfileSwitchingSupported(): boolean { + if (!TvUserProfileModule) { + return false; + } + + try { + return TvUserProfileModule.isProfileSwitchingSupported(); + } catch (error) { + console.error("[TvUserProfile] Error checking profile support:", error); + return false; + } +} + +/** + * Subscribe to profile change events. + * The callback receives the new profile ID (or null if no profile). + * Returns an unsubscribe function. + */ +export function addProfileChangeListener( + callback: (profileId: string | null) => void, +): () => void { + if (!TvUserProfileModule) { + // Return no-op unsubscribe on unsupported platforms + return () => {}; + } + + const subscription = TvUserProfileModule.addListener( + "onProfileChange", + (event) => { + callback(event.profileId); + }, + ); + + return () => subscription.remove(); +} + +export default { + getCurrentProfileId, + isProfileSwitchingSupported, + addProfileChangeListener, +}; diff --git a/modules/tv-user-profile/ios/TvUserProfile.podspec b/modules/tv-user-profile/ios/TvUserProfile.podspec new file mode 100644 index 000000000..648af143c --- /dev/null +++ b/modules/tv-user-profile/ios/TvUserProfile.podspec @@ -0,0 +1,23 @@ +Pod::Spec.new do |s| + s.name = 'TvUserProfile' + s.version = '1.0.0' + s.summary = 'tvOS User Profile Management for Expo' + s.description = 'Native tvOS module to get current user profile and listen for profile changes using TVUserManager' + s.author = '' + s.homepage = 'https://docs.expo.dev/modules/' + s.platforms = { :ios => '15.6', :tvos => '15.0' } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + # TVServices framework is only available on tvOS + s.tvos.frameworks = 'TVServices' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/modules/tv-user-profile/ios/TvUserProfileModule.swift b/modules/tv-user-profile/ios/TvUserProfileModule.swift new file mode 100644 index 000000000..8a3d2a714 --- /dev/null +++ b/modules/tv-user-profile/ios/TvUserProfileModule.swift @@ -0,0 +1,82 @@ +import ExpoModulesCore +#if os(tvOS) +import TVServices +#endif + +public class TvUserProfileModule: Module { + #if os(tvOS) + private let userManager = TVUserManager() + private var profileObservation: NSKeyValueObservation? + #endif + + public func definition() -> ModuleDefinition { + Name("TvUserProfile") + + // Define event that can be sent to JavaScript + Events("onProfileChange") + + // Get current tvOS profile identifier + Function("getCurrentProfileId") { () -> String? in + #if os(tvOS) + let identifier = self.userManager.currentUserIdentifier + print("[TvUserProfile] Current profile ID: \(identifier ?? "nil")") + return identifier + #else + return nil + #endif + } + + // Check if running on tvOS with profile support + Function("isProfileSwitchingSupported") { () -> Bool in + #if os(tvOS) + return true + #else + return false + #endif + } + + OnCreate { + #if os(tvOS) + self.setupProfileObserver() + #endif + } + + OnDestroy { + #if os(tvOS) + self.profileObservation?.invalidate() + self.profileObservation = nil + #endif + } + } + + #if os(tvOS) + private func setupProfileObserver() { + // Debug: Print all available info about TVUserManager + print("[TvUserProfile] TVUserManager created") + print("[TvUserProfile] currentUserIdentifier: \(userManager.currentUserIdentifier ?? "nil")") + if #available(tvOS 16.0, *) { + print("[TvUserProfile] shouldStorePreferencesForCurrentUser: \(userManager.shouldStorePreferencesForCurrentUser)") + } + + // Set up KVO observation on currentUserIdentifier + profileObservation = userManager.observe(\.currentUserIdentifier, options: [.new, .old, .initial]) { [weak self] manager, change in + guard let self = self else { return } + + let newProfileId = change.newValue ?? nil + let oldProfileId = change.oldValue ?? nil + + print("[TvUserProfile] KVO fired - old: \(oldProfileId ?? "nil"), new: \(newProfileId ?? "nil")") + + // Only send event if the profile actually changed + if newProfileId != oldProfileId { + print("[TvUserProfile] Profile changed from \(oldProfileId ?? "nil") to \(newProfileId ?? "nil")") + self.sendEvent("onProfileChange", [ + "profileId": newProfileId as Any + ]) + } + } + + print("[TvUserProfile] Profile observer set up successfully") + } + #endif +} diff --git a/modules/wifi-ssid/ios/WifiSsidModule.swift b/modules/wifi-ssid/ios/WifiSsidModule.swift index 0a2a5faa6..5254fcb5d 100644 --- a/modules/wifi-ssid/ios/WifiSsidModule.swift +++ b/modules/wifi-ssid/ios/WifiSsidModule.swift @@ -1,13 +1,19 @@ import ExpoModulesCore +#if !os(tvOS) import NetworkExtension import SystemConfiguration.CaptiveNetwork +#endif public class WifiSsidModule: Module { public func definition() -> ModuleDefinition { Name("WifiSsid") // Get current WiFi SSID using NEHotspotNetwork (iOS 14+) + // Not available on tvOS AsyncFunction("getSSID") { () -> String? in + #if os(tvOS) + return nil + #else return await withCheckedContinuation { continuation in NEHotspotNetwork.fetchCurrent { network in if let ssid = network?.ssid { @@ -21,14 +27,21 @@ public class WifiSsidModule: Module { } } } + #endif } // Synchronous version using only CNCopyCurrentNetworkInfo + // Not available on tvOS Function("getSSIDSync") { () -> String? in + #if os(tvOS) + return nil + #else return self.getSSIDViaCNCopy() + #endif } } + #if !os(tvOS) private func getSSIDViaCNCopy() -> String? { guard let interfaces = CNCopySupportedInterfaces() as? [String] else { print("[WifiSsid] CNCopySupportedInterfaces returned nil") @@ -49,4 +62,5 @@ public class WifiSsidModule: Module { print("[WifiSsid] No SSID found via CNCopyCurrentNetworkInfo") return nil } + #endif } diff --git a/package.json b/package.json index 9864985a9..f7256b9a5 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "android:tv": "cross-env EXPO_TV=1 expo run:android", "build:android:local": "cd android && cross-env NODE_ENV=production ./gradlew assembleRelease", "ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production", + "ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production", "prepare": "husky", "typecheck": "node scripts/typecheck.js", "check": "biome check . --max-diagnostics 1000", @@ -25,69 +26,70 @@ "postinstall": "patch-package" }, "dependencies": { - "@bottom-tabs/react-navigation": "1.1.0", + "@bottom-tabs/react-navigation": "1.2.0", "@douglowder/expo-av-route-picker-view": "^0.0.5", - "@expo/metro-runtime": "~6.1.1", + "@expo/metro-runtime": "~56.0.13", "@expo/react-native-action-sheet": "^4.1.1", - "@expo/ui": "0.2.0-beta.9", + "@expo/ui": "~56.0.14", "@expo/vector-icons": "^15.0.3", "@gorhom/bottom-sheet": "5.2.8", "@jellyfin/sdk": "^0.13.0", - "@react-native-community/netinfo": "^11.4.1", - "@react-navigation/material-top-tabs": "7.4.9", - "@react-navigation/native": "^7.0.14", + "@react-native-community/netinfo": "^12.0.0", + "@react-navigation/native": "^7.2.5", "@shopify/flash-list": "2.0.2", - "@tanstack/query-sync-storage-persister": "^5.90.18", + "@tanstack/query-sync-storage-persister": "^5.100.14", "@tanstack/react-pacer": "^0.19.1", - "@tanstack/react-query": "5.90.20", - "@tanstack/react-query-persist-client": "^5.90.18", + "@tanstack/react-query": "5.100.14", + "@tanstack/react-query-persist-client": "^5.100.14", "axios": "^1.7.9", - "expo": "~54.0.31", - "expo-application": "~7.0.8", - "expo-asset": "~12.0.12", - "expo-background-task": "~1.0.10", - "expo-blur": "~15.0.8", - "expo-brightness": "~14.0.8", - "expo-build-properties": "~1.0.10", - "expo-constants": "18.0.13", - "expo-crypto": "^15.0.8", - "expo-dev-client": "~6.0.20", - "expo-device": "~8.0.10", - "expo-font": "~14.0.10", - "expo-haptics": "~15.0.8", - "expo-image": "~3.0.11", - "expo-linear-gradient": "~15.0.8", - "expo-linking": "~8.0.11", - "expo-localization": "~17.0.8", - "expo-location": "^19.0.8", - "expo-notifications": "~0.32.16", - "expo-router": "~6.0.21", - "expo-screen-orientation": "~9.0.8", - "expo-secure-store": "^15.0.8", - "expo-sharing": "~14.0.8", - "expo-splash-screen": "~31.0.13", - "expo-status-bar": "~3.0.9", - "expo-system-ui": "~6.0.9", - "expo-task-manager": "14.0.9", - "expo-web-browser": "~15.0.10", - "i18next": "^25.0.0", - "jotai": "2.16.2", - "lodash": "4.17.23", + "expo": "~56.0.6", + "expo-application": "~56.0.3", + "expo-asset": "~56.0.15", + "expo-audio": "~56.0.11", + "expo-background-task": "~56.0.15", + "expo-blur": "~56.0.3", + "expo-brightness": "~56.0.5", + "expo-build-properties": "~56.0.15", + "expo-camera": "~56.0.7", + "expo-constants": "~56.0.16", + "expo-crypto": "~56.0.4", + "expo-dev-client": "~56.0.16", + "expo-device": "~56.0.4", + "expo-font": "~56.0.5", + "expo-haptics": "~56.0.3", + "expo-image": "~56.0.9", + "expo-linear-gradient": "~56.0.4", + "expo-linking": "~56.0.12", + "expo-localization": "~56.0.6", + "expo-location": "~56.0.14", + "expo-notifications": "~56.0.14", + "expo-router": "~56.2.7", + "expo-screen-orientation": "~56.0.5", + "expo-secure-store": "~56.0.4", + "expo-sharing": "~56.0.14", + "expo-splash-screen": "~56.0.10", + "expo-status-bar": "~56.0.4", + "expo-system-ui": "~56.0.5", + "expo-task-manager": "~56.0.15", + "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.5.4", - "react-native": "0.81.5", + "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.1.0", + "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", @@ -95,12 +97,13 @@ "react-native-ios-utilities": "5.2.0", "react-native-mmkv": "4.1.1", "react-native-nitro-modules": "0.33.1", - "react-native-pager-view": "^6.9.1", - "react-native-reanimated": "~4.1.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.6.0", - "react-native-screens": "~4.18.0", - "react-native-svg": "15.12.1", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "4.25.2", + "react-native-svg": "15.15.4", "react-native-text-ticker": "^1.15.0", "react-native-track-player": "github:lovegaoshi/react-native-track-player#APM", "react-native-udp": "^4.1.7", @@ -108,25 +111,25 @@ "react-native-uuid": "^2.0.3", "react-native-volume-manager": "^2.0.8", "react-native-web": "^0.21.0", - "react-native-worklets": "0.5.1", + "react-native-worklets": "0.8.3", "sonner-native": "0.21.2", "tailwindcss": "3.3.2", "use-debounce": "^10.0.4", - "zod": "4.1.13" + "zod": "4.4.3" }, "devDependencies": { - "@babel/core": "7.28.6", - "@biomejs/biome": "2.3.11", - "@react-native-community/cli": "20.1.1", - "@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.23", - "@types/react": "19.1.17", + "@types/lodash": "4.17.24", + "@types/react": "~19.2.10", "@types/react-test-renderer": "19.1.0", "cross-env": "10.1.0", - "expo-doctor": "1.17.14", + "expo-doctor": "1.19.7", "husky": "9.1.7", - "lint-staged": "16.2.7", + "lint-staged": "17.0.5", "react-test-renderer": "19.2.3", "typescript": "5.9.3" }, @@ -143,6 +146,7 @@ }, "install": { "exclude": [ + "react-native", "react-native-screens" ] } @@ -159,7 +163,9 @@ "trustedDependencies": [ "unrs-resolver" ], - "resolutions": { - "expo-constants": "18.0.13" + "patchedDependencies": { + "react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch", + "react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch", + "react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch" } } diff --git a/plugins/with-runtime-framework-headers.js b/plugins/with-runtime-framework-headers.js index 2b660f702..23e7d1011 100644 --- a/plugins/with-runtime-framework-headers.js +++ b/plugins/with-runtime-framework-headers.js @@ -24,6 +24,45 @@ function buildPatch() { " t.build_configurations.each do |cfg|", " cfg.build_settings['HEADER_SEARCH_PATHS'] ||= '$(inherited)'", " cfg.build_settings['HEADER_SEARCH_PATHS'] << \" #{extra_hdrs.join(' ')}\"", + " cfg.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES'", + " # iOS 26 / Xcode 26: SwiftUI was split into SwiftUI + SwiftUICore. The SwiftUI", + " # pods (ExpoUI, glass-effect, glass-poster, …) emit a `-framework SwiftUICore`", + " # autolink directive that, under use_frameworks :static, flows into the app", + " # executable's link. The app isn't an allowed client of the private", + " # SwiftUICore.tbd → `cannot link directly with 'SwiftUICore'`. Dropping that one", + " # autolink at the Swift frontend lets the symbols resolve via SwiftUI's", + " # re-export instead. Phone-only — tvOS links fine and must stay untouched.", + " if ENV['EXPO_TV'] != '1'", + " cfg.build_settings['OTHER_SWIFT_FLAGS'] ||= '$(inherited)'", + " cfg.build_settings['OTHER_SWIFT_FLAGS'] << ' -Xfrontend -disable-autolink-framework -Xfrontend SwiftUICore'", + " end", + " end", + " end", + "", + " # Safely patch RCTThirdPartyComponentsProvider.mm to avoid startup crash on unlinked Fabric components", + ' filepath = "#{installer.sandbox.root}/../build/generated/ios/ReactCodegen/RCTThirdPartyComponentsProvider.mm"', + " if File.exist?(filepath)", + " content = File.read(filepath)", + " if content =~ /thirdPartyComponents = @\\{([\\s\\S]*?)\\};/", + " entries = $1", + ' new_code = "NSMutableDictionary *dict = [NSMutableDictionary dictionary];\\n"', + ' new_code += " Class cls;\\n"', + " entries.each_line do |line|", + " line = line.strip", + " next if line.empty?", + ' if line =~ /@\\"(.*?)\\":\\s*NSClassFromString\\(@\\"(.*?)\\"\\),?(.*)/', + " key = $1", + " val = $2", + " comment = $3", + ' new_code += " cls = NSClassFromString(@\\"#{val}\\"); if (cls) dict[@\\"#{key}\\"] = cls;#{comment}\\n"', + " else", + ' new_code += " // #{line}\\n"', + " end", + " end", + ' new_code += " thirdPartyComponents = dict;"', + " content = content.sub(/thirdPartyComponents = @\\{[\\s\\S]*?\\};/, new_code)", + " File.write(filepath, content)", + ' puts "✅ Patched RCTThirdPartyComponentsProvider.mm for safety"', " end", " end", PATCH_END, diff --git a/plugins/withAndroidAlertColors.js b/plugins/withAndroidAlertColors.js new file mode 100644 index 000000000..c0570047b --- /dev/null +++ b/plugins/withAndroidAlertColors.js @@ -0,0 +1,40 @@ +const { + withAndroidColors, + withAndroidColorsNight, +} = require("expo/config-plugins"); + +const withAndroidAlertColors = (config) => { + const setColor = (colorsList, name, value) => { + const existingColor = colorsList.find( + (item) => item.$ && item.$.name === name, + ); + if (existingColor) { + existingColor._ = value; + } else { + colorsList.push({ + $: { name }, + _: value, + }); + } + }; + + config = withAndroidColors(config, (config) => { + const colors = config.modResults; + const colorsList = colors.resources.color || []; + setColor(colorsList, "colorPrimary", "#000000"); + colors.resources.color = colorsList; + return config; + }); + + config = withAndroidColorsNight(config, (config) => { + const colors = config.modResults; + const colorsList = colors.resources.color || []; + setColor(colorsList, "colorPrimary", "#FFFFFF"); + colors.resources.color = colorsList; + return config; + }); + + return config; +}; + +module.exports = withAndroidAlertColors; diff --git a/plugins/withTVOSAppIcon.js b/plugins/withTVOSAppIcon.js new file mode 100644 index 000000000..50114eb6b --- /dev/null +++ b/plugins/withTVOSAppIcon.js @@ -0,0 +1,31 @@ +const { withXcodeProject } = require("@expo/config-plugins"); + +const withTVOSAppIcon = (config) => { + // Only apply for TV builds + if (process.env.EXPO_TV !== "1") { + return config; + } + + return withXcodeProject(config, async (config) => { + const xcodeProject = config.modResults; + + const buildConfigurations = xcodeProject.pbxXCBuildConfigurationSection(); + + for (const key in buildConfigurations) { + const buildConfig = buildConfigurations[key]; + if ( + typeof buildConfig === "object" && + buildConfig.buildSettings && + buildConfig.buildSettings.PRODUCT_NAME + ) { + // Set the tvOS app icon + buildConfig.buildSettings.ASSETCATALOG_COMPILER_APPICON_NAME = + "TVAppIcon"; + } + } + + return config; + }); +}; + +module.exports = withTVOSAppIcon; diff --git a/plugins/withTVOSTopShelf.js b/plugins/withTVOSTopShelf.js new file mode 100644 index 000000000..56610fcf7 --- /dev/null +++ b/plugins/withTVOSTopShelf.js @@ -0,0 +1,196 @@ +const { + withEntitlementsPlist, + withInfoPlist, + withXcodeProject, +} = require("@expo/config-plugins"); + +const EXTENSION_TARGET_NAME = "StreamyfinTopShelf"; +const TARGET_SOURCE_DIR = "../targets/StreamyfinTopShelf"; +const APP_GROUP_INFO_PLIST_KEY = "StreamyfinAppGroupIdentifier"; +const KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY = + "StreamyfinKeychainAccessGroupIdentifier"; + +function getBundleIdentifier(config) { + return config.ios?.bundleIdentifier || "com.fredrikburmester.streamyfin"; +} + +function getAppGroupIdentifier(config) { + return `group.${getBundleIdentifier(config)}.tvtopshelf`; +} + +function getKeychainAccessGroupIdentifier(config) { + return `$(AppIdentifierPrefix)${getBundleIdentifier(config)}`; +} + +function getBuildConfigurations(project, configurationListId) { + const configurationList = + project.hash.project.objects.XCConfigurationList[configurationListId]; + + if (!configurationList?.buildConfigurations) return []; + + const buildConfigurations = project.pbxXCBuildConfigurationSection(); + return configurationList.buildConfigurations + .map((config) => buildConfigurations[config.value]) + .filter(Boolean); +} + +function ensureAppGroup(value, appGroupIdentifier) { + const groups = Array.isArray(value) ? value : []; + return groups.includes(appGroupIdentifier) + ? groups + : [...groups, appGroupIdentifier]; +} + +function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) { + const groups = Array.isArray(value) ? value : []; + return groups.includes(keychainAccessGroupIdentifier) + ? groups + : [...groups, keychainAccessGroupIdentifier]; +} + +function ensureAppExtension( + appExtensions, + targetName, + bundleIdentifier, + appGroupIdentifier, + keychainAccessGroupIdentifier, +) { + const extensionConfig = { + targetName, + bundleIdentifier, + entitlements: { + "com.apple.security.application-groups": [appGroupIdentifier], + "keychain-access-groups": [keychainAccessGroupIdentifier], + }, + }; + const extensions = Array.isArray(appExtensions) ? appExtensions : []; + // Keep plugin runs idempotent and preserve unrelated app extension entries. + const existingIndex = extensions.findIndex( + (appExtension) => appExtension?.targetName === targetName, + ); + + if (existingIndex === -1) { + return [...extensions, extensionConfig]; + } + + return extensions.map((appExtension, index) => + index === existingIndex ? extensionConfig : appExtension, + ); +} + +const withTVOSTopShelf = (config) => { + const appGroupIdentifier = getAppGroupIdentifier(config); + const keychainAccessGroupIdentifier = + getKeychainAccessGroupIdentifier(config); + const bundleIdentifier = getBundleIdentifier(config); + const extensionBundleIdentifier = `${bundleIdentifier}.tvtopshelf`; + const isTVBuild = process.env.EXPO_TV === "1"; + + if (isTVBuild) { + config.extra = { + ...config.extra, + eas: { + ...config.extra?.eas, + build: { + ...config.extra?.eas?.build, + experimental: { + ...config.extra?.eas?.build?.experimental, + ios: { + ...config.extra?.eas?.build?.experimental?.ios, + appExtensions: ensureAppExtension( + config.extra?.eas?.build?.experimental?.ios?.appExtensions, + EXTENSION_TARGET_NAME, + extensionBundleIdentifier, + appGroupIdentifier, + keychainAccessGroupIdentifier, + ), + }, + }, + }, + }, + }; + + config = withInfoPlist(config, (config) => { + config.modResults[APP_GROUP_INFO_PLIST_KEY] = appGroupIdentifier; + config.modResults[KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY] = + keychainAccessGroupIdentifier; + return config; + }); + + config = withEntitlementsPlist(config, (config) => { + config.modResults["com.apple.security.application-groups"] = + ensureAppGroup( + config.modResults["com.apple.security.application-groups"], + appGroupIdentifier, + ); + config.modResults["keychain-access-groups"] = ensureKeychainAccessGroup( + config.modResults["keychain-access-groups"], + keychainAccessGroupIdentifier, + ); + return config; + }); + } + + if (!isTVBuild) { + return config; + } + + return withXcodeProject(config, (config) => { + const project = config.modResults; + + if (project.pbxTargetByName(EXTENSION_TARGET_NAME)) { + return config; + } + + const target = project.addTarget( + EXTENSION_TARGET_NAME, + "app_extension", + EXTENSION_TARGET_NAME, + extensionBundleIdentifier, + ); + + project.addBuildPhase( + [`${TARGET_SOURCE_DIR}/TopShelfProvider.swift`], + "PBXSourcesBuildPhase", + "Sources", + target.uuid, + ); + project.addBuildPhase( + ["TVServices.framework"], + "PBXFrameworksBuildPhase", + "Frameworks", + target.uuid, + ); + + const buildConfigurations = getBuildConfigurations( + project, + target.pbxNativeTarget.buildConfigurationList, + ); + + for (const buildConfig of buildConfigurations) { + buildConfig.buildSettings = { + ...buildConfig.buildSettings, + CODE_SIGN_ENTITLEMENTS: `${TARGET_SOURCE_DIR}/${EXTENSION_TARGET_NAME}.entitlements`, + APPLICATION_EXTENSION_API_ONLY: "YES", + CURRENT_PROJECT_VERSION: "1", + INFOPLIST_FILE: `${TARGET_SOURCE_DIR}/Info.plist`, + IPHONEOS_DEPLOYMENT_TARGET: + buildConfig.buildSettings.IPHONEOS_DEPLOYMENT_TARGET || "15.6", + MARKETING_VERSION: config.version || "1.0", + PRODUCT_BUNDLE_IDENTIFIER: extensionBundleIdentifier, + PRODUCT_NAME: `"${EXTENSION_TARGET_NAME}"`, + SDKROOT: "appletvos", + SKIP_INSTALL: "YES", + SWIFT_VERSION: "5.9", + APP_GROUP_IDENTIFIER: appGroupIdentifier, + KEYCHAIN_ACCESS_GROUP_IDENTIFIER: `"${keychainAccessGroupIdentifier}"`, + SUPPORTED_PLATFORMS: '"appletvos appletvsimulator"', + TARGETED_DEVICE_FAMILY: "3", + }; + } + + return config; + }); +}; + +module.exports = withTVOSTopShelf; diff --git a/plugins/withTVUserManagement.js b/plugins/withTVUserManagement.js new file mode 100644 index 000000000..0cb2f8e85 --- /dev/null +++ b/plugins/withTVUserManagement.js @@ -0,0 +1,20 @@ +const { withEntitlementsPlist } = require("expo/config-plugins"); + +/** + * Expo config plugin to add User Management entitlement for tvOS profile linking + */ +const withTVUserManagement = (config) => { + return withEntitlementsPlist(config, (config) => { + // Only add for tvOS builds (check if building for TV) + // The entitlement is needed for TVUserManager.currentUserIdentifier to work + config.modResults["com.apple.developer.user-management"] = [ + "runs-as-current-user", + ]; + + console.log("[withTVUserManagement] Added user-management entitlement"); + + return config; + }); +}; + +module.exports = withTVUserManagement; diff --git a/plugins/withTVXcodeEnv.js b/plugins/withTVXcodeEnv.js new file mode 100644 index 000000000..86f367552 --- /dev/null +++ b/plugins/withTVXcodeEnv.js @@ -0,0 +1,117 @@ +const { withDangerousMod } = require("@expo/config-plugins"); +const { execSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +/** + * Expo config plugin that adds EXPO_TV=1 and NODE_BINARY to .xcode.env.local for TV builds. + * + * This ensures that when building directly from Xcode (without using `bun run ios:tv`), + * Metro bundler knows it's a TV build and properly excludes unsupported modules + * like react-native-track-player. + * + * It also sets NODE_BINARY for nvm users since Xcode can't resolve shell functions. + */ +const withTVXcodeEnv = (config) => { + // Only apply for TV builds + if (process.env.EXPO_TV !== "1") { + return config; + } + + return withDangerousMod(config, [ + "ios", + async (config) => { + const iosPath = path.join(config.modRequest.projectRoot, "ios"); + const xcodeEnvLocalPath = path.join(iosPath, ".xcode.env.local"); + + // Read existing content or start fresh + let content = ""; + if (fs.existsSync(xcodeEnvLocalPath)) { + content = fs.readFileSync(xcodeEnvLocalPath, "utf-8"); + } + + let modified = false; + + // Add NODE_BINARY if not already present (needed for nvm users) + if (!content.includes("export NODE_BINARY=")) { + const nodePath = getNodeBinaryPath(); + if (nodePath) { + if (content.length > 0 && !content.endsWith("\n")) { + content += "\n"; + } + content += `export NODE_BINARY=${nodePath}\n`; + modified = true; + console.log( + `[withTVXcodeEnv] Added NODE_BINARY=${nodePath} to .xcode.env.local`, + ); + } + } + + // Add EXPO_TV=1 if not already present + const expoTvExport = "export EXPO_TV=1"; + if (!content.includes(expoTvExport)) { + if (content.length > 0 && !content.endsWith("\n")) { + content += "\n"; + } + content += `${expoTvExport}\n`; + modified = true; + console.log("[withTVXcodeEnv] Added EXPO_TV=1 to .xcode.env.local"); + } + + if (modified) { + fs.writeFileSync(xcodeEnvLocalPath, content); + } + + return config; + }, + ]); +}; + +/** + * Get the actual node binary path, handling nvm installations. + */ +function getNodeBinaryPath() { + try { + // First try to get node path directly (works for non-nvm installs) + const directPath = execSync("which node 2>/dev/null", { + encoding: "utf-8", + }).trim(); + if (directPath && fs.existsSync(directPath)) { + return directPath; + } + } catch { + // Ignore errors + } + + try { + // For nvm users, source nvm and get the path + const nvmPath = execSync( + 'bash -c "source ~/.nvm/nvm.sh 2>/dev/null && which node"', + { encoding: "utf-8" }, + ).trim(); + if (nvmPath && fs.existsSync(nvmPath)) { + return nvmPath; + } + } catch { + // Ignore errors + } + + // Fallback: look for node in common nvm location + const homeDir = process.env.HOME || process.env.USERPROFILE; + if (homeDir) { + const nvmVersionsDir = path.join(homeDir, ".nvm", "versions", "node"); + if (fs.existsSync(nvmVersionsDir)) { + const versions = fs.readdirSync(nvmVersionsDir).sort().reverse(); + for (const version of versions) { + const nodeBin = path.join(nvmVersionsDir, version, "bin", "node"); + if (fs.existsSync(nodeBin)) { + return nodeBin; + } + } + } + } + + return null; +} + +module.exports = withTVXcodeEnv; diff --git a/providers/GlobalModalProvider.tsx b/providers/GlobalModalProvider.tsx index 76ced95ab..c22688994 100644 --- a/providers/GlobalModalProvider.tsx +++ b/providers/GlobalModalProvider.tsx @@ -5,10 +5,13 @@ import { type ReactNode, useCallback, useContext, + useEffect, useRef, useState, } from "react"; +import { BackHandler, Platform } from "react-native"; + interface ModalOptions { enableDynamicSizing?: boolean; snapPoints?: (string | number)[]; @@ -73,6 +76,25 @@ export const GlobalModalProvider: React.FC = ({ }); }, []); + useEffect(() => { + if (Platform.OS !== "android") return; + + const onBackPress = () => { + if (isVisible) { + hideModal(); + return true; + } + return false; + }; + + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + onBackPress, + ); + + return () => subscription.remove(); + }, [isVisible, hideModal]); + const value = { showModal, hideModal, diff --git a/providers/InactivityProvider.tsx b/providers/InactivityProvider.tsx new file mode 100644 index 000000000..2c47ada6f --- /dev/null +++ b/providers/InactivityProvider.tsx @@ -0,0 +1,221 @@ +import type React from "react"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useRef, +} from "react"; +import { AppState, type AppStateStatus, Platform } from "react-native"; +import { useJellyfin } from "@/providers/JellyfinProvider"; +import { InactivityTimeout, useSettings } from "@/utils/atoms/settings"; +import { storage } from "@/utils/mmkv"; + +const INACTIVITY_LAST_ACTIVITY_KEY = "INACTIVITY_LAST_ACTIVITY"; + +interface InactivityContextValue { + resetInactivityTimer: () => void; + pauseInactivityTimer: () => void; + resumeInactivityTimer: () => void; +} + +const InactivityContext = createContext( + undefined, +); + +/** + * TV-only provider that tracks user inactivity and auto-logs out + * when the configured timeout is exceeded. + * + * Features: + * - Tracks last activity timestamp (persisted to MMKV) + * - Resets timer on any focus change (via resetInactivityTimer) + * - Pauses timer during video playback (via pauseInactivityTimer/resumeInactivityTimer) + * - Handles app backgrounding: logs out immediately if timeout exceeded while away + * - No-op on mobile platforms + */ +export const InactivityProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const { settings } = useSettings(); + const { logout } = useJellyfin(); + const timerRef = useRef(null); + const appStateRef = useRef(AppState.currentState); + const isPausedRef = useRef(false); + + const timeoutMs = settings.inactivityTimeout ?? InactivityTimeout.Disabled; + const isEnabled = Platform.isTV && timeoutMs > 0; + + const clearTimer = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const updateLastActivity = useCallback(() => { + if (!isEnabled) return; + storage.set(INACTIVITY_LAST_ACTIVITY_KEY, Date.now()); + }, [isEnabled]); + + const getLastActivity = useCallback((): number => { + return storage.getNumber(INACTIVITY_LAST_ACTIVITY_KEY) ?? Date.now(); + }, []); + + const startTimer = useCallback( + (remainingMs?: number) => { + if (!isEnabled || isPausedRef.current) return; + + clearTimer(); + + const delay = remainingMs ?? timeoutMs; + timerRef.current = setTimeout(() => { + logout(); + storage.remove(INACTIVITY_LAST_ACTIVITY_KEY); + }, delay); + }, + [isEnabled, timeoutMs, clearTimer, logout], + ); + + const resetInactivityTimer = useCallback(() => { + if (!isEnabled || isPausedRef.current) return; + + updateLastActivity(); + startTimer(); + }, [isEnabled, updateLastActivity, startTimer]); + + const pauseInactivityTimer = useCallback(() => { + if (!isEnabled) return; + + isPausedRef.current = true; + clearTimer(); + // Update last activity so when we resume, we start fresh + updateLastActivity(); + }, [isEnabled, clearTimer, updateLastActivity]); + + const resumeInactivityTimer = useCallback(() => { + if (!isEnabled) return; + + isPausedRef.current = false; + updateLastActivity(); + startTimer(); + }, [isEnabled, updateLastActivity, startTimer]); + + // Handle app state changes (background/foreground) + useEffect(() => { + if (!isEnabled) return; + + const handleAppStateChange = (nextAppState: AppStateStatus) => { + const wasBackground = + appStateRef.current === "background" || + appStateRef.current === "inactive"; + const isNowActive = nextAppState === "active"; + + if (wasBackground && isNowActive) { + // App returned to foreground + // If paused (e.g., video playing), don't check timeout + if (isPausedRef.current) { + appStateRef.current = nextAppState; + return; + } + + // Check if timeout exceeded + const lastActivity = getLastActivity(); + const elapsed = Date.now() - lastActivity; + + if (elapsed >= timeoutMs) { + // Timeout exceeded while backgrounded - logout immediately + logout(); + storage.remove(INACTIVITY_LAST_ACTIVITY_KEY); + } else { + // Restart timer with remaining time + const remainingMs = timeoutMs - elapsed; + startTimer(remainingMs); + } + } else if (nextAppState === "background" || nextAppState === "inactive") { + // App going to background - clear timer (time continues via timestamp) + clearTimer(); + } + + appStateRef.current = nextAppState; + }; + + const subscription = AppState.addEventListener( + "change", + handleAppStateChange, + ); + + return () => { + subscription.remove(); + }; + }, [isEnabled, timeoutMs, getLastActivity, startTimer, clearTimer, logout]); + + // Initialize timer when enabled or timeout changes + useEffect(() => { + if (!isEnabled) { + clearTimer(); + return; + } + + // Don't start timer if paused + if (isPausedRef.current) return; + + // Check if we should logout based on last activity + const lastActivity = getLastActivity(); + const elapsed = Date.now() - lastActivity; + + if (elapsed >= timeoutMs) { + // Already timed out - logout + logout(); + storage.remove(INACTIVITY_LAST_ACTIVITY_KEY); + } else { + // Start timer with remaining time + const remainingMs = timeoutMs - elapsed; + startTimer(remainingMs); + } + + return () => { + clearTimer(); + }; + }, [isEnabled, timeoutMs, getLastActivity, startTimer, clearTimer, logout]); + + // Reset activity on initial mount when enabled + useEffect(() => { + if (isEnabled && !isPausedRef.current) { + updateLastActivity(); + startTimer(); + } + }, []); + + const contextValue: InactivityContextValue = { + resetInactivityTimer, + pauseInactivityTimer, + resumeInactivityTimer, + }; + + return ( + + {children} + + ); +}; + +/** + * Hook to access the inactivity timer controls. + * Returns no-op functions if not within the provider (safe on mobile). + */ +export const useInactivity = (): InactivityContextValue => { + const context = useContext(InactivityContext); + + // Return no-ops if not within provider (e.g., on mobile) + if (!context) { + return { + resetInactivityTimer: () => {}, + pauseInactivityTimer: () => {}, + resumeInactivityTimer: () => {}, + }; + } + + return context; +}; diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 0bce84390..7dff4b366 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -2,7 +2,7 @@ import "@/augmentations"; import { type Api, Jellyfin } from "@jellyfin/sdk"; import type { UserDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getUserApi } from "@jellyfin/sdk/lib/utils/api"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import axios, { AxiosError } from "axios"; import { useSegments } from "expo-router"; import * as SplashScreen from "expo-splash-screen"; @@ -19,7 +19,7 @@ import { } from "react"; import { useTranslation } from "react-i18next"; import { AppState, Platform } from "react-native"; -import { getDeviceName } from "react-native-device-info"; +import { getDeviceNameSync } from "react-native-device-info"; import uuid from "react-native-uuid"; import useRouter from "@/hooks/useAppRouter"; import { useInterval } from "@/hooks/useInterval"; @@ -29,6 +29,7 @@ import { writeErrorLog, writeInfoLog } from "@/utils/log"; import { storage } from "@/utils/mmkv"; import { type AccountSecurityType, + addAccountToServer, addServerToList, deleteAccountCredential, getAccountCredential, @@ -38,14 +39,50 @@ import { updateAccountToken, } from "@/utils/secureCredentials"; import { store } from "@/utils/store"; +import { clearTVDiscoverySafely } from "@/utils/tvDiscovery/sync"; interface Server { address: string; } -export const apiAtom = atom(null); -export const userAtom = atom(null); +const initialApi = (() => { + try { + const token = storage.getString("token") || null; + const serverUrl = storage.getString("serverUrl") || null; + if (serverUrl && token) { + const id = getOrSetDeviceId(); + const deviceName = getDeviceNameSync(); + const jellyfinInstance = new Jellyfin({ + clientInfo: { name: "Streamyfin", version: "0.54.0" }, + deviceInfo: { + name: deviceName, + id, + }, + }); + return jellyfinInstance.createApi(serverUrl, token); + } + } catch (e) { + console.error("Failed to initialize API synchronously:", e); + } + return null; +})(); + +const initialUser = (() => { + try { + const userStr = storage.getString("user"); + if (userStr) { + return JSON.parse(userStr) as UserDto; + } + } catch (e) { + console.error("Failed to parse initial user synchronously:", e); + } + return null; +})(); + +export const apiAtom = atom(initialApi); +export const userAtom = atom(initialUser); export const wsAtom = atom(null); +export const cacheVersionAtom = atom(0); interface LoginOptions { saveAccount?: boolean; @@ -65,6 +102,7 @@ interface JellyfinContextValue { ) => Promise; logout: () => Promise; initiateQuickConnect: () => Promise; + stopQuickConnectPolling: () => void; loginWithSavedCredential: ( serverUrl: string, userId: string, @@ -85,42 +123,46 @@ const JellyfinContext = createContext( export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ children, }) => { - const [jellyfin, setJellyfin] = useState(undefined); - const [deviceId, setDeviceId] = useState(undefined); + const [jellyfin] = useState(() => { + try { + const id = getOrSetDeviceId(); + const deviceName = getDeviceNameSync(); + return new Jellyfin({ + clientInfo: { name: "Streamyfin", version: "0.54.0" }, + deviceInfo: { + name: deviceName, + id, + }, + }); + } catch (e) { + console.error("Failed to initialize Jellyfin synchronously in state:", e); + return undefined; + } + }); + const [deviceId] = useState(() => { + try { + return getOrSetDeviceId(); + } catch { + return undefined; + } + }); const { t } = useTranslation(); - useEffect(() => { - (async () => { - const id = getOrSetDeviceId(); - const deviceName = await getDeviceName(); - setJellyfin( - () => - new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.52.0" }, - deviceInfo: { - name: deviceName, - id, - }, - }), - ); - setDeviceId(id); - })(); - }, []); - const [api, setApi] = useAtom(apiAtom); const [user, setUser] = useAtom(userAtom); const [isPolling, setIsPolling] = useState(false); const [secret, setSecret] = useState(null); const { setPluginSettings, refreshStreamyfinPluginSettings } = useSettings(); const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr(); + const queryClient = useQueryClient(); const headers = useMemo(() => { if (!deviceId) return {}; return { authorization: `MediaBrowser Client="Streamyfin", Device=${ Platform.OS === "android" ? "Android" : "iOS" - }, DeviceId="${deviceId}", Version="0.52.0"`, + }, DeviceId="${deviceId}", Version="0.54.0"`, }; }, [deviceId]); @@ -146,6 +188,11 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ } }, [api, deviceId, headers]); + const stopQuickConnectPolling = useCallback(() => { + setIsPolling(false); + setSecret(null); + }, []); + const pollQuickConnect = useCallback(async () => { if (!api || !secret || !jellyfin) return; @@ -178,10 +225,15 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ } return false; } catch (error) { - if (error instanceof AxiosError && error.response?.status === 400) { - setIsPolling(false); - setSecret(null); - throw new Error("The code has expired. Please try again."); + if (error instanceof AxiosError) { + if (error.response?.status === 400 || error.response?.status === 404) { + setIsPolling(false); + setSecret(null); + if (error.response?.status === 400) { + throw new Error("The code has expired. Please try again."); + } + return false; + } } console.error("Error polling Quick Connect:", error); throw error; @@ -219,6 +271,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const setServerMutation = useMutation({ mutationFn: async (server: Server) => { + clearTVDiscoverySafely(); const apiInstance = jellyfin?.createApi(server.address); if (!apiInstance?.basePath) throw new Error("Failed to connect"); @@ -237,6 +290,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const removeServerMutation = useMutation({ mutationFn: async () => { + clearTVDiscoverySafely(); storage.remove("serverUrl"); setApi(null); }, @@ -286,6 +340,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ savedAt: Date.now(), securityType, pinHash, + primaryImageTag: auth.data.User.PrimaryImageTag ?? undefined, }); } @@ -338,7 +393,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const logoutMutation = useMutation({ mutationFn: async () => { - await api + // Fire-and-forget: don't block logout on server cleanup + api ?.delete(`/Streamyfin/device/${deviceId}`) .then((_r) => writeInfoLog("Deleted expo push token for device")) .catch((_e) => @@ -346,10 +402,16 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ ); storage.remove("token"); + clearTVDiscoverySafely(); setUser(null); setApi(null); setPluginSettings(undefined); await clearAllJellyseerData(); + + // Clear React Query cache to prevent data from previous account lingering + queryClient.clear(); + storage.remove("REACT_QUERY_OFFLINE_CACHE"); + // Note: We keep saved credentials for quick switching back }, onError: (error) => { @@ -382,6 +444,10 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ try { const response = await getUserApi(apiInstance).getCurrentUser(); + // Clear React Query cache to prevent data from previous account lingering + queryClient.clear(); + storage.remove("REACT_QUERY_OFFLINE_CACHE"); + // Token is valid, update state setApi(apiInstance); setUser(response.data); @@ -389,17 +455,47 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ storage.set("token", credential.token); storage.set("user", JSON.stringify(response.data)); + // Update account info (in case user changed their avatar) + if (response.data.PrimaryImageTag !== credential.primaryImageTag) { + addAccountToServer(serverUrl, credential.serverName, { + userId: credential.userId, + username: credential.username, + securityType: credential.securityType, + savedAt: credential.savedAt, + primaryImageTag: response.data.PrimaryImageTag ?? undefined, + }); + } + // Refresh plugin settings await refreshStreamyfinPluginSettings(); } catch (error) { - // Token is invalid/expired - remove it - if ( - axios.isAxiosError(error) && - (error.response?.status === 401 || error.response?.status === 403) - ) { - await deleteAccountCredential(serverUrl, userId); - throw new Error(t("server.session_expired")); + // Check for axios error + if (axios.isAxiosError(error)) { + // Token is invalid/expired - remove it + if ( + error.response?.status === 401 || + error.response?.status === 403 + ) { + await deleteAccountCredential(serverUrl, userId); + throw new Error(t("server.session_expired")); + } + + // Network error - server not reachable (no response means server didn't respond) + if (!error.response) { + throw new Error(t("home.server_unreachable")); + } } + + // Check for network error by message pattern (fallback detection) + if ( + error instanceof Error && + (error.message.toLowerCase().includes("network") || + error.message.toLowerCase().includes("econnrefused") || + error.message.toLowerCase().includes("timeout")) + ) { + throw new Error(t("home.server_unreachable")); + } + throw error; } }, @@ -430,17 +526,22 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const auth = await apiInstance.authenticateUserByName(username, password); if (auth.data.AccessToken && auth.data.User) { + // Clear React Query cache to prevent data from previous account lingering + queryClient.clear(); + storage.remove("REACT_QUERY_OFFLINE_CACHE"); + setUser(auth.data.User); storage.set("user", JSON.stringify(auth.data.User)); setApi(jellyfin.createApi(serverUrl, auth.data.AccessToken)); storage.set("serverUrl", serverUrl); storage.set("token", auth.data.AccessToken); - // Update the saved credential with new token + // Update the saved credential with new token and image tag await updateAccountToken( serverUrl, auth.data.User.Id || "", auth.data.AccessToken, + auth.data.User.PrimaryImageTag ?? undefined, ); // Refresh plugin settings @@ -472,6 +573,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ (newUrl: string) => { if (!jellyfin || !api?.accessToken) return; + clearTVDiscoverySafely(); const newApi = jellyfin.createApi(newUrl, api.accessToken); setApi(newApi); // Note: We don't update storage.set("serverUrl") here @@ -527,6 +629,19 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ username: storedUser.Name, savedAt: Date.now(), securityType: "none", + primaryImageTag: response.data.PrimaryImageTag ?? undefined, + }); + } else if ( + response.data.PrimaryImageTag !== + existingCredential.primaryImageTag + ) { + // Update image tag if it has changed + addAccountToServer(serverUrl, existingCredential.serverName, { + userId: existingCredential.userId, + username: existingCredential.username, + securityType: existingCredential.securityType, + savedAt: existingCredential.savedAt, + primaryImageTag: response.data.PrimaryImageTag ?? undefined, }); } } @@ -549,6 +664,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ loginMutation.mutateAsync({ username, password, serverName, options }), logout: () => logoutMutation.mutateAsync(), initiateQuickConnect, + stopQuickConnectPolling, loginWithSavedCredential: (serverUrl, userId) => loginWithSavedCredentialMutation.mutateAsync({ serverUrl, userId }), loginWithPassword: (serverUrl, username, password) => @@ -588,10 +704,11 @@ function useProtectedRoute(user: UserDto | null, loaded = false) { if (loaded === false) return; const inAuthGroup = segments.length > 1 && segments[0] === "(auth)"; + const isTopShelfLaunchRoute = segments[0] === "topshelf"; if (!user?.Id && inAuthGroup) { router.replace("/login"); - } else if (user?.Id && !inAuthGroup) { + } else if (user?.Id && !inAuthGroup && !isTopShelfLaunchRoute) { router.replace("/(auth)/(tabs)/(home)/"); } }, [user, segments, loaded]); diff --git a/providers/MusicPlayerProvider.tsx b/providers/MusicPlayerProvider.tsx index 63871a22b..71bce6e11 100644 --- a/providers/MusicPlayerProvider.tsx +++ b/providers/MusicPlayerProvider.tsx @@ -15,12 +15,7 @@ import React, { useRef, useState, } from "react"; -import TrackPlayer, { - Capability, - type Progress, - RepeatMode as TPRepeatMode, - type Track, -} from "react-native-track-player"; +import { Platform } from "react-native"; import { downloadTrack, getLocalPath, @@ -34,6 +29,22 @@ import { settingsAtom } from "@/utils/atoms/settings"; import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl"; import { storage } from "@/utils/mmkv"; +// Conditionally import TrackPlayer only on non-TV platforms +// This prevents the native module from being loaded on TV where it doesn't exist +const TrackPlayer = Platform.isTV + ? null + : require("react-native-track-player").default; + +const TrackPlayerModule = Platform.isTV + ? null + : require("react-native-track-player"); + +// Extract types and enums from the module (only available on non-TV) +const Capability = TrackPlayerModule?.Capability; +const TPRepeatMode = TrackPlayerModule?.RepeatMode; +type Track = NonNullable["Track"]; +type Progress = NonNullable["Progress"]; + // Storage keys const STORAGE_KEYS = { QUEUE: "music_player_queue", @@ -116,6 +127,28 @@ interface MusicPlayerContextType extends MusicPlayerState { triggerLookahead: () => void; } +const defaultState: MusicPlayerState = { + currentTrack: null, + queue: [], + originalQueue: [], + queueIndex: 0, + isPlaying: false, + isLoading: false, + loadingTrackId: null, + progress: 0, + duration: 0, + streamUrl: null, + playSessionId: null, + repeatMode: "off", + shuffleEnabled: false, + mediaSource: null, + isTranscoding: false, + trackMediaInfoMap: {}, +}; + +// No-op function for TV stub +const noop = () => {}; + const MusicPlayerContext = createContext( undefined, ); @@ -132,6 +165,48 @@ interface MusicPlayerProviderProps { children: ReactNode; } +// Stub provider for tvOS - music playback is not supported +const TVMusicPlayerProvider: React.FC = ({ + children, +}) => { + const value: MusicPlayerContextType = { + ...defaultState, + playTrack: noop, + playQueue: noop, + playAlbum: noop, + playPlaylist: noop, + pause: noop, + resume: noop, + togglePlayPause: noop, + next: noop, + previous: noop, + seek: noop, + stop: noop, + addToQueue: noop, + playNext: noop, + removeFromQueue: noop, + moveInQueue: noop, + reorderQueue: noop, + clearQueue: noop, + jumpToIndex: noop, + setRepeatMode: noop, + toggleShuffle: noop, + setProgress: noop, + setDuration: noop, + setIsPlaying: noop, + reportProgress: noop, + onTrackEnd: noop, + syncFromTrackPlayer: noop, + triggerLookahead: noop, + }; + + return ( + + {children} + + ); +}; + // Persistence helpers const saveQueueToStorage = (queue: BaseItemDto[], queueIndex: number) => { try { @@ -272,7 +347,8 @@ const itemToTrack = ( }; }; -export const MusicPlayerProvider: React.FC = ({ +// Full implementation for non-TV platforms +const MobileMusicPlayerProvider: React.FC = ({ children, }) => { const api = useAtomValue(apiAtom); @@ -306,6 +382,8 @@ export const MusicPlayerProvider: React.FC = ({ // Setup TrackPlayer and AudioStorage useEffect(() => { + if (!TrackPlayer) return; + const setupPlayer = async () => { if (playerSetupRef.current) return; @@ -354,19 +432,21 @@ export const MusicPlayerProvider: React.FC = ({ // Sync repeat mode to TrackPlayer useEffect(() => { + if (!TrackPlayer) return; + const syncRepeatMode = async () => { if (!playerSetupRef.current) return; - let tpRepeatMode: TPRepeatMode; + let tpRepeatMode: typeof TPRepeatMode; switch (state.repeatMode) { case "one": - tpRepeatMode = TPRepeatMode.Track; + tpRepeatMode = TPRepeatMode?.Track; break; case "all": - tpRepeatMode = TPRepeatMode.Queue; + tpRepeatMode = TPRepeatMode?.Queue; break; default: - tpRepeatMode = TPRepeatMode.Off; + tpRepeatMode = TPRepeatMode?.Off; } await TrackPlayer.setRepeatMode(tpRepeatMode); }; @@ -553,14 +633,13 @@ export const MusicPlayerProvider: React.FC = ({ // Load remaining tracks in the background without blocking playback const loadRemainingTracksInBackground = useCallback( async (queue: BaseItemDto[], startIndex: number, preferLocal: boolean) => { - if (!api || !user?.Id) return; + if (!api || !user?.Id || !TrackPlayer) return; const mediaInfoMap: Record = {}; const failedItemIds: string[] = []; // Track items that failed to prepare // Process tracks BEFORE the start index (insert at position 0, pushing current track forward) const beforeTracks: Track[] = []; - const beforeSuccessIds: string[] = []; // Track successful IDs to maintain order for (let i = 0; i < startIndex; i++) { const item = queue[i]; if (!item.Id) continue; @@ -568,7 +647,6 @@ export const MusicPlayerProvider: React.FC = ({ const prepared = await prepareTrack(item, preferLocal); if (prepared) { beforeTracks.push(prepared.track); - beforeSuccessIds.push(item.Id); if (prepared.mediaInfo) { mediaInfoMap[item.Id] = prepared.mediaInfo; } @@ -641,7 +719,7 @@ export const MusicPlayerProvider: React.FC = ({ const loadAndPlayQueue = useCallback( async (queue: BaseItemDto[], startIndex: number) => { - if (!api || !user?.Id || queue.length === 0) return; + if (!api || !user?.Id || queue.length === 0 || !TrackPlayer) return; const preferLocal = settings?.preferLocalAudio ?? true; @@ -856,11 +934,13 @@ export const MusicPlayerProvider: React.FC = ({ ); const pause = useCallback(async () => { + if (!TrackPlayer) return; await TrackPlayer.pause(); setState((prev) => ({ ...prev, isPlaying: false })); }, []); const resume = useCallback(async () => { + if (!TrackPlayer) return; if (!state.streamUrl && state.currentTrack && api && user?.Id) { // Need to load the track first (e.g., after app restart) const result = await getAudioStreamUrl( @@ -905,6 +985,7 @@ export const MusicPlayerProvider: React.FC = ({ }, [state.isPlaying, pause, resume]); const next = useCallback(async () => { + if (!TrackPlayer) return; const currentIndex = await TrackPlayer.getActiveTrackIndex(); const queueLength = (await TrackPlayer.getQueue()).length; @@ -964,6 +1045,7 @@ export const MusicPlayerProvider: React.FC = ({ ]); const previous = useCallback(async () => { + if (!TrackPlayer) return; const position = await TrackPlayer.getProgress().then( (p: Progress) => p.position, ); @@ -1033,11 +1115,13 @@ export const MusicPlayerProvider: React.FC = ({ ]); const seek = useCallback(async (position: number) => { + if (!TrackPlayer) return; await TrackPlayer.seekTo(position); setState((prev) => ({ ...prev, progress: position })); }, []); const stop = useCallback(async () => { + if (!TrackPlayer) return; if (state.currentTrack && state.playSessionId) { reportPlaybackStopped( state.currentTrack, @@ -1087,7 +1171,7 @@ export const MusicPlayerProvider: React.FC = ({ // Queue management const addToQueue = useCallback( async (tracks: BaseItemDto | BaseItemDto[]) => { - if (!api || !user?.Id) return; + if (!api || !user?.Id || !TrackPlayer) return; const tracksArray = Array.isArray(tracks) ? tracks : [tracks]; const preferLocal = settings?.preferLocalAudio ?? true; @@ -1120,7 +1204,7 @@ export const MusicPlayerProvider: React.FC = ({ const playNext = useCallback( async (tracks: BaseItemDto | BaseItemDto[]) => { - if (!api || !user?.Id) return; + if (!api || !user?.Id || !TrackPlayer) return; const tracksArray = Array.isArray(tracks) ? tracks : [tracks]; const currentIndex = await TrackPlayer.getActiveTrackIndex(); @@ -1168,6 +1252,7 @@ export const MusicPlayerProvider: React.FC = ({ ); const removeFromQueue = useCallback(async (index: number) => { + if (!TrackPlayer) return; const queueLength = (await TrackPlayer.getQueue()).length; const currentIndex = await TrackPlayer.getActiveTrackIndex(); @@ -1201,6 +1286,7 @@ export const MusicPlayerProvider: React.FC = ({ const moveInQueue = useCallback( async (fromIndex: number, toIndex: number) => { + if (!TrackPlayer) return; const queue = await TrackPlayer.getQueue(); if ( fromIndex < 0 || @@ -1241,6 +1327,7 @@ export const MusicPlayerProvider: React.FC = ({ // Reorder queue with a new array (used by drag-to-reorder UI) const reorderQueue = useCallback( async (newQueue: BaseItemDto[]) => { + if (!TrackPlayer) return; // Find where the current track ended up in the new order const currentTrackId = state.currentTrack?.Id; const newIndex = currentTrackId @@ -1253,7 +1340,7 @@ export const MusicPlayerProvider: React.FC = ({ // Create a map of trackId -> current TrackPlayer index const currentPositions = new Map(); - tpQueue.forEach((track, idx) => { + tpQueue.forEach((track: Track, idx: number) => { currentPositions.set(track.id, idx); }); @@ -1296,6 +1383,7 @@ export const MusicPlayerProvider: React.FC = ({ ); const clearQueue = useCallback(async () => { + if (!TrackPlayer) return; const currentIndex = await TrackPlayer.getActiveTrackIndex(); const queue = await TrackPlayer.getQueue(); @@ -1325,6 +1413,7 @@ export const MusicPlayerProvider: React.FC = ({ const jumpToIndex = useCallback( async (index: number) => { + if (!TrackPlayer) return; if ( index < 0 || index >= state.queue.length || @@ -1460,6 +1549,7 @@ export const MusicPlayerProvider: React.FC = ({ // Sync state from TrackPlayer (called when active track changes) // Uses ID-based lookup instead of index to handle queue mismatches const syncFromTrackPlayer = useCallback(async () => { + if (!TrackPlayer) return; const activeTrack = await TrackPlayer.getActiveTrack(); if (!activeTrack?.id) return; @@ -1476,6 +1566,7 @@ export const MusicPlayerProvider: React.FC = ({ // Called by playback engine when track ends const onTrackEnd = useCallback(() => { + if (!TrackPlayer) return; if (state.repeatMode === "one") { TrackPlayer.seekTo(0); TrackPlayer.play(); @@ -1485,6 +1576,7 @@ export const MusicPlayerProvider: React.FC = ({ // Look-ahead cache: pre-cache upcoming N tracks (excludes current track to avoid bandwidth competition) const triggerLookahead = useCallback(async () => { + if (!TrackPlayer) return; // Check if caching is enabled in settings if (settings?.audioLookaheadEnabled === false) return; if (!api || !user?.Id) return; @@ -1598,3 +1690,7 @@ export const MusicPlayerProvider: React.FC = ({ ); }; + +// Export the appropriate provider based on platform +export const MusicPlayerProvider: React.FC = + Platform.isTV ? TVMusicPlayerProvider : MobileMusicPlayerProvider; diff --git a/providers/NetworkStatusProvider.tsx b/providers/NetworkStatusProvider.tsx index 25b4fd62e..b71aaeabe 100644 --- a/providers/NetworkStatusProvider.tsx +++ b/providers/NetworkStatusProvider.tsx @@ -35,8 +35,8 @@ async function checkApiReachable(basePath?: string): Promise { } export function NetworkStatusProvider({ children }: { children: ReactNode }) { - const [isConnected, setIsConnected] = useState(false); - const [serverConnected, setServerConnected] = useState(null); + const [isConnected, setIsConnected] = useState(true); + const [serverConnected, setServerConnected] = useState(true); const [loading, setLoading] = useState(false); const [api] = useAtom(apiAtom); const queryClient = useQueryClient(); diff --git a/providers/PlaySettingsProvider.tsx b/providers/PlaySettingsProvider.tsx index e3718a337..fe1d39f3b 100644 --- a/providers/PlaySettingsProvider.tsx +++ b/providers/PlaySettingsProvider.tsx @@ -9,7 +9,7 @@ import { Platform } from "react-native"; import type { Bitrate } from "@/components/BitrateSelector"; import { settingsAtom } from "@/utils/atoms/settings"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; -import { generateDeviceProfile } from "@/utils/profiles/native"; +import { generateDeviceProfile } from "../utils/profiles/native"; import { apiAtom, userAtom } from "./JellyfinProvider"; export type PlaybackType = { diff --git a/providers/ServerUrlProvider.tsx b/providers/ServerUrlProvider.tsx index 17ce17734..f73eb907f 100644 --- a/providers/ServerUrlProvider.tsx +++ b/providers/ServerUrlProvider.tsx @@ -34,13 +34,6 @@ export function ServerUrlProvider({ children }: Props): React.ReactElement { const { switchServerUrl } = useJellyfin(); const { ssid, permissionStatus } = useWifiSSID(); - console.log( - "[ServerUrlProvider] ssid:", - ssid, - "permissionStatus:", - permissionStatus, - ); - const [isUsingLocalUrl, setIsUsingLocalUrl] = useState(false); const [effectiveServerUrl, setEffectiveServerUrl] = useState( null, @@ -76,13 +69,6 @@ export function ServerUrlProvider({ children }: Props): React.ReactElement { const targetUrl = shouldUseLocal ? config!.localUrl : remoteUrl; - console.log("[ServerUrlProvider] evaluateAndSwitchUrl:", { - ssid, - shouldUseLocal, - targetUrl, - config, - }); - switchServerUrl(targetUrl); setIsUsingLocalUrl(shouldUseLocal); setEffectiveServerUrl(targetUrl); @@ -90,7 +76,6 @@ export function ServerUrlProvider({ children }: Props): React.ReactElement { // Manual refresh function for when config changes const refreshUrlState = useCallback(() => { - console.log("[ServerUrlProvider] refreshUrlState called"); evaluateAndSwitchUrl(); }, [evaluateAndSwitchUrl]); diff --git a/providers/WebSocketProvider.tsx b/providers/WebSocketProvider.tsx index 78d3c3c83..ed9db7549 100644 --- a/providers/WebSocketProvider.tsx +++ b/providers/WebSocketProvider.tsx @@ -12,9 +12,22 @@ import { } from "react"; import { AppState, type AppStateStatus } from "react-native"; import useRouter from "@/hooks/useAppRouter"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider"; import { useNetworkStatus } from "@/providers/NetworkStatusProvider"; +// Query keys that depend on the set of library items and should be refreshed +// when the server reports that the library changed (items added/removed/updated). +const LIBRARY_CHANGE_QUERY_KEYS = [ + ["home"], + ["library-items"], + ["nextUp-all"], + ["nextUp"], + ["resumeItems"], + ["seasons"], + ["episodes"], +] as const; + interface WebSocketMessage { MessageType: string; Data: any; @@ -42,10 +55,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { const [isConnected, setIsConnected] = useState(false); const [lastMessage, setLastMessage] = useState(null); const router = useRouter(); + const queryClient = useNetworkAwareQueryClient(); const deviceId = useMemo(() => { return getOrSetDeviceId(); }, []); const reconnectAttemptsRef = useRef(0); + const libraryChangeDebounceRef = useRef | null>( + null, + ); const connectWebSocket = useCallback(() => { if (!deviceId || !api?.accessToken || !isNetworkConnected) { @@ -66,7 +83,6 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { const reconnectDelay = 10000; newWebSocket.onopen = () => { - console.log("WebSocket connection opened"); setIsConnected(true); reconnectAttemptsRef.current = 0; keepAliveInterval = setInterval(() => { @@ -112,18 +128,57 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { }; }, [api, deviceId, isNetworkConnected]); + const handleLibraryChanged = useCallback( + (data: any) => { + // Jellyfin sends LibraryChanged when a scan adds/updates/removes items. + // Only refresh when something actually changed in the item set. + const hasChanges = + (data?.ItemsAdded?.length ?? 0) > 0 || + (data?.ItemsRemoved?.length ?? 0) > 0 || + (data?.ItemsUpdated?.length ?? 0) > 0 || + (data?.FoldersAddedTo?.length ?? 0) > 0 || + (data?.FoldersRemovedFrom?.length ?? 0) > 0; + + if (!hasChanges) { + return; + } + + // A single scan can emit several LibraryChanged messages in quick + // succession, so debounce the invalidation to refetch only once. + if (libraryChangeDebounceRef.current) { + clearTimeout(libraryChangeDebounceRef.current); + } + libraryChangeDebounceRef.current = setTimeout(() => { + for (const queryKey of LIBRARY_CHANGE_QUERY_KEYS) { + queryClient.invalidateQueries({ queryKey: [...queryKey] }); + } + }, 1000); + }, + [queryClient], + ); + useEffect(() => { if (!lastMessage) { return; } if (lastMessage.MessageType === "Play") { handlePlayCommand(lastMessage.Data); + } else if (lastMessage.MessageType === "LibraryChanged") { + handleLibraryChanged(lastMessage.Data); } - }, [lastMessage, router]); + }, [lastMessage, router, handleLibraryChanged]); + + useEffect(() => { + return () => { + if (libraryChangeDebounceRef.current) { + clearTimeout(libraryChangeDebounceRef.current); + } + }; + }, []); const handlePlayCommand = useCallback( (data: any) => { - if (!data || !data.ItemIds || !data.ItemIds.length) { + if (!data?.ItemIds?.length) { return; } @@ -151,7 +206,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { }, [connectWebSocket]); useEffect(() => { - if (!deviceId || !api || !api?.accessToken || !isNetworkConnected) { + if (!deviceId || !api?.accessToken || !isNetworkConnected) { return; } diff --git a/react-native.config.js b/react-native.config.js index 6e8801ee0..3e85d555b 100644 --- a/react-native.config.js +++ b/react-native.config.js @@ -37,6 +37,11 @@ const dependencies = { ), "react-native-ios-utilities": disableForTV("react-native-ios-utilities"), "react-native-pager-view": disableForTV("react-native-pager-view"), + "react-native-track-player": disableForTV("react-native-track-player"), + "expo-location": disableForTV("expo-location"), + "react-native-glass-effect-view": disableForTV( + "react-native-glass-effect-view", + ), }; // Filter out undefined values diff --git a/scripts/ios/build-ios.ts b/scripts/ios/build-ios.ts index 1d7bf77c7..33110507b 100644 --- a/scripts/ios/build-ios.ts +++ b/scripts/ios/build-ios.ts @@ -525,10 +525,23 @@ function displayBuildError( console.error(line); } console.error("--- End Build Errors ---\n"); - } else if (stdout.trim()) { + } + + // Linker failures ("Undefined symbols for architecture …", the SwiftUICore + // autolink rejection, "ld: …") don't carry an "error:" token, so the pattern + // filter above drops the symbol name and "referenced from" context that + // actually pinpoints the culprit. Surface that block explicitly. + const stdoutLines = stdout.split("\n"); + const undefIdx = stdoutLines.findIndex((line: string) => + line.includes("Undefined symbols"), + ); + if (undefIdx >= 0) { + console.error("\n--- Linker error detail ---"); + console.error(stdoutLines.slice(undefIdx, undefIdx + 40).join("\n")); + console.error("--- End linker error detail ---\n"); + } else if (errorLines.length === 0 && stdout.trim()) { // No specific error patterns found, show last N lines of stdout - const lines = stdout.split("\n"); - const lastLines = lines.slice(-ERROR_OUTPUT_TAIL_LINES).join("\n"); + const lastLines = stdoutLines.slice(-ERROR_OUTPUT_TAIL_LINES).join("\n"); console.error( `\n--- Last ${ERROR_OUTPUT_TAIL_LINES} lines of build output ---`, ); diff --git a/targets/StreamyfinTopShelf/Info.plist b/targets/StreamyfinTopShelf/Info.plist new file mode 100644 index 000000000..184b8d3d4 --- /dev/null +++ b/targets/StreamyfinTopShelf/Info.plist @@ -0,0 +1,40 @@ + + + + + StreamyfinAppGroupIdentifier + $(APP_GROUP_IDENTIFIER) + StreamyfinKeychainAccessGroupIdentifier + $(KEYCHAIN_ACCESS_GROUP_IDENTIFIER) + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + CFBundleDisplayName + Streamyfin Top Shelf + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.tv-top-shelf + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).TopShelfProvider + + + diff --git a/targets/StreamyfinTopShelf/StreamyfinTopShelf.entitlements b/targets/StreamyfinTopShelf/StreamyfinTopShelf.entitlements new file mode 100644 index 000000000..d29ed85fd --- /dev/null +++ b/targets/StreamyfinTopShelf/StreamyfinTopShelf.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + $(APP_GROUP_IDENTIFIER) + + keychain-access-groups + + $(KEYCHAIN_ACCESS_GROUP_IDENTIFIER) + + + diff --git a/targets/StreamyfinTopShelf/TopShelfProvider.swift b/targets/StreamyfinTopShelf/TopShelfProvider.swift new file mode 100644 index 000000000..ee73685d8 --- /dev/null +++ b/targets/StreamyfinTopShelf/TopShelfProvider.swift @@ -0,0 +1,125 @@ +import Foundation +import Security +import TVServices + +private let appGroupInfoPlistKey = "StreamyfinAppGroupIdentifier" +private let keychainAccessGroupInfoPlistKey = "StreamyfinKeychainAccessGroupIdentifier" +private let cacheKey = "TopShelfCache" +private let apiKeyService = "StreamyfinTopShelf" +private let apiKeyAccount = "JellyfinApiKey" + +private struct TopShelfCachePayload: Decodable { + let sections: [TopShelfCacheSection] +} + +private struct TopShelfCacheSection: Decodable { + let title: String + let items: [TopShelfCacheItem] +} + +private struct TopShelfCacheItem: Decodable { + let id: String + let title: String + let imageUrl: String? + let route: String + let playRoute: String? +} + +final class TopShelfProvider: TVTopShelfContentProvider { + override func loadTopShelfContent( + completionHandler: @escaping (TVTopShelfContent?) -> Void + ) { + guard + let appGroupIdentifier = Bundle.main.object( + forInfoDictionaryKey: appGroupInfoPlistKey + ) as? String, + let defaults = UserDefaults(suiteName: appGroupIdentifier), + let json = defaults.string(forKey: cacheKey), + let data = json.data(using: .utf8), + let payload = try? JSONDecoder().decode(TopShelfCachePayload.self, from: data) + else { + completionHandler(nil) + return + } + + let apiKey = readAPIKey() + let sections = payload.sections.compactMap { section -> TVTopShelfItemCollection? in + let items = section.items.compactMap { makeTopShelfItem($0, apiKey: apiKey) } + guard !items.isEmpty else { return nil } + + let collection = TVTopShelfItemCollection(items: items) + collection.title = section.title + return collection + } + + completionHandler(sections.isEmpty ? nil : TVTopShelfSectionedContent(sections: sections)) + } + + private func makeTopShelfItem( + _ cacheItem: TopShelfCacheItem, + apiKey: String? + ) -> TVTopShelfSectionedItem? { + guard let route = URL(string: cacheItem.route) else { + return nil + } + + let item = TVTopShelfSectionedItem(identifier: cacheItem.id) + item.title = cacheItem.title + item.imageShape = .poster + item.displayAction = TVTopShelfAction(url: route) + + if let playRoute = cacheItem.playRoute, let playURL = URL(string: playRoute) { + item.playAction = TVTopShelfAction(url: playURL) + } + + if let imageUrl = cacheItem.imageUrl, + let url = imageURL(from: imageUrl, apiKey: apiKey) { + item.setImageURL(url, for: .screenScale1x) + item.setImageURL(url, for: .screenScale2x) + } + + return item + } + + private func imageURL(from imageUrl: String, apiKey: String?) -> URL? { + guard var components = URLComponents(string: imageUrl) else { + return nil + } + + if let apiKey, !apiKey.isEmpty { + var queryItems = components.queryItems ?? [] + queryItems.removeAll { $0.name == "api_key" } + queryItems.append(URLQueryItem(name: "api_key", value: apiKey)) + components.queryItems = queryItems + } + + return components.url + } + + private func readAPIKey() -> String? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: apiKeyService, + kSecAttrAccount as String: apiKeyAccount, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + if let keychainAccessGroupIdentifier = Bundle.main.object( + forInfoDictionaryKey: keychainAccessGroupInfoPlistKey + ) as? String { + query[kSecAttrAccessGroup as String] = keychainAccessGroupIdentifier + } + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard + status == errSecSuccess, + let data = item as? Data + else { + return nil + } + + return String(data: data, encoding: .utf8) + } +} diff --git a/translations/de.json b/translations/de.json index bfb614b78..3d3001dcd 100644 --- a/translations/de.json +++ b/translations/de.json @@ -7,7 +7,7 @@ "username_placeholder": "Benutzername", "password_placeholder": "Passwort", "login_button": "Anmelden", - "quick_connect": "Schnellverbindung", + "quick_connect": "Quick Connect", "enter_code_to_login": "Gib den Code {{code}} ein, um dich anzumelden", "failed_to_initiate_quick_connect": "Fehler beim Initiieren der Schnellverbindung", "got_it": "Verstanden", @@ -30,48 +30,48 @@ "connect_button": "Verbinden", "previous_servers": "Vorherige Server", "clear_button": "Löschen", - "swipe_to_remove": "Swipe to remove", + "swipe_to_remove": "Wischen, um zu entfernen", "search_for_local_servers": "Nach lokalen Servern suchen", "searching": "Suche...", "servers": "Server", - "saved": "Saved", - "session_expired": "Session Expired", - "please_login_again": "Your saved session has expired. Please log in again.", - "remove_saved_login": "Remove Saved Login", - "remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.", - "accounts_count": "{{count}} accounts", - "select_account": "Select Account", - "add_account": "Add Account", - "remove_account_description": "This will remove the saved credentials for {{username}}." + "saved": "Gespeichert", + "session_expired": "Sitzung abgelaufen", + "please_login_again": "Ihre Sitzung ist abgelaufen. Bitte erneut anmelden.", + "remove_saved_login": "Gespeicherte Zugangsdaten entfernen", + "remove_saved_login_description": "Hiermit werden ihre gespeicherten Zugangsdaten für diesen Server entfernt. Sie müssen sich dann erneut anmelden.", + "accounts_count": "{{count}} Konten", + "select_account": "Konto auswählen", + "add_account": "Konto hinzufügen", + "remove_account_description": "Hiermit werden die gespeicherten Zugangsdaten für {{username}} entfernt." }, "save_account": { - "title": "Save Account", - "save_for_later": "Save this account", - "security_option": "Security Option", - "no_protection": "No protection", - "no_protection_desc": "Quick login without authentication", - "pin_code": "PIN code", - "pin_code_desc": "4-digit PIN required when switching", - "password": "Re-enter password", - "password_desc": "Password required when switching", - "save_button": "Save", - "cancel_button": "Cancel" + "title": "Konto speichern", + "save_for_later": "Dieses Konto speichern", + "security_option": "Sicherheitseinstellung", + "no_protection": "Keine", + "no_protection_desc": "Schnellanmeldung ohne Authentifizierung", + "pin_code": "PIN", + "pin_code_desc": "4-stellige PIN bei Konto-Wechsel erforderlich", + "password": "Passwort wiederholen", + "password_desc": "Passwort bei Konto-Wechsel erforderlich", + "save_button": "Speichern", + "cancel_button": "Abbrechen" }, "pin": { - "enter_pin": "Enter PIN", - "enter_pin_for": "Enter PIN for {{username}}", - "enter_4_digits": "Enter 4 digits", - "invalid_pin": "Invalid PIN", - "setup_pin": "Set Up PIN", - "confirm_pin": "Confirm PIN", - "pins_dont_match": "PINs don't match", - "forgot_pin": "Forgot PIN?", - "forgot_pin_desc": "Your saved credentials will be removed" + "enter_pin": "PIN eingeben", + "enter_pin_for": "PIN für {{username}} eingeben", + "enter_4_digits": "4 Ziffern eingeben", + "invalid_pin": "Ungültige PIN", + "setup_pin": "PIN festlegen", + "confirm_pin": "PIN bestätigen", + "pins_dont_match": "PIN stimmt nicht überein", + "forgot_pin": "PIN vergessen?", + "forgot_pin_desc": "Ihre gespeicherten Zugangsdaten werden entfernt" }, "password": { - "enter_password": "Enter Password", - "enter_password_for": "Enter password for {{username}}", - "invalid_password": "Invalid password" + "enter_password": "Passwort eingeben", + "enter_password_for": "Passwort für {{username}} eingeben", + "invalid_password": "Ungültiges Passwort" }, "home": { "checking_server_connection": "Überprüfe Serververbindung...", @@ -87,7 +87,7 @@ "error_message": "Etwas ist schiefgelaufen.\nBitte melde dich ab und wieder an.", "continue_watching": "Weiterschauen", "next_up": "Als nächstes", - "continue_and_next_up": "Continue & Next Up", + "continue_and_next_up": "\"Weiterschauen\" und \"Als Nächstes\"", "recently_added_in": "Kürzlich hinzugefügt in {{libraryName}}", "suggested_movies": "Empfohlene Filme", "suggested_episodes": "Empfohlene Episoden", @@ -120,36 +120,36 @@ }, "appearance": { "title": "Aussehen", - "merge_next_up_continue_watching": "Merge Continue Watching & Next Up", - "hide_remote_session_button": "Hide Remote Session Button" + "merge_next_up_continue_watching": "\"Weiterschauen\" und \"Als Nächstes\" kombinieren", + "hide_remote_session_button": "Button für Remote-Sitzung ausblenden" }, "network": { - "title": "Network", - "local_network": "Local Network", - "auto_switch_enabled": "Auto-switch when at home", - "auto_switch_description": "Automatically switch to local URL when connected to home WiFi", - "local_url": "Local URL", - "local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)", + "title": "Netzwerk", + "local_network": "Lokales Netzwerk", + "auto_switch_enabled": "Zuhause automatisch wechseln", + "auto_switch_description": "Im WLAN Zuhause automatisch zu lokaler URL wechseln", + "local_url": "Lokale URL", + "local_url_hint": "Lokale Server-URL eingeben (zB. http://192.168.1.100:8096)", "local_url_placeholder": "http://192.168.1.100:8096", - "home_wifi_networks": "Home WiFi Networks", - "add_current_network": "Add \"{{ssid}}\"", - "not_connected_to_wifi": "Not connected to WiFi", - "no_networks_configured": "No networks configured", - "add_network_hint": "Add your home WiFi network to enable auto-switching", - "current_wifi": "Current WiFi", - "using_url": "Using", - "local": "Local URL", + "home_wifi_networks": "Private WLAN-Netze", + "add_current_network": "{{ssid}} hinzufügen", + "not_connected_to_wifi": "Nicht mit WLAN verbunden", + "no_networks_configured": "Keine Netzwerke konfiguriert", + "add_network_hint": "Füge dein privates WLAN-Netz hinzu um automatischen Wechsel zu aktivieren", + "current_wifi": "Aktuelles WLAN-Netz", + "using_url": "Verwendet", + "local": "Lokale URL", "remote": "Remote URL", - "not_connected": "Not connected", - "current_server": "Current Server", + "not_connected": "Nicht verbunden", + "current_server": "Aktueller Server", "remote_url": "Remote URL", - "active_url": "Active URL", - "not_configured": "Not configured", - "network_added": "Network added", - "network_already_added": "Network already added", - "no_wifi_connected": "Not connected to WiFi", - "permission_denied": "Location permission denied", - "permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings." + "active_url": "Aktive URL", + "not_configured": "Nicht konfiguriert", + "network_added": "Netzwerk hinzugefügt", + "network_already_added": "Netzwerk bereits hinzugefügt", + "no_wifi_connected": "Nicht mit WLAN verbunden", + "permission_denied": "Standortberechtigung nicht verfügbar", + "permission_denied_explanation": "Standortberechtigung ist nötig um WLAN-Netze für den automatischen Wechsel zu erkennen. Bitte in den Einstellungen aktivieren." }, "user_info": { "user_info_title": "Benutzerinformationen", @@ -159,82 +159,82 @@ "app_version": "App-Version" }, "quick_connect": { - "quick_connect_title": "Schnellverbindung", - "authorize_button": "Schnellverbindung autorisieren", - "enter_the_quick_connect_code": "Gib den Schnellverbindungscode ein...", - "success": "Erfolg", - "quick_connect_autorized": "Schnellverbindung autorisiert", + "quick_connect_title": "Quick Connect", + "authorize_button": "Quick Connect autorisieren", + "enter_the_quick_connect_code": "Quick Connect-Code eingeben...", + "success": "Erfolgreich verbunden", + "quick_connect_autorized": "Quick Connect autorisiert", "error": "Fehler", "invalid_code": "Ungültiger Code", "authorize": "Autorisieren" }, "media_controls": { "media_controls_title": "Mediensteuerung", - "forward_skip_length": "Vorspulzeit", - "rewind_length": "Rückspulzeit", + "forward_skip_length": "Vorspullänge", + "rewind_length": "Rückspullänge", "seconds_unit": "s" }, "gesture_controls": { "gesture_controls_title": "Gestensteuerung", - "horizontal_swipe_skip": "Horizontales Wischen zum Überspringen", - "horizontal_swipe_skip_description": "Wische links/rechts, wenn Steuerelemente ausgeblendet werden um zu überspringen", - "left_side_brightness": "Helligkeitskontrolle der linken Seite", - "left_side_brightness_description": "Wischen Sie auf der linken Seite nach oben/runter, um die Helligkeit anzupassen", - "right_side_volume": "Lautstärkeregelung der rechten Seite", - "right_side_volume_description": "Auf der rechten Seite nach oben/unten wischen, um Lautstärke anzupassen", - "hide_volume_slider": "Hide Volume Slider", - "hide_volume_slider_description": "Hide the volume slider in the video player", - "hide_brightness_slider": "Hide Brightness Slider", - "hide_brightness_slider_description": "Hide the brightness slider in the video player" + "horizontal_swipe_skip": "Horizontal Wischen zum Überspringen", + "horizontal_swipe_skip_description": "Wische links/rechts, wenn Steuerelemente ausgeblendet sind um zu überspringen", + "left_side_brightness": "Helligkeitsregler Links", + "left_side_brightness_description": "Links nach oben/unten wischen um Helligkeit anzupassen", + "right_side_volume": "Lautstärkeregler Rechts", + "right_side_volume_description": "Rechts nach oben/unten wischen um Lautstärke anzupassen", + "hide_volume_slider": "Lautstärkeregler ausblenden", + "hide_volume_slider_description": "Lautstärkeregler im Videoplayer ausblenden", + "hide_brightness_slider": "Helligkeitsregler ausblenden", + "hide_brightness_slider_description": "Helligkeitsregler im Videoplayer ausblenden" }, "audio": { "audio_title": "Audio", - "set_audio_track": "Audiospur aus dem vorherigen Element festlegen", + "set_audio_track": "Audiospur aus dem vorherigen Element übernehmen", "audio_language": "Audio-Sprache", - "audio_hint": "Wähl die Standardsprache für Audio aus.", + "audio_hint": "Standardsprache für Audio auswählen.", "none": "Keine", "language": "Sprache", "transcode_mode": { - "title": "Audio Transcoding", - "description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled", + "title": "Audio-Transcoding", + "description": "Legt fest, wie Surround-Audio (7.1, TrueHD, DTS-HD) behandelt wird", "auto": "Auto", - "stereo": "Force Stereo", - "5_1": "Allow 5.1", + "stereo": "Stereo erzwingen", + "5_1": "5.1 erlauben", "passthrough": "Passthrough" } }, "subtitles": { "subtitle_title": "Untertitel", - "subtitle_hint": "Konfigurier die Untertitel-Präferenzen.", + "subtitle_hint": "Untertitel-Erscheinungsbild und Verhalten konfigurieren.", "subtitle_language": "Untertitel-Sprache", "subtitle_mode": "Untertitel-Modus", - "set_subtitle_track": "Untertitel-Spur aus dem vorherigen Element festlegen", + "set_subtitle_track": "Untertitel-Spur aus dem vorherigen Element übernehmen", "subtitle_size": "Untertitel-Größe", "none": "Keine", "language": "Sprache", "loading": "Lädt", "modes": { "Default": "Standard", - "Smart": "Intelligent", + "Smart": "Smart", "Always": "Immer", "None": "Keine", - "OnlyForced": "Nur erzwungen" + "OnlyForced": "Nur erzwungene" }, "text_color": "Textfarbe", "background_color": "Hintergrundfarbe", "outline_color": "Konturfarbe", - "outline_thickness": "Umriss Dicke", + "outline_thickness": "Konturdicke", "background_opacity": "Hintergrundtransparenz", - "outline_opacity": "Kontur-Deckkraft", - "bold_text": "Bold Text", + "outline_opacity": "Konturtransparenz", + "bold_text": "Fettgedruckter Text", "colors": { "Black": "Schwarz", "Gray": "Grau", "Silver": "Silber", "White": "Weiß", - "Maroon": "Marotte", + "Maroon": "Rotbraun", "Red": "Rot", - "Fuchsia": "Fuchsia", + "Fuchsia": "Magenta", "Yellow": "Gelb", "Olive": "Olivgrün", "Green": "Grün", @@ -251,29 +251,29 @@ "Normal": "Normal", "Thick": "Dick" }, - "subtitle_color": "Subtitle Color", - "subtitle_background_color": "Background Color", - "subtitle_font": "Subtitle Font", - "ksplayer_title": "KSPlayer Settings", + "subtitle_color": "Untertitelfarbe", + "subtitle_background_color": "Hintergrundfarbe", + "subtitle_font": "Untertitel-Schriftart", + "ksplayer_title": "KSPlayer Einstellungen", "hardware_decode": "Hardware Decoding", - "hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues." + "hardware_decode_description": "Hardwarebeschleunigung für Video Decoding verwenden. Deaktivieren wenn Wiedergabeprobleme auftreten." }, "vlc_subtitles": { - "title": "VLC Subtitle Settings", - "hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.", - "text_color": "Text Color", - "background_color": "Background Color", - "background_opacity": "Background Opacity", - "outline_color": "Outline Color", - "outline_opacity": "Outline Opacity", - "outline_thickness": "Outline Thickness", - "bold": "Bold Text", - "margin": "Bottom Margin" + "title": "VLC Untertitel-Einstellungen", + "hint": "Anpassen des Untertitel-Erscheinungsbildes für VLC. Änderungen werden bei der nächsten Wiedergabe übernommen.", + "text_color": "Schriftfarbe", + "background_color": "Hintergrundfarbe", + "background_opacity": "Hintergrundtransparenz", + "outline_color": "Konturfarbe", + "outline_opacity": "Konturtransparenz", + "outline_thickness": "Konturdicke", + "bold": "Fettgedruckter Text", + "margin": "Unterer Abstand" }, "video_player": { - "title": "Video Player", - "video_player": "Video Player", - "video_player_description": "Choose which video player to use on iOS.", + "title": "Videoplayer", + "video_player": "Videoplayer", + "video_player_description": "Videoplayer auf iOS auswählen.", "ksplayer": "KSPlayer", "vlc": "VLC" }, @@ -282,7 +282,7 @@ "video_orientation": "Videoausrichtung", "orientation": "Ausrichtung", "orientations": { - "DEFAULT": "Standard", + "DEFAULT": "Geräteausrichtung folgen", "ALL": "Alle", "PORTRAIT": "Hochformat", "PORTRAIT_UP": "Hochformat oben", @@ -294,54 +294,54 @@ "UNKNOWN": "Unbekannt" }, "safe_area_in_controls": "Sicherer Bereich in den Steuerungen", - "video_player": "Video player", + "video_player": "Videoplayer", "video_players": { "VLC_3": "VLC 3", "VLC_4": "VLC 4 (Experimentell + PiP)" }, "show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen", - "show_large_home_carousel": "Zeige Großes Heimkarussell (Beta)", + "show_large_home_carousel": "Zeige große Startseiten-Übersicht (Beta)", "hide_libraries": "Bibliotheken ausblenden", - "select_liraries_you_want_to_hide": "Wähl die Bibliotheken aus, die du im Bibliothekstab und auf der Startseite ausblenden möchtest.", + "select_liraries_you_want_to_hide": "Bibliotheken auswählen die aus dem Bibliothekstab und der Startseite ausgeblendet werden sollen.", "disable_haptic_feedback": "Haptisches Feedback deaktivieren", "default_quality": "Standardqualität", - "default_playback_speed": "Default Playback Speed", - "auto_play_next_episode": "Auto-play Next Episode", - "max_auto_play_episode_count": "Max. automatische Wiedergabe Episodenanzahl", + "default_playback_speed": "Standard-Wiedergabegeschwindigkeit", + "auto_play_next_episode": "Automatisch nächste Episode abspielen", + "max_auto_play_episode_count": "Maximale automatisch abzuspielende Episodenanzahl", "disabled": "Deaktiviert" }, "downloads": { "downloads_title": "Downloads" }, "music": { - "title": "Music", - "playback_title": "Playback", - "playback_description": "Configure how music is played.", - "prefer_downloaded": "Prefer Downloaded Songs", + "title": "Musik", + "playback_title": "Wiedergabe", + "playback_description": "Konfigurieren, wie Musik abgespielt wird.", + "prefer_downloaded": "Bevorzuge heruntergeladene Titel", "caching_title": "Caching", - "caching_description": "Automatically cache upcoming tracks for smoother playback.", - "lookahead_enabled": "Enable Look-Ahead Caching", - "lookahead_count": "Tracks to Pre-cache", - "max_cache_size": "Max Cache Size" + "caching_description": "Automatisches Caching anstehender Titel für bessere Wiedergabe.", + "lookahead_enabled": "Look-Ahead Caching aktivieren", + "lookahead_count": "Titel vorher in den Cache laden", + "max_cache_size": "Maximale Cache-Größe" }, "plugins": { - "plugins_title": "Erweiterungen", + "plugins_title": "Plugins", "jellyseerr": { - "jellyseerr_warning": "Diese integration ist in einer frühen Entwicklungsphase. Erwarte Veränderungen.", - "server_url": "Server Adresse", - "server_url_hint": "Beispiel: http(s)://your-host.url\n(Portnummer hinzufügen, falls erforderlich)", - "server_url_placeholder": "Jellyseerr URL...", + "jellyseerr_warning": "Diese Integration ist in einer frühen Entwicklungsphase und kann jederzeit geändert werden.", + "server_url": "Server URL", + "server_url_hint": "Beispiel: http(s)://your-host.url\n(Port hinzufügen, falls erforderlich)", + "server_url_placeholder": "Seerr URL", "password": "Passwort", "password_placeholder": "Passwort für Jellyfin Benutzer {{username}} eingeben", "login_button": "Anmelden", "total_media_requests": "Gesamtanfragen", "movie_quota_limit": "Film-Anfragelimit", - "movie_quota_days": "Film-Anfragetage", - "tv_quota_limit": "TV-Anfragelimit", - "tv_quota_days": "TV-Anfragetage", - "reset_jellyseerr_config_button": "Setze Jellyseerr-Konfiguration zurück", + "movie_quota_days": "Film-Anfragetagelimit", + "tv_quota_limit": "Serien-Anfragelimit", + "tv_quota_days": "Serien-Anfragetagelimit", + "reset_jellyseerr_config_button": "Seerr-Konfiguration zurücksetzen", "unlimited": "Unlimitiert", - "plus_n_more": "+{{n}} more", + "plus_n_more": "+{{n}} weitere", "order_by": { "DEFAULT": "Standard", "VOTE_COUNT_AND_AVERAGE": "Stimmenanzahl und Durchschnitt", @@ -352,71 +352,71 @@ "enable_marlin_search": "Aktiviere Marlin Search", "url": "URL", "server_url_placeholder": "http(s)://domain.org:port", - "marlin_search_hint": "Gib die URL für den Marlin Server ein. Die URL sollte http oder https enthalten und optional den Port.", + "marlin_search_hint": "URL für den Marlin Server eingeben. Die URL sollte http oder https enthalten und optional den Port.", "read_more_about_marlin": "Erfahre mehr über Marlin.", "save_button": "Speichern", "toasts": { "saved": "Gespeichert", - "refreshed": "Settings refreshed from server" + "refreshed": "Einstellungen vom Server aktualisiert" }, - "refresh_from_server": "Refresh Settings from Server" + "refresh_from_server": "Einstellungen vom Server aktualisieren" }, "streamystats": { - "enable_streamystats": "Enable Streamystats", - "disable_streamystats": "Disable Streamystats", - "enable_search": "Use for Search", + "enable_streamystats": "Streamystats aktivieren", + "disable_streamystats": "Streamystats deaktivieren", + "enable_search": "Zum Suchen verwenden", "url": "URL", "server_url_placeholder": "http(s)://streamystats.example.com", - "streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.", - "read_more_about_streamystats": "Read More About Streamystats.", - "save_button": "Save", - "save": "Save", + "streamystats_search_hint": "URL für den Streamystats-Server eingeben.", + "read_more_about_streamystats": "Mehr über Streamystats erfahren.", + "save_button": "Speichern", + "save": "Gespeichert", "features_title": "Features", - "home_sections_title": "Home Sections", - "enable_movie_recommendations": "Movie Recommendations", - "enable_series_recommendations": "Series Recommendations", - "enable_promoted_watchlists": "Promoted Watchlists", - "hide_watchlists_tab": "Hide Watchlists Tab", - "home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.", - "recommended_movies": "Recommended Movies", - "recommended_series": "Recommended Series", + "home_sections_title": "Startseitenbereiche", + "enable_movie_recommendations": "Filmempfehlungen", + "enable_series_recommendations": "Serienempfehlungen", + "enable_promoted_watchlists": "Empfohlene Merklisten", + "hide_watchlists_tab": "Merklisten-Tab ausblenden", + "home_sections_hint": "Zeige personalisierte Empfehlungen und empfohlene Merklisten von Streamystats auf der Startseite.", + "recommended_movies": "Empfohlene Filme", + "recommended_series": "Empfohlene Serien", "toasts": { - "saved": "Saved", - "refreshed": "Settings refreshed from server", - "disabled": "Streamystats disabled" + "saved": "Gespeichert", + "refreshed": "Einstellungen vom Server aktualisiert", + "disabled": "Streamystats deaktiviert" }, - "refresh_from_server": "Refresh Settings from Server" + "refresh_from_server": "Einstellungen vom Server aktualisieren" }, "kefinTweaks": { - "watchlist_enabler": "Enable our Watchlist integration", - "watchlist_button": "Toggle Watchlist integration" + "watchlist_enabler": "Merklisten-Integration aktivieren", + "watchlist_button": "Merklisten-Integration umschalten" } }, "storage": { "storage_title": "Speicher", "app_usage": "App {{usedSpace}}%", "device_usage": "Gerät {{availableSpace}}%", - "size_used": "{{used}} von {{total}} benutzt", - "delete_all_downloaded_files": "Alle Downloads löschen", - "music_cache_title": "Music Cache", - "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", - "enable_music_cache": "Enable Music Cache", - "clear_music_cache": "Clear Music Cache", - "music_cache_size": "{{size}} cached", - "music_cache_cleared": "Music cache cleared", - "delete_all_downloaded_songs": "Delete All Downloaded Songs", - "downloaded_songs_size": "{{size}} downloaded", - "downloaded_songs_deleted": "Downloaded songs deleted" + "size_used": "{{used}} von {{total}} genutzt", + "delete_all_downloaded_files": "Alle heruntergeladenen Dateien löschen", + "music_cache_title": "Musik-Cache", + "music_cache_description": "Beim Anhören Titel automatisch in den Cache laden um bessere Wiedergabe und Offline-Wiedergabe zu ermöglichen", + "enable_music_cache": "Musik-Cache aktivieren", + "clear_music_cache": "Musik-Cache leeren", + "music_cache_size": "{{size}} gechached", + "music_cache_cleared": "Musik-Cache geleert", + "delete_all_downloaded_songs": "Alle heruntergeladenen Titel löschen", + "downloaded_songs_size": "{{size}} heruntergeladen", + "downloaded_songs_deleted": "Heruntergeladene Titel gelöscht" }, "intro": { - "title": "Intro ", - "show_intro": "Show intro", - "reset_intro": "Reset intro" + "title": "Einführung", + "show_intro": "Einführung anzeigen", + "reset_intro": "Einführung zurücksetzen" }, "logs": { "logs_title": "Logs", - "export_logs": "Export logs", - "click_for_more_info": "Click for more info", + "export_logs": "Logs exportieren", + "click_for_more_info": "Für mehr Informationen klicken", "level": "Level", "no_logs_available": "Keine Logs verfügbar", "delete_all_logs": "Alle Logs löschen" @@ -438,21 +438,21 @@ }, "downloads": { "downloads_title": "Downloads", - "tvseries": "TV-Serien", + "tvseries": "Serien", "movies": "Filme", "queue": "Warteschlange", "other_media": "Andere Medien", - "queue_hint": "Warteschlange und aktive Downloads gehen verloren bei App-Neustart", + "queue_hint": "Warteschlange und aktive Downloads gehen verloren wenn die App neu gestartet wird", "no_items_in_queue": "Keine Elemente in der Warteschlange", "no_downloaded_items": "Keine heruntergeladenen Elemente", "delete_all_movies_button": "Alle Filme löschen", - "delete_all_tvseries_button": "Alle TV-Serien löschen", + "delete_all_tvseries_button": "Alle Serien löschen", "delete_all_button": "Alles löschen", - "delete_all_other_media_button": "Andere Medien löschen", + "delete_all_other_media_button": "Alle anderen Medien löschen", "active_download": "Aktiver Download", "no_active_downloads": "Keine aktiven Downloads", "active_downloads": "Aktive Downloads", - "new_app_version_requires_re_download": "Die neue App-Version erfordert das erneute Herunterladen.", + "new_app_version_requires_re_download": "Neue App-Version erfordert erneutes Herunterladen", "new_app_version_requires_re_download_description": "Die neue App-Version erfordert das erneute Herunterladen von Filmen und Serien. Bitte lösche alle heruntergeladenen Elemente und starte den Download erneut.", "back": "Zurück", "delete": "Löschen", @@ -463,8 +463,8 @@ "you_are_not_allowed_to_download_files": "Du hast keine Berechtigung, Dateien herunterzuladen", "deleted_all_movies_successfully": "Alle Filme erfolgreich gelöscht!", "failed_to_delete_all_movies": "Fehler beim Löschen aller Filme", - "deleted_all_tvseries_successfully": "Alle TV-Serien erfolgreich gelöscht!", - "failed_to_delete_all_tvseries": "Fehler beim Löschen aller TV-Serien", + "deleted_all_tvseries_successfully": "Alle Serien erfolgreich gelöscht!", + "failed_to_delete_all_tvseries": "Fehler beim Löschen aller Serien", "deleted_media_successfully": "Andere Medien erfolgreich gelöscht!", "failed_to_delete_media": "Fehler beim Löschen anderer Medien", "download_deleted": "Download gelöscht", @@ -486,7 +486,7 @@ "all_files_folders_and_jobs_deleted_successfully": "Alle Dateien, Ordner und Jobs erfolgreich gelöscht", "failed_to_clean_cache_directory": "Fehler beim Bereinigen des Cache-Verzeichnisses", "could_not_get_download_url_for_item": "Download-URL für {{itemName}} konnte nicht geladen werden", - "go_to_downloads": "Gehe zu den Downloads", + "go_to_downloads": "Zu Downloads gehen", "file_deleted": "{{item}} gelöscht" } } @@ -499,18 +499,18 @@ "subtitle": "Untertitel", "play": "Abspielen", "none": "Keine", - "track": "Track", - "cancel": "Cancel", - "delete": "Delete", + "track": "Spur", + "cancel": "Abbrechen", + "delete": "Löschen", "ok": "OK", - "remove": "Remove", - "next": "Next", - "back": "Back", - "continue": "Continue", - "verifying": "Verifying..." + "remove": "Entfernen", + "next": "Weiter", + "back": "Zurück", + "continue": "Fortsetzen", + "verifying": "Verifiziere..." }, "search": { - "search": "Suche...", + "search": "Suchen...", "x_items": "{{count}} Elemente", "library": "Bibliothek", "discover": "Entdecken", @@ -521,33 +521,33 @@ "episodes": "Episoden", "collections": "Sammlungen", "actors": "Schauspieler", - "artists": "Artists", - "albums": "Albums", - "songs": "Songs", + "artists": "Künstler", + "albums": "Alben", + "songs": "Titel", "playlists": "Playlists", "request_movies": "Film anfragen", "request_series": "Serie anfragen", "recently_added": "Kürzlich hinzugefügt", "recent_requests": "Kürzlich angefragt", - "plex_watchlist": "Plex Watchlist", - "trending": "In den Trends", + "plex_watchlist": "Plex Merkliste", + "trending": "Beliebt", "popular_movies": "Beliebte Filme", "movie_genres": "Film-Genres", "upcoming_movies": "Kommende Filme", "studios": "Studios", - "popular_tv": "Beliebte TV-Serien", - "tv_genres": "TV-Serien-Genres", - "upcoming_tv": "Kommende TV-Serien", - "networks": "Netzwerke", + "popular_tv": "Beliebte Serien", + "tv_genres": "Serien-Genres", + "upcoming_tv": "Kommende Serien", + "networks": "Sender", "tmdb_movie_keyword": "TMDB Film-Schlüsselwort", "tmdb_movie_genre": "TMDB Film-Genre", - "tmdb_tv_keyword": "TMDB TV-Serien-Schlüsselwort", - "tmdb_tv_genre": "TMDB TV-Serien-Genre", + "tmdb_tv_keyword": "TMDB Serien-Schlüsselwort", + "tmdb_tv_genre": "TMDB Serien-Genre", "tmdb_search": "TMDB Suche", "tmdb_studio": "TMDB Studio", "tmdb_network": "TMDB Netzwerk", "tmdb_movie_streaming_services": "TMDB Film-Streaming-Dienste", - "tmdb_tv_streaming_services": "TMDB TV-Serien-Streaming-Dienste" + "tmdb_tv_streaming_services": "TMDB Serien-Streaming-Dienste" }, "library": { "no_results": "Keine Ergebnisse", @@ -572,7 +572,7 @@ "genres": "Genres", "years": "Jahre", "sort_by": "Sortieren nach", - "filter_by": "Filter By", + "filter_by": "Filtern nach", "sort_order": "Sortierreihenfolge", "tags": "Tags" } @@ -585,7 +585,7 @@ "boxsets": "Boxsets", "playlists": "Wiedergabelisten", "noDataTitle": "Noch keine Favoriten", - "noData": "Markiere Elemente als Favoriten, damit sie hier für einen schnellen Zugriff angezeigt werden." + "noData": "Elemente als Favoriten markieren, um sie hier anzuzeigen." }, "custom_links": { "no_links": "Keine Links" @@ -593,7 +593,7 @@ "player": { "error": "Fehler", "failed_to_get_stream_url": "Fehler beim Abrufen der Stream-URL", - "an_error_occured_while_playing_the_video": "Ein Fehler ist beim Abspielen des Videos aufgetreten. Überprüf die Logs in den Einstellungen.", + "an_error_occured_while_playing_the_video": "Ein Fehler ist beim Abspielen des Videos aufgetreten. Logs in den Einstellungen überprüfen.", "client_error": "Client-Fehler", "could_not_create_stream_for_chromecast": "Konnte keinen Stream für Chromecast erstellen", "message_from_server": "Nachricht vom Server: {{message}}", @@ -602,17 +602,17 @@ "audio_tracks": "Audiospuren:", "playback_state": "Wiedergabestatus:", "index": "Index:", - "continue_watching": "Weiterschauen", + "continue_watching": "Fortsetzen", "go_back": "Zurück", - "downloaded_file_title": "Diese Datei wurde heruntergeladen", - "downloaded_file_message": "Möchten Sie die heruntergeladene Datei abspielen?", + "downloaded_file_title": "Diese Datei wurde bereits heruntergeladen", + "downloaded_file_message": "Heruntergeladene Datei abspielen?", "downloaded_file_yes": "Ja", "downloaded_file_no": "Nein", "downloaded_file_cancel": "Abbrechen" }, "item_card": { "next_up": "Als Nächstes", - "no_items_to_display": "Keine Elemente zum Anzeigen", + "no_items_to_display": "Keine Elemente", "cast_and_crew": "Besetzung und Crew", "series": "Serien", "seasons": "Staffeln", @@ -630,7 +630,7 @@ "subtitles": "Untertitel", "show_more": "Mehr anzeigen", "show_less": "Weniger anzeigen", - "appeared_in": "Erschienen in", + "appeared_in": "Erschien in", "could_not_load_item": "Konnte Element nicht laden", "none": "Keine", "download": { @@ -639,13 +639,13 @@ "download_episode": "Episode herunterladen", "download_movie": "Film herunterladen", "download_x_item": "{{item_count}} Elemente herunterladen", - "download_unwatched_only": "Nur unbeobachtete", + "download_unwatched_only": "Nur Ungesehene", "download_button": "Herunterladen" } }, "live_tv": { - "next": "Nächster", - "previous": "Vorheriger", + "next": "Nächste", + "previous": "Vorherige", "coming_soon": "Demnächst", "on_now": "Jetzt", "shows": "Serien", @@ -658,10 +658,10 @@ "confirm": "Bestätigen", "cancel": "Abbrechen", "yes": "Ja", - "whats_wrong": "Hast du Probleme?", - "issue_type": "Fehlerart", - "select_an_issue": "Wähle einen Fehlerart aus", - "types": "Arten", + "whats_wrong": "Was stimmt nicht?", + "issue_type": "Art des Problems", + "select_an_issue": "Wähle die Art des Problems aus", + "types": "Problem-Arten", "describe_the_issue": "(optional) Beschreibe das Problem", "submit_button": "Absenden", "report_issue_button": "Fehler melden", @@ -671,7 +671,7 @@ "cast": "Besetzung", "details": "Details", "status": "Status", - "original_title": "Original Titel", + "original_title": "Originaltitel", "series_type": "Serien Typ", "release_dates": "Veröffentlichungsdaten", "first_air_date": "Erstausstrahlungsdatum", @@ -687,10 +687,10 @@ "request_as": "Anfragen als", "tags": "Tags", "quality_profile": "Qualitätsprofil", - "root_folder": "Root-Ordner", - "season_all": "Season (all)", + "root_folder": "Stammverzeichnis", + "season_all": "Staffeln (alle)", "season_number": "Staffel {{season_number}}", - "number_episodes": "{{episode_number}} Folgen", + "number_episodes": "{{episode_number}} Episoden", "born": "Geboren", "appearances": "Auftritte", "approve": "Genehmigen", @@ -698,9 +698,9 @@ "requested_by": "Angefragt von {{user}}", "unknown_user": "Unbekannter Nutzer", "toasts": { - "jellyseer_does_not_meet_requirements": "Jellyseerr Server erfüllt nicht die Anforderungsversion. Bitte aktualisiere deinen Jellyseerr Server auf mindestens 2.0.0", - "jellyseerr_test_failed": "Jellyseerr-Test fehlgeschlagen. Bitte versuche es erneut.", - "failed_to_test_jellyseerr_server_url": "Fehler beim Testen der Jellyseerr-Server-URL", + "jellyseer_does_not_meet_requirements": "Seerr-Server erfüllt nicht die minimalen Versionsanforderungen. Bitte den Seerr-Server auf mindestens 2.0.0 aktualisieren.", + "jellyseerr_test_failed": "Seerr-Test fehlgeschlagen. Bitte erneut versuchen.", + "failed_to_test_jellyseerr_server_url": "Fehler beim Test der Seerr-Server-URL", "issue_submitted": "Problem eingereicht!", "requested_item": "{{item}} angefragt!", "you_dont_have_permission_to_request": "Du hast keine Berechtigung Anfragen zu stellen", @@ -715,131 +715,131 @@ "home": "Startseite", "search": "Suche", "library": "Bibliothek", - "custom_links": "Benutzerdefinierte Links", + "custom_links": "Links", "favorites": "Favoriten" }, "music": { - "title": "Music", + "title": "Musik", "tabs": { - "suggestions": "Suggestions", - "albums": "Albums", - "artists": "Artists", + "suggestions": "Vorschläge", + "albums": "Alben", + "artists": "Künstler", "playlists": "Playlists", - "tracks": "tracks" + "tracks": "Titel" }, "filters": { - "all": "All" + "all": "Alle" }, - "recently_added": "Recently Added", - "recently_played": "Recently Played", - "frequently_played": "Frequently Played", - "explore": "Explore", - "top_tracks": "Top Tracks", - "play": "Play", + "recently_added": "Kürzlich hinzugefügt", + "recently_played": "Vor kurzem gehört", + "frequently_played": "Oft gehört", + "explore": "Entdecken", + "top_tracks": "Top-Titel", + "play": "Abspielen", "shuffle": "Shuffle", - "play_top_tracks": "Play Top Tracks", - "no_suggestions": "No suggestions available", - "no_albums": "No albums found", - "no_artists": "No artists found", - "no_playlists": "No playlists found", - "album_not_found": "Album not found", - "artist_not_found": "Artist not found", - "playlist_not_found": "Playlist not found", + "play_top_tracks": "Top-Tracks abspielen", + "no_suggestions": "Keine Vorschläge verfügbar", + "no_albums": "Keine Alben gefunden", + "no_artists": "Keine Künstler gefunden", + "no_playlists": "Keine Playlists gefunden", + "album_not_found": "Album nicht gefunden", + "artist_not_found": "Künstler nicht gefunden", + "playlist_not_found": "Playlist nicht gefunden", "track_options": { - "play_next": "Play Next", - "add_to_queue": "Add to Queue", - "add_to_playlist": "Add to Playlist", - "download": "Download", - "downloaded": "Downloaded", - "downloading": "Downloading...", - "cached": "Cached", - "delete_download": "Delete Download", - "delete_cache": "Remove from Cache", - "go_to_artist": "Go to Artist", - "go_to_album": "Go to Album", - "add_to_favorites": "Add to Favorites", - "remove_from_favorites": "Remove from Favorites", - "remove_from_playlist": "Remove from Playlist" + "play_next": "Als Nächstes wiedergeben", + "add_to_queue": "Zur Warteschlange hinzufügen", + "add_to_playlist": "Zur Playlist hinzufügen", + "download": "Herunterladen", + "downloaded": "Heruntergeladen", + "downloading": "Wird heruntergeladen...", + "cached": "Gecached", + "delete_download": "Download löschen", + "delete_cache": "Aus dem Cache löschen", + "go_to_artist": "Zum Künstler gehen", + "go_to_album": "Zum Album gehen", + "add_to_favorites": "Zu Favoriten hinzufügen", + "remove_from_favorites": "Aus Favoriten entfernen", + "remove_from_playlist": "Aus Playlist entfernen" }, "playlists": { - "create_playlist": "Create Playlist", + "create_playlist": "Playlist erstellen", "playlist_name": "Playlist Name", - "enter_name": "Enter playlist name", - "create": "Create", - "search_playlists": "Search playlists...", - "added_to": "Added to {{name}}", - "added": "Added to playlist", - "removed_from": "Removed from {{name}}", - "removed": "Removed from playlist", - "created": "Playlist created", - "create_new": "Create New Playlist", - "failed_to_add": "Failed to add to playlist", - "failed_to_remove": "Failed to remove from playlist", - "failed_to_create": "Failed to create playlist", - "delete_playlist": "Delete Playlist", - "delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", - "deleted": "Playlist deleted", - "failed_to_delete": "Failed to delete playlist" + "enter_name": "Playlist Name eingeben", + "create": "Erstellen", + "search_playlists": "Playlisten durchsuchen...", + "added_to": "Zu {{name}} hinzugefügt", + "added": "Zur Playlist hinzugefügt", + "removed_from": "Aus {{name}} entfernt", + "removed": "Aus Playlist entfernt", + "created": "Playlist erstellt", + "create_new": "Neue Playlist erstellen", + "failed_to_add": "Fehler beim Hinzufügen zur Playlist", + "failed_to_remove": "Fehler beim Entfernen aus der Playlist", + "failed_to_create": "Fehler beim Erstellen der Playlist", + "delete_playlist": "Playlist löschen", + "delete_confirm": "Bist Du sicher, dass Du \"{{name}}\" löschen möchtest? Das kann nicht rückgängig gemacht werden.", + "deleted": "Playlist gelöscht", + "failed_to_delete": "Fehler beim Löschen der Playlist" }, "sort": { - "title": "Sort By", - "alphabetical": "Alphabetical", - "date_created": "Date Created" + "title": "Sortieren nach", + "alphabetical": "Alphabetisch", + "date_created": "Erstellungsdatum" } }, "watchlists": { - "title": "Watchlists", - "my_watchlists": "My Watchlists", - "public_watchlists": "Public Watchlists", - "create_title": "Create Watchlist", - "edit_title": "Edit Watchlist", - "create_button": "Create Watchlist", - "save_button": "Save Changes", - "delete_button": "Delete", - "remove_button": "Remove", - "cancel_button": "Cancel", + "title": "Merklisten", + "my_watchlists": "Meine Merklisten", + "public_watchlists": "Öffentliche Merklisten", + "create_title": "Merkliste erstellen", + "edit_title": "Merkliste bearbeiten", + "create_button": "Merkliste erstellen", + "save_button": "Änderungen speichern", + "delete_button": "Löschen", + "remove_button": "Entfernen", + "cancel_button": "Abbrechen", "name_label": "Name", - "name_placeholder": "Enter watchlist name", - "description_label": "Description", - "description_placeholder": "Enter description (optional)", - "is_public_label": "Public Watchlist", - "is_public_description": "Allow others to view this watchlist", - "allowed_type_label": "Content Type", - "sort_order_label": "Default Sort Order", - "empty_title": "No Watchlists", - "empty_description": "Create your first watchlist to start organizing your media", - "empty_watchlist": "This watchlist is empty", - "empty_watchlist_hint": "Add items from your library to this watchlist", - "not_configured_title": "Streamystats Not Configured", - "not_configured_description": "Configure Streamystats in settings to use watchlists", - "go_to_settings": "Go to Settings", - "add_to_watchlist": "Add to Watchlist", - "remove_from_watchlist": "Remove from Watchlist", - "select_watchlist": "Select Watchlist", - "create_new": "Create New Watchlist", - "item": "item", - "items": "items", - "public": "Public", - "private": "Private", - "you": "You", - "by_owner": "By another user", - "not_found": "Watchlist not found", - "delete_confirm_title": "Delete Watchlist", - "delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", - "remove_item_title": "Remove from Watchlist", - "remove_item_message": "Remove \"{{name}}\" from this watchlist?", - "loading": "Loading watchlists...", - "no_compatible_watchlists": "No compatible watchlists", - "create_one_first": "Create a watchlist that accepts this content type" + "name_placeholder": "Merklistenname eingeben", + "description_label": "Beschreibung", + "description_placeholder": "Beschreibung eingeben (optional)", + "is_public_label": "Öffentliche Merkliste", + "is_public_description": "Anderen erlauben diese Merkliste anzusehen", + "allowed_type_label": "Inhaltstyp", + "sort_order_label": "Standard-Sortierreihenfolge", + "empty_title": "Keine Merklisten", + "empty_description": "Erstelle deine erste Merkliste um deine Medien zu organisieren", + "empty_watchlist": "Diese Merkliste ist leer", + "empty_watchlist_hint": "Füge Elemente aus deiner Bibliothek zu dieser Merkliste hinzu", + "not_configured_title": "Streamystats nicht konfiguriert", + "not_configured_description": "Streamystats in den Einstellungen konfigurieren, um Merklisten zu verwenden", + "go_to_settings": "Gehe zu Einstellungen", + "add_to_watchlist": "Zur Merkliste hinzufügen", + "remove_from_watchlist": "Von Merkliste entfernen", + "select_watchlist": "Merkliste auswählen", + "create_new": "Neue Merkliste erstellen", + "item": "Element", + "items": "Elemente", + "public": "Öffentlich", + "private": "Privat", + "you": "Du", + "by_owner": "Von einem anderen Benutzer", + "not_found": "Merkliste nicht gefunden", + "delete_confirm_title": "Merkliste löschen", + "delete_confirm_message": "Bist Du sicher, dass Du \"{{name}}\" löschen möchtest? Das kann nicht rückgängig gemacht werden.", + "remove_item_title": "Von Merkliste entfernen", + "remove_item_message": "\"{{name}}\" von dieser Merkliste entfernen?", + "loading": "Lade Merklisten...", + "no_compatible_watchlists": "Keine kompatiblen Merklisten", + "create_one_first": "Erstelle eine Merkliste, welche diesen Inhaltstyp akzeptiert" }, "playback_speed": { - "title": "Playback Speed", - "apply_to": "Apply To", - "speed": "Speed", + "title": "Wiedergabegeschwindigkeit", + "apply_to": "Anwenden auf", + "speed": "Geschwindigkeit", "scope": { - "media": "This media only", - "show": "This show", - "all": "All media (default)" + "media": "Nur hier", + "show": "Nur diese Serie", + "all": "Alle (Standard)" } } } diff --git a/translations/en.json b/translations/en.json index 3fe9efb66..58fe4828b 100644 --- a/translations/en.json +++ b/translations/en.json @@ -4,6 +4,9 @@ "error_title": "Error", "login_title": "Log In", "login_to_title": "Log in to", + "select_user": "Select a user to log in", + "add_user_to_login": "Add a user to log in", + "add_user": "Add User", "username_placeholder": "Username", "password_placeholder": "Password", "login_button": "Log In", @@ -42,7 +45,13 @@ "accounts_count": "{{count}} accounts", "select_account": "Select Account", "add_account": "Add Account", - "remove_account_description": "This will remove the saved credentials for {{username}}." + "remove_account_description": "This will remove the saved credentials for {{username}}.", + "remove_server": "Remove Server", + "remove_server_description": "This will remove {{server}} and all saved accounts from your list.", + "select_your_server": "Select Your Server", + "add_server_to_get_started": "Add a server to get started", + "add_server": "Add Server", + "change_server": "Change Server" }, "save_account": { "title": "Save Account", @@ -86,6 +95,7 @@ "oops": "Oops!", "error_message": "Something went wrong.\nPlease log out and in again.", "continue_watching": "Continue Watching", + "continue": "Continue", "next_up": "Next Up", "continue_and_next_up": "Continue & Next Up", "recently_added_in": "Recently Added in {{libraryName}}", @@ -109,6 +119,12 @@ "settings": { "settings_title": "Settings", "log_out_button": "Log Out", + "switch_user": { + "title": "Switch User", + "account": "Account", + "switch_user": "Switch User on This Server", + "current": "current" + }, "categories": { "title": "Categories" }, @@ -121,7 +137,16 @@ "appearance": { "title": "Appearance", "merge_next_up_continue_watching": "Merge Continue Watching & Next Up", - "hide_remote_session_button": "Hide Remote Session Button" + "hide_remote_session_button": "Hide Remote Session Button", + "show_home_backdrop": "Dynamic Home Backdrop", + "show_hero_carousel": "Hero Carousel", + "show_series_poster_on_episode": "Show Series Poster on Episodes", + "theme_music": "Theme Music", + "display_size": "Display Size", + "display_size_small": "Small", + "display_size_default": "Default", + "display_size_large": "Large", + "display_size_extra_large": "Extra Large" }, "network": { "title": "Network", @@ -174,6 +199,22 @@ "rewind_length": "Rewind Length", "seconds_unit": "s" }, + "buffer": { + "title": "Buffer Settings", + "cache_mode": "Cache Mode", + "cache_auto": "Auto", + "cache_yes": "Enabled", + "cache_no": "Disabled", + "buffer_duration": "Buffer Duration", + "max_cache_size": "Max Cache Size", + "max_backward_cache": "Max Backward Cache" + }, + "vo_driver": { + "title": "Video Output", + "vo_mode": "VO Driver", + "gpu_next": "gpu-next (Recommended)", + "gpu": "gpu" + }, "gesture_controls": { "gesture_controls_title": "Gesture Controls", "horizontal_swipe_skip": "Horizontal Swipe to Skip", @@ -256,7 +297,23 @@ "subtitle_font": "Subtitle Font", "ksplayer_title": "KSPlayer Settings", "hardware_decode": "Hardware Decoding", - "hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues." + "hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.", + "opensubtitles_title": "OpenSubtitles", + "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.", + "opensubtitles_api_key": "API Key", + "opensubtitles_api_key_placeholder": "Enter API key...", + "opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers", + "mpv_subtitle_scale": "Subtitle Scale", + "mpv_subtitle_margin_y": "Vertical Margin", + "mpv_subtitle_align_x": "Horizontal Align", + "mpv_subtitle_align_y": "Vertical Align", + "align": { + "left": "Left", + "center": "Center", + "right": "Right", + "top": "Top", + "bottom": "Bottom" + } }, "vlc_subtitles": { "title": "VLC Subtitle Settings", @@ -406,7 +463,13 @@ "music_cache_cleared": "Music cache cleared", "delete_all_downloaded_songs": "Delete All Downloaded Songs", "downloaded_songs_size": "{{size}} downloaded", - "downloaded_songs_deleted": "Downloaded songs deleted" + "downloaded_songs_deleted": "Downloaded songs deleted", + "clear_all_cache": "Clear All Cache", + "clear_all_cache_confirm": "Clear All Cache?", + "clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.", + "clear_all_cache_success": "Cache Cleared", + "clear_all_cache_success_desc": "All cache has been cleared successfully.", + "clear_all_cache_error_desc": "An error occurred while clearing the cache." }, "intro": { "title": "Intro", @@ -430,6 +493,21 @@ "error_deleting_files": "Error Deleting Files", "background_downloads_enabled": "Background downloads enabled", "background_downloads_disabled": "Background downloads disabled" + }, + "security": { + "title": "Security", + "inactivity_timeout": { + "title": "Inactivity Timeout", + "description": "Auto logout after inactivity", + "disabled": "Disabled", + "1_minute": "1 minute", + "5_minutes": "5 minutes", + "15_minutes": "15 minutes", + "30_minutes": "30 minutes", + "1_hour": "1 hour", + "4_hours": "4 hours", + "24_hours": "24 hours" + } } }, "sessions": { @@ -492,6 +570,7 @@ } }, "common": { + "no_results": "No Results", "select": "Select", "no_trailer_available": "No trailer available", "video": "Video", @@ -501,13 +580,16 @@ "none": "None", "track": "Track", "cancel": "Cancel", + "stop": "Stop", "delete": "Delete", "ok": "OK", "remove": "Remove", "next": "Next", "back": "Back", "continue": "Continue", - "verifying": "Verifying..." + "verifying": "Verifying...", + "login": "Login", + "refresh": "Refresh" }, "search": { "search": "Search...", @@ -556,6 +638,7 @@ "movies": "Movies", "series": "Series", "boxsets": "Box Sets", + "playlists": "Playlists", "items": "Items" }, "options": { @@ -574,7 +657,11 @@ "sort_by": "Sort By", "filter_by": "Filter By", "sort_order": "Sort Order", - "tags": "Tags" + "tags": "Tags", + "all": "All", + "reset": "Reset", + "asc": "Ascending", + "desc": "Descending" } }, "favorites": { @@ -591,6 +678,7 @@ "no_links": "No Links" }, "player": { + "live": "LIVE", "error": "Error", "failed_to_get_stream_url": "Failed to get the stream URL", "an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.", @@ -608,7 +696,35 @@ "downloaded_file_message": "Do you want to play the downloaded file?", "downloaded_file_yes": "Yes", "downloaded_file_no": "No", - "downloaded_file_cancel": "Cancel" + "downloaded_file_cancel": "Cancel", + "swipe_down_settings": "Swipe down for settings", + "ends_at": "ends at", + "search_subtitles": "Search Subtitles", + "subtitle_tracks": "Tracks", + "subtitle_search": "Search & Download", + "download": "Download", + "subtitle_download_hint": "Downloaded subtitles will be saved to your library", + "using_jellyfin_server": "Using Jellyfin Server", + "language": "Language", + "results": "Results", + "searching": "Searching...", + "search_failed": "Search failed", + "no_subtitle_provider": "No subtitle provider configured on server", + "no_subtitles_found": "No subtitles found", + "add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback", + "settings": "Settings", + "skip_intro": "Skip Intro", + "skip_credits": "Skip Credits", + "stopPlayback": "Stop Playback", + "stopPlayingTitle": "Stop playing \"{{title}}\"?", + "stopPlayingConfirm": "Are you sure you want to stop playback?", + "downloaded": "Downloaded" + }, + "chapters": { + "title": "Chapters", + "chapter_number": "Chapter {{number}}", + "open": "Open chapters", + "close": "Close chapters" }, "item_card": { "next_up": "Next Up", @@ -617,6 +733,11 @@ "series": "Series", "seasons": "Seasons", "season": "Season", + "from_this_series": "From This Series", + "more_from_this_season": "More from this Season", + "view_series": "View Series", + "view_season": "View Season", + "select_season": "Select Season", "no_episodes_for_this_season": "No episodes for this season", "overview": "Overview", "more_with": "More with {{name}}", @@ -627,10 +748,21 @@ "media_options": "Media Options", "quality": "Quality", "audio": "Audio", - "subtitles": "Subtitle", + "subtitles": { + "label": "Subtitle", + "none": "None", + "tracks": "Tracks" + }, "show_more": "Show More", "show_less": "Show Less", + "left": "left", + "more_info": "More Info", + "director": "Director", + "cast": "Cast", + "technical_details": "Technical Details", "appeared_in": "Appeared In", + "movies": "Movies", + "shows": "Shows", "could_not_load_item": "Could Not Load Item", "none": "None", "download": { @@ -641,7 +773,13 @@ "download_x_item": "Download {{item_count}} Items", "download_unwatched_only": "Unwatched Only", "download_button": "Download" - } + }, + "mark_played": "Mark as Watched", + "mark_unplayed": "Mark as Unwatched", + "resume_playback": "Resume Playback", + "resume_playback_description": "Do you want to continue where you left off or start from the beginning?", + "play_from_start": "Play from Start", + "continue_from": "Continue from {{time}}" }, "live_tv": { "next": "Next", @@ -652,7 +790,18 @@ "movies": "Movies", "sports": "Sports", "for_kids": "For Kids", - "news": "News" + "news": "News", + "page_of": "Page {{current}} of {{total}}", + "no_programs": "No programs available", + "no_channels": "No channels available", + "tabs": { + "programs": "Programs", + "guide": "Guide", + "channels": "Channels", + "recordings": "Recordings", + "schedule": "Schedule", + "series": "Series" + } }, "jellyseerr": { "confirm": "Confirm", @@ -697,6 +846,12 @@ "decline": "Decline", "requested_by": "Requested by {{user}}", "unknown_user": "Unknown User", + "select": "Select", + "request_all": "Request All", + "request_seasons": "Request Seasons", + "select_seasons": "Select Seasons", + "request_selected": "Request Selected", + "n_selected": "{{count}} selected", "toasts": { "jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0", "jellyseerr_test_failed": "Seerr test failed. Please try again.", @@ -716,7 +871,8 @@ "search": "Search", "library": "Library", "custom_links": "Custom Links", - "favorites": "Favorites" + "favorites": "Favorites", + "settings": "Settings" }, "music": { "title": "Music", @@ -841,5 +997,36 @@ "show": "This show", "all": "All media (default)" } + }, + "companion_login": { + "title": "Pair with TV", + "align_qr": "Align the QR code within the frame", + "enter_code_manually": "Enter code manually", + "pairing_enter_credentials": "Enter credentials for TV", + "pairing_code_label": "Pairing code", + "server": "Server", + "authorize_button": "Authorize", + "authorizing": "Authorizing...", + "scan_again": "Scan Again", + "done": "Done", + "success_title": "Authorization Sent", + "pairing_tv_connecting": "The TV is connecting to your account", + "error_title": "Authorization Failed", + "error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.", + "error_generic": "Something went wrong. Please try again.", + "error_permission_denied": "Camera permission is required to scan QR codes.", + "login_as": "Log in as {{username}}?", + "on_server": "on {{server}}", + "use_different_user": "Use a different user", + "open_settings": "Open Settings" + }, + "pairing": { + "pair_with_phone": "Pair with Phone", + "pair_with_phone_title": "Login TV", + "pair_with_phone_description": "Scan the QR code displayed on your TV to log in", + "waiting_for_phone": "Waiting for phone...", + "scan_with_phone": "Scan with the Streamyfin app on your phone", + "logging_in": "Logging in...", + "logging_in_description": "Connecting to your server" } } diff --git a/translations/es.json b/translations/es.json index 9ab662ebd..c98493d93 100644 --- a/translations/es.json +++ b/translations/es.json @@ -39,39 +39,39 @@ "please_login_again": "Su sesión guardada ha caducado. Por favor, inicie sesión de nuevo.", "remove_saved_login": "Eliminar inicio de sesión guardado", "remove_saved_login_description": "Esto eliminará tus credenciales guardadas para este servidor. Tendrás que volver a introducir tu nombre de usuario y contraseña la próxima vez.", - "accounts_count": "{{count}} accounts", - "select_account": "Select Account", - "add_account": "Add Account", - "remove_account_description": "This will remove the saved credentials for {{username}}." + "accounts_count": "{{count}} cuentas", + "select_account": "Seleccione una cuenta", + "add_account": "Añadir cuenta", + "remove_account_description": "Esto eliminará las credenciales guardadas para {{username}}." }, "save_account": { - "title": "Save Account", - "save_for_later": "Save this account", - "security_option": "Security Option", - "no_protection": "No protection", - "no_protection_desc": "Quick login without authentication", - "pin_code": "PIN code", - "pin_code_desc": "4-digit PIN required when switching", - "password": "Re-enter password", - "password_desc": "Password required when switching", - "save_button": "Save", - "cancel_button": "Cancel" + "title": "Guardar Cuenta", + "save_for_later": "Guardar esta cuenta", + "security_option": "Opciones de seguridad", + "no_protection": "Sin Protección", + "no_protection_desc": "Inicio de sesión rápido sin autenticación", + "pin_code": "Código PIN", + "pin_code_desc": "PIN de 4 dígitos requerido al cambiar", + "password": "Vuelva a introducir la contraseña", + "password_desc": "Contraseña requerida al cambiar", + "save_button": "Guardar", + "cancel_button": "Cancelar" }, "pin": { - "enter_pin": "Enter PIN", - "enter_pin_for": "Enter PIN for {{username}}", - "enter_4_digits": "Enter 4 digits", - "invalid_pin": "Invalid PIN", - "setup_pin": "Set Up PIN", - "confirm_pin": "Confirm PIN", - "pins_dont_match": "PINs don't match", - "forgot_pin": "Forgot PIN?", - "forgot_pin_desc": "Your saved credentials will be removed" + "enter_pin": "Introduce el PIN", + "enter_pin_for": "Introduzca el PIN para {{username}}", + "enter_4_digits": "Introduce 4 dígitos", + "invalid_pin": "PIN inválido", + "setup_pin": "Configurar PIN", + "confirm_pin": "Confirmar PIN", + "pins_dont_match": "Los códigos PIN no coinciden", + "forgot_pin": "¿Olvidó el PIN?", + "forgot_pin_desc": "Sus credenciales guardadas serán eliminadas" }, "password": { - "enter_password": "Enter Password", - "enter_password_for": "Enter password for {{username}}", - "invalid_password": "Invalid password" + "enter_password": "Introduzca la contraseña", + "enter_password_for": "Introduzca la contraseña para {{username}}", + "invalid_password": "Contraseña inválida" }, "home": { "checking_server_connection": "Comprobando conexión con el servidor...", @@ -124,32 +124,32 @@ "hide_remote_session_button": "Ocultar botón de sesión remota" }, "network": { - "title": "Network", - "local_network": "Local Network", - "auto_switch_enabled": "Auto-switch when at home", - "auto_switch_description": "Automatically switch to local URL when connected to home WiFi", - "local_url": "Local URL", - "local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)", + "title": "Cadena", + "local_network": "Red local", + "auto_switch_enabled": "Cambiar automáticamente en casa", + "auto_switch_description": "Cambiar automáticamente a la URL local cuando se conecta a la WiFi de casa", + "local_url": "URL local", + "local_url_hint": "Introduzca la dirección de su servidor local (por ejemplo, http://192.168.1.100:8096)", "local_url_placeholder": "http://192.168.1.100:8096", - "home_wifi_networks": "Home WiFi Networks", - "add_current_network": "Add \"{{ssid}}\"", - "not_connected_to_wifi": "Not connected to WiFi", - "no_networks_configured": "No networks configured", - "add_network_hint": "Add your home WiFi network to enable auto-switching", - "current_wifi": "Current WiFi", - "using_url": "Using", - "local": "Local URL", - "remote": "Remote URL", - "not_connected": "Not connected", - "current_server": "Current Server", - "remote_url": "Remote URL", - "active_url": "Active URL", - "not_configured": "Not configured", - "network_added": "Network added", - "network_already_added": "Network already added", - "no_wifi_connected": "Not connected to WiFi", - "permission_denied": "Location permission denied", - "permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings." + "home_wifi_networks": "Redes WiFi domésticas", + "add_current_network": "Añadir \"{{ssid}}\"", + "not_connected_to_wifi": "No está conectado a WiFi", + "no_networks_configured": "No hay redes configuradas", + "add_network_hint": "Añade tu red WiFi doméstica para activar el cambio automático", + "current_wifi": "WiFi actual", + "using_url": "Utilizando", + "local": "URL local", + "remote": "URL Remota", + "not_connected": "Sin conexión", + "current_server": "Servidor actual", + "remote_url": "URL Remota", + "active_url": "URL Activa", + "not_configured": "Sin configurar", + "network_added": "Red añadida", + "network_already_added": "Red ya añadida", + "no_wifi_connected": "Sin conexión a WiFi", + "permission_denied": "Permiso de ubicación denegado", + "permission_denied_explanation": "Se necesita el permiso de ubicación para detectar la red WiFi para cambiar automáticamente. Por favor, actívala en Configuración." }, "user_info": { "user_info_title": "Información de usuario", @@ -195,12 +195,12 @@ "none": "Ninguno", "language": "Idioma", "transcode_mode": { - "title": "Audio Transcoding", - "description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled", + "title": "Transcodificación de audio", + "description": "Controla cómo el audio envolvente (7.1, TrueHD, DTS-HD) es manejado", "auto": "Auto", - "stereo": "Force Stereo", - "5_1": "Allow 5.1", - "passthrough": "Passthrough" + "stereo": "Forzar salida estéreo", + "5_1": "Permitir 5.1", + "passthrough": "Directo" } }, "subtitles": { @@ -259,16 +259,16 @@ "hardware_decode_description": "Utilizar la aceleración de hardware para la decodificación de vídeo. Deshabilite si experimenta problemas de reproducción." }, "vlc_subtitles": { - "title": "VLC Subtitle Settings", - "hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.", - "text_color": "Text Color", - "background_color": "Background Color", - "background_opacity": "Background Opacity", - "outline_color": "Outline Color", - "outline_opacity": "Outline Opacity", - "outline_thickness": "Outline Thickness", - "bold": "Bold Text", - "margin": "Bottom Margin" + "title": "Configuración de subtítulos VLC", + "hint": "Personalizar la apariencia de los subtítulos para el reproductor VLC. Los cambios tendrán efecto en la próxima reproducción.", + "text_color": "Color del texto", + "background_color": "Color del fondo", + "background_opacity": "Opacidad del fondo", + "outline_color": "Color del contorno", + "outline_opacity": "Opacidad del contorno", + "outline_thickness": "Grosor del contorno", + "bold": "Texto en negrita", + "margin": "Margen inferior" }, "video_player": { "title": "Reproductor de vídeo", @@ -300,13 +300,13 @@ "VLC_4": "VLC 4 (Experimental + PiP)" }, "show_custom_menu_links": "Mostrar enlaces de menú personalizados", - "show_large_home_carousel": "Show Large Home Carousel (beta)", + "show_large_home_carousel": "Mostrar carrusel del menú principal grande (beta)", "hide_libraries": "Ocultar bibliotecas", "select_liraries_you_want_to_hide": "Selecciona las bibliotecas que quieres ocultar de la pestaña Bibliotecas y de Inicio.", "disable_haptic_feedback": "Desactivar feedback háptico", "default_quality": "Calidad por defecto", "default_playback_speed": "Velocidad de reproducción predeterminada", - "auto_play_next_episode": "Auto-play Next Episode", + "auto_play_next_episode": "Reproducir automáticamente el siguiente episodio", "max_auto_play_episode_count": "Máximo número de episodios de Auto Play", "disabled": "Deshabilitado" }, @@ -317,10 +317,10 @@ "title": "Música", "playback_title": "Reproducir", "playback_description": "Configurar cómo se reproduce la música.", - "prefer_downloaded": "Prefer Downloaded Songs", + "prefer_downloaded": "Preferir las canciones descargadas", "caching_title": "Almacenando en caché", - "caching_description": "Automatically cache upcoming tracks for smoother playback.", - "lookahead_enabled": "Enable Look-Ahead Caching", + "caching_description": "Cachear automáticamente las próximas canciones para una reproducción más suave.", + "lookahead_enabled": "Activar el look-Ahead Cache", "lookahead_count": "", "max_cache_size": "Tamaño máximo del caché" }, @@ -399,7 +399,7 @@ "size_used": "{{used}} de {{total}} usado", "delete_all_downloaded_files": "Eliminar todos los archivos descargados", "music_cache_title": "Caché de música", - "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", + "music_cache_description": "Cachear automáticamente las canciones mientras escuchas una reproducción más suave y soporte sin conexión", "enable_music_cache": "Activar Caché de Música", "clear_music_cache": "Borrar Caché de Música", "music_cache_size": "Caché {{Tamaño}}", @@ -504,10 +504,10 @@ "delete": "Borrar", "ok": "Aceptar", "remove": "Eliminar", - "next": "Next", - "back": "Back", - "continue": "Continue", - "verifying": "Verifying..." + "next": "Siguiente", + "back": "Atrás", + "continue": "Continuar", + "verifying": "Verificando..." }, "search": { "search": "Buscar...", @@ -753,8 +753,8 @@ "downloaded": "Descargado", "downloading": "Descargando...", "cached": "En caché", - "delete_download": "Delete Download", - "delete_cache": "Remove from Cache", + "delete_download": "Eliminar descarga", + "delete_cache": "Borrar del caché", "go_to_artist": "Ir al artista", "go_to_album": "Ir al álbum", "add_to_favorites": "Añadir a Favoritos", diff --git a/translations/fr.json b/translations/fr.json index 21fef280b..b2663cd6e 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -305,8 +305,8 @@ "select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans l'onglet Bibliothèque et les sections de la page d'accueil.", "disable_haptic_feedback": "Désactiver le retour haptique", "default_quality": "Qualité par défaut", - "default_playback_speed": "Default Playback Speed", - "auto_play_next_episode": "Auto-play Next Episode", + "default_playback_speed": "Vitesse de lecture par défaut", + "auto_play_next_episode": "Lecture automatique de l'épisode suivant", "max_auto_play_episode_count": "Nombre d'épisodes en lecture automatique max", "disabled": "Désactivé" }, @@ -314,15 +314,15 @@ "downloads_title": "Téléchargements" }, "music": { - "title": "Music", - "playback_title": "Playback", - "playback_description": "Configure how music is played.", - "prefer_downloaded": "Prefer Downloaded Songs", - "caching_title": "Caching", - "caching_description": "Automatically cache upcoming tracks for smoother playback.", - "lookahead_enabled": "Enable Look-Ahead Caching", - "lookahead_count": "Tracks to Pre-cache", - "max_cache_size": "Max Cache Size" + "title": "Musique", + "playback_title": "Lecture", + "playback_description": "Configurer le mode de lecture de la musique.", + "prefer_downloaded": "Supprimer toutes les musiques téléchargées", + "caching_title": "Mise en cache", + "caching_description": "Mettre automatiquement en cache les pistes à venir pour une lecture plus fluide.", + "lookahead_enabled": "Activer la mise en cache guidée", + "lookahead_count": "Pistes à pré-mettre en cache", + "max_cache_size": "Taille max de cache" }, "plugins": { "plugins_title": "Plugins", @@ -357,19 +357,19 @@ "save_button": "Enregistrer", "toasts": { "saved": "Enregistré", - "refreshed": "Settings refreshed from server" + "refreshed": "Paramètres actualisés depuis le serveur" }, - "refresh_from_server": "Refresh Settings from Server" + "refresh_from_server": "Rafraîchir les paramètres depuis le serveur" }, "streamystats": { - "enable_streamystats": "Enable Streamystats", - "disable_streamystats": "Disable Streamystats", - "enable_search": "Use for Search", + "enable_streamystats": "Activer Streamystats", + "disable_streamystats": "Désactiver Streamystats", + "enable_search": "Utiliser pour la recherche", "url": "URL", "server_url_placeholder": "http(s)://streamystats.example.com", - "streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.", - "read_more_about_streamystats": "Read More About Streamystats.", - "save_button": "Save", + "streamystats_search_hint": "Entrez l'URL de votre serveur Streamystats. L'URL doit inclure http ou https et éventuellement le port.", + "read_more_about_streamystats": "En savoir plus sur Streamystats.", + "save_button": "Enregistrer", "save": "Enregistrer", "features_title": "Fonctionnalités", "home_sections_title": "Sections de la page d´accueil", @@ -572,7 +572,7 @@ "genres": "Genres", "years": "Années", "sort_by": "Trier par", - "filter_by": "Filter By", + "filter_by": "Filtrer par", "sort_order": "Ordre de tri", "tags": "Tags" } @@ -719,127 +719,127 @@ "favorites": "Favoris" }, "music": { - "title": "Music", + "title": "Musique", "tabs": { "suggestions": "Suggestions", "albums": "Albums", - "artists": "Artists", + "artists": "Artistes", "playlists": "Playlists", - "tracks": "tracks" + "tracks": "morceaux" }, "filters": { - "all": "All" + "all": "Toutes" }, - "recently_added": "Recently Added", - "recently_played": "Recently Played", - "frequently_played": "Frequently Played", - "explore": "Explore", - "top_tracks": "Top Tracks", - "play": "Play", - "shuffle": "Shuffle", - "play_top_tracks": "Play Top Tracks", - "no_suggestions": "No suggestions available", - "no_albums": "No albums found", - "no_artists": "No artists found", - "no_playlists": "No playlists found", - "album_not_found": "Album not found", - "artist_not_found": "Artist not found", - "playlist_not_found": "Playlist not found", + "recently_added": "Ajoutés récemment", + "recently_played": "Récemment joué", + "frequently_played": "Fréquemment joué", + "explore": "Explorez", + "top_tracks": "Top chansons", + "play": "Lecture", + "shuffle": "Aléatoire", + "play_top_tracks": "Jouer les pistes les plus populaires", + "no_suggestions": "Pas de suggestion disponible", + "no_albums": "Pas d'albums trouvés", + "no_artists": "Pas d'artistes trouvé", + "no_playlists": "Pas de playlists trouvées", + "album_not_found": "Album introuvable", + "artist_not_found": "Artiste introuvable", + "playlist_not_found": "Playlist introuvable", "track_options": { - "play_next": "Play Next", - "add_to_queue": "Add to Queue", - "add_to_playlist": "Add to Playlist", - "download": "Download", - "downloaded": "Downloaded", - "downloading": "Downloading...", - "cached": "Cached", - "delete_download": "Delete Download", - "delete_cache": "Remove from Cache", - "go_to_artist": "Go to Artist", - "go_to_album": "Go to Album", - "add_to_favorites": "Add to Favorites", - "remove_from_favorites": "Remove from Favorites", - "remove_from_playlist": "Remove from Playlist" + "play_next": "Lecture suivante", + "add_to_queue": "Ajouter à la file d'attente", + "add_to_playlist": "Ajouter à la playlist", + "download": "Télécharger", + "downloaded": "Téléchargé", + "downloading": "Téléchargement en cours...", + "cached": "En cache", + "delete_download": "Supprimer un téléchargement", + "delete_cache": "Supprimer du cache", + "go_to_artist": "Voir l'artiste", + "go_to_album": "Aller à l’album", + "add_to_favorites": "Ajouter aux favoris", + "remove_from_favorites": "Retirer des favoris", + "remove_from_playlist": "Retirer de la playlist" }, "playlists": { - "create_playlist": "Create Playlist", - "playlist_name": "Playlist Name", - "enter_name": "Enter playlist name", - "create": "Create", - "search_playlists": "Search playlists...", - "added_to": "Added to {{name}}", - "added": "Added to playlist", - "removed_from": "Removed from {{name}}", - "removed": "Removed from playlist", - "created": "Playlist created", - "create_new": "Create New Playlist", - "failed_to_add": "Failed to add to playlist", - "failed_to_remove": "Failed to remove from playlist", - "failed_to_create": "Failed to create playlist", - "delete_playlist": "Delete Playlist", - "delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", - "deleted": "Playlist deleted", - "failed_to_delete": "Failed to delete playlist" + "create_playlist": "Créer une Playlist", + "playlist_name": "Nom de la Playlist", + "enter_name": "Entrer le nom de la playlist", + "create": "Créer", + "search_playlists": "Rechercher des playlists...", + "added_to": "Ajouté à {{name}}", + "added": "Ajouté à la playlist", + "removed_from": "Retiré de {{name}}", + "removed": "Retiré de la playlist", + "created": "Playlist créée", + "create_new": "Créer une nouvelle playlist", + "failed_to_add": "Échec de l'ajout à la playlist", + "failed_to_remove": "Échec de la suppression de la playlist", + "failed_to_create": "Échec de la suppression de la playlist", + "delete_playlist": "Supprimer la playlist", + "delete_confirm": "Êtes-vous sûr de vouloir supprimer « {{ name }} » ? Cette action est irréversible.", + "deleted": "Playlist supprimée", + "failed_to_delete": "Échec de la suppression de la playlist" }, "sort": { - "title": "Sort By", - "alphabetical": "Alphabetical", - "date_created": "Date Created" + "title": "Trier par", + "alphabetical": "Ordre alphabétique", + "date_created": "Date de création" } }, "watchlists": { "title": "Watchlists", "my_watchlists": "My Watchlists", - "public_watchlists": "Public Watchlists", - "create_title": "Create Watchlist", - "edit_title": "Edit Watchlist", - "create_button": "Create Watchlist", - "save_button": "Save Changes", - "delete_button": "Delete", - "remove_button": "Remove", - "cancel_button": "Cancel", - "name_label": "Name", - "name_placeholder": "Enter watchlist name", + "public_watchlists": "Watchlist publique", + "create_title": "Créer une Watchlist", + "edit_title": "Modifier la Watchlist", + "create_button": "Créer une Watchlist", + "save_button": "Enregistrer les modifications", + "delete_button": "Supprimer", + "remove_button": "Retirer", + "cancel_button": "Annuler", + "name_label": "Nom", + "name_placeholder": "Entrer le nom de la playlist", "description_label": "Description", - "description_placeholder": "Enter description (optional)", + "description_placeholder": "Entrez la description (facultatif)", "is_public_label": "Public Watchlist", - "is_public_description": "Allow others to view this watchlist", - "allowed_type_label": "Content Type", - "sort_order_label": "Default Sort Order", - "empty_title": "No Watchlists", - "empty_description": "Create your first watchlist to start organizing your media", - "empty_watchlist": "This watchlist is empty", - "empty_watchlist_hint": "Add items from your library to this watchlist", - "not_configured_title": "Streamystats Not Configured", - "not_configured_description": "Configure Streamystats in settings to use watchlists", - "go_to_settings": "Go to Settings", - "add_to_watchlist": "Add to Watchlist", - "remove_from_watchlist": "Remove from Watchlist", - "select_watchlist": "Select Watchlist", - "create_new": "Create New Watchlist", - "item": "item", - "items": "items", - "public": "Public", - "private": "Private", - "you": "You", - "by_owner": "By another user", - "not_found": "Watchlist not found", - "delete_confirm_title": "Delete Watchlist", - "delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", - "remove_item_title": "Remove from Watchlist", - "remove_item_message": "Remove \"{{name}}\" from this watchlist?", - "loading": "Loading watchlists...", - "no_compatible_watchlists": "No compatible watchlists", - "create_one_first": "Create a watchlist that accepts this content type" + "is_public_description": "Autoriser d'autres personnes à voir cette liste de suivi", + "allowed_type_label": "Type de contenu", + "sort_order_label": "Ordre de tri par défaut", + "empty_title": "Pas de Watchlists", + "empty_description": "Créez votre première liste de suivi pour commencer à organiser vos médias", + "empty_watchlist": "Cette liste de suivi est vide", + "empty_watchlist_hint": "Ajouter des éléments de votre bibliothèque à cette liste de suivi", + "not_configured_title": "Streamystats non configuré", + "not_configured_description": "Configurer Streamystats dans les paramètres pour utiliser les listes de suivi", + "go_to_settings": "Accédez aux Paramètres", + "add_to_watchlist": "Ajouter à la Watchlist", + "remove_from_watchlist": "Retirer de la Watchlist", + "select_watchlist": "Sélectionner la liste de suivi", + "create_new": "Créer une Watchlist", + "item": "médias", + "items": "élément", + "public": "Publique", + "private": "Privée", + "you": "Vous-même", + "by_owner": "Par un autre utilisateur", + "not_found": "Playlist introuvable", + "delete_confirm_title": "Supprimer la Watchlist", + "delete_confirm_message": "Tous les médias (par défaut)", + "remove_item_title": "Retirer de la Watchlist", + "remove_item_message": "Retirer «{{name}}» de cette liste de suivi?", + "loading": "Chargement des listes de suivi...", + "no_compatible_watchlists": "Aucune liste de suivi compatible", + "create_one_first": "Créer une liste de suivi qui accepte ce type de contenu" }, "playback_speed": { - "title": "Playback Speed", - "apply_to": "Apply To", - "speed": "Speed", + "title": "Vitesse de lecture", + "apply_to": "Appliquer à", + "speed": "Vitesse", "scope": { - "media": "This media only", - "show": "This show", - "all": "All media (default)" + "media": "Ce média uniquement", + "show": "Cette série", + "all": "Tous les médias (par défaut)" } } } diff --git a/translations/it.json b/translations/it.json index a6de11c10..604934b78 100644 --- a/translations/it.json +++ b/translations/it.json @@ -34,9 +34,9 @@ "search_for_local_servers": "Ricerca dei server locali", "searching": "Cercando...", "servers": "Server", - "saved": "Saved", + "saved": "Salvato", "session_expired": "Session Expired", - "please_login_again": "Your saved session has expired. Please log in again.", + "please_login_again": "La tua sessione è scaduta. Si prega di eseguire nuovamente l'accesso.", "remove_saved_login": "Remove Saved Login", "remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.", "accounts_count": "{{count}} accounts", @@ -125,7 +125,7 @@ }, "network": { "title": "Network", - "local_network": "Local Network", + "local_network": "", "auto_switch_enabled": "Auto-switch when at home", "auto_switch_description": "Automatically switch to local URL when connected to home WiFi", "local_url": "Local URL", @@ -137,7 +137,7 @@ "no_networks_configured": "No networks configured", "add_network_hint": "Add your home WiFi network to enable auto-switching", "current_wifi": "Current WiFi", - "using_url": "Using", + "using_url": "Sta utilizzando", "local": "Local URL", "remote": "Remote URL", "not_connected": "Not connected", diff --git a/translations/nl.json b/translations/nl.json index 326f9a1ec..375aeab10 100644 --- a/translations/nl.json +++ b/translations/nl.json @@ -30,7 +30,7 @@ "connect_button": "Verbinden", "previous_servers": "vorige servers", "clear_button": "Wissen", - "swipe_to_remove": "Swipe to remove", + "swipe_to_remove": "Swipe om te verwijderen.", "search_for_local_servers": "Zoek naar lokale servers", "searching": "Zoeken...", "servers": "Servers", @@ -40,38 +40,38 @@ "remove_saved_login": "Opgeslagen login verwijderen", "remove_saved_login_description": "Hiermee worden uw opgeslagen gegevens voor deze server verwijderd. U moet uw gebruikersnaam en wachtwoord de volgende keer opnieuw invoeren.", "accounts_count": "{{count}} accounts", - "select_account": "Select Account", - "add_account": "Add Account", - "remove_account_description": "This will remove the saved credentials for {{username}}." + "select_account": "Account selecteren", + "add_account": "Account toevoegen", + "remove_account_description": "Hiermee worden de opgeslagen inloggegevens voor {{username}} verwijderd." }, "save_account": { - "title": "Save Account", - "save_for_later": "Save this account", - "security_option": "Security Option", - "no_protection": "No protection", - "no_protection_desc": "Quick login without authentication", - "pin_code": "PIN code", - "pin_code_desc": "4-digit PIN required when switching", - "password": "Re-enter password", - "password_desc": "Password required when switching", - "save_button": "Save", - "cancel_button": "Cancel" + "title": "Account opslaan", + "save_for_later": "Dit account opslaan", + "security_option": "Beveiligingsopties", + "no_protection": "Geen beveiliging", + "no_protection_desc": "Snelle login zonder authenticatie", + "pin_code": "Pincode", + "pin_code_desc": "4-cijferige pincode vereist bij wisselen", + "password": "Wachtwoord opnieuw invoeren", + "password_desc": "Wachtwoord vereist bij wisselen", + "save_button": "Opslaan", + "cancel_button": "Annuleren" }, "pin": { - "enter_pin": "Enter PIN", - "enter_pin_for": "Enter PIN for {{username}}", - "enter_4_digits": "Enter 4 digits", - "invalid_pin": "Invalid PIN", - "setup_pin": "Set Up PIN", - "confirm_pin": "Confirm PIN", - "pins_dont_match": "PINs don't match", - "forgot_pin": "Forgot PIN?", - "forgot_pin_desc": "Your saved credentials will be removed" + "enter_pin": "Pincode invoeren", + "enter_pin_for": "Pincode voor {{username}} invoeren", + "enter_4_digits": "Voer 6 cijfers in", + "invalid_pin": "Ongeldige pincode", + "setup_pin": "Pincode instellen", + "confirm_pin": "Pincode bevestigen", + "pins_dont_match": "Pincodes komen niet overeen", + "forgot_pin": "Pincode vergeten?", + "forgot_pin_desc": "Je opgeslagen inloggegevens worden verwijderd" }, "password": { - "enter_password": "Enter Password", - "enter_password_for": "Enter password for {{username}}", - "invalid_password": "Invalid password" + "enter_password": "Voer wachtwoord in", + "enter_password_for": "Voer wachtwoord voor {{username}} in", + "invalid_password": "Ongeldig wachtwoord" }, "home": { "checking_server_connection": "Serververbinding controleren...", @@ -84,7 +84,7 @@ "server_unreachable": "Server onbereikbaar", "server_unreachable_message": "Kon de server niet bereiken.\nControleer uw netwerkverbinding.", "oops": "Oeps!", - "error_message": "Er ging iets fout\nGelieve af en aan te melden.", + "error_message": "Er ging iets fout\nProbeer opnieuw in te loggen.", "continue_watching": "Verder Kijken", "next_up": "Volgende", "continue_and_next_up": "Doorgaan & Volgende", @@ -124,32 +124,32 @@ "hide_remote_session_button": "Verberg Knop voor Externe Sessie" }, "network": { - "title": "Network", - "local_network": "Local Network", - "auto_switch_enabled": "Auto-switch when at home", - "auto_switch_description": "Automatically switch to local URL when connected to home WiFi", - "local_url": "Local URL", - "local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)", + "title": "Netwerk", + "local_network": "Lokaal netwerk", + "auto_switch_enabled": "Automatisch wisselen wanneer thuis", + "auto_switch_description": "Automatisch wisselen naar lokale URL wanneer verbonden met thuisnetwerk", + "local_url": "Lokale URL", + "local_url_hint": "Voer uw lokale serveradres in (bijv. http://192.168.1.100:8096)", "local_url_placeholder": "http://192.168.1.100:8096", - "home_wifi_networks": "Home WiFi Networks", - "add_current_network": "Add \"{{ssid}}\"", - "not_connected_to_wifi": "Not connected to WiFi", - "no_networks_configured": "No networks configured", - "add_network_hint": "Add your home WiFi network to enable auto-switching", - "current_wifi": "Current WiFi", - "using_url": "Using", - "local": "Local URL", - "remote": "Remote URL", - "not_connected": "Not connected", - "current_server": "Current Server", - "remote_url": "Remote URL", - "active_url": "Active URL", - "not_configured": "Not configured", - "network_added": "Network added", - "network_already_added": "Network already added", - "no_wifi_connected": "Not connected to WiFi", - "permission_denied": "Location permission denied", - "permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings." + "home_wifi_networks": "Wi-Fi netwerken", + "add_current_network": "Voeg \"{{ssid}} \" toe", + "not_connected_to_wifi": "Niet verbonden met Wi-Fi", + "no_networks_configured": "Geen netwerken geconfigureerd", + "add_network_hint": "Voeg je thuisnetwerk toe om automatisch wisselen in te schakelen", + "current_wifi": "Huidige Wi-Fi", + "using_url": "Gebruik makend van", + "local": "Lokale URL", + "remote": "Externe URL", + "not_connected": "Niet verbonden", + "current_server": "Huidige Server", + "remote_url": "Externe URL", + "active_url": "Actieve URL", + "not_configured": "Niet geconfigureerd", + "network_added": "Netwerk toegevoegd", + "network_already_added": "Netwerk reeds toegevoegd", + "no_wifi_connected": "Niet verbonden met Wi-Fi", + "permission_denied": "Locatie toestemming geweigerd", + "permission_denied_explanation": "Locatie permissie is vereist om Wifi-netwerk te kunnen detecteren voor automatisch wisselen. Schakel het in via Instellingen." }, "user_info": { "user_info_title": "Gebruiker Info", @@ -195,11 +195,11 @@ "none": "Geen", "language": "Taal", "transcode_mode": { - "title": "Audio Transcoding", - "description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled", - "auto": "Auto", - "stereo": "Force Stereo", - "5_1": "Allow 5.1", + "title": "Audio-transcoding", + "description": "Bepaalt hoe surround audio (7.1, TrueHD, DTS-HD) wordt behandeld", + "auto": "Automatisch", + "stereo": "Stereo forceren", + "5_1": "5.1 toestaan", "passthrough": "Passthrough" } }, @@ -231,7 +231,7 @@ "Black": "Zwart", "Gray": "Grijs", "Silver": "Zilver", - "White": "wit", + "White": "Wit", "Maroon": "Kastanjebruin", "Red": "Rood", "Fuchsia": "Fuchsia", @@ -259,14 +259,14 @@ "hardware_decode_description": "Gebruik hardware acceleratie voor video-decodering. Uitschakelen als u problemen met afspelen ondervindt." }, "vlc_subtitles": { - "title": "VLC Subtitle Settings", - "hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.", - "text_color": "Text Color", - "background_color": "Background Color", - "background_opacity": "Background Opacity", - "outline_color": "Outline Color", - "outline_opacity": "Outline Opacity", - "outline_thickness": "Outline Thickness", + "title": "VLC ondertitel instellingen", + "hint": "Aanpassen van ondertiteling voor VLC-speler. Wijzigingen worden toegepast bij het afspelen.", + "text_color": "Tekstkleur", + "background_color": "Achtergrondkleur", + "background_opacity": "Doorzichtigheid achtergrond", + "outline_color": "Kleur omlijning", + "outline_opacity": "Omtrek opaciteit", + "outline_thickness": "Omtrek dikte", "bold": "Bold Text", "margin": "Bottom Margin" }, @@ -306,7 +306,7 @@ "disable_haptic_feedback": "Haptische feedback uitschakelen", "default_quality": "Standaard kwaliteit", "default_playback_speed": "Standaard Afspeelsnelheid", - "auto_play_next_episode": "Auto-play Next Episode", + "auto_play_next_episode": "Volgende aflevering automatisch afspelen", "max_auto_play_episode_count": "Max Automatisch Aflevering Aantal", "disabled": "Uitgeschakeld" }, @@ -378,12 +378,12 @@ "enable_promoted_watchlists": "Gepromote Kijklijst", "hide_watchlists_tab": "Hide Watchlists Tab", "home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.", - "recommended_movies": "Recommended Movies", - "recommended_series": "Recommended Series", + "recommended_movies": "Aanbevolen films", + "recommended_series": "Aanbevolen serie", "toasts": { - "saved": "Saved", + "saved": "Opgeslagen", "refreshed": "Settings refreshed from server", - "disabled": "Streamystats disabled" + "disabled": "Streamystats uitgeschakeld" }, "refresh_from_server": "Refresh Settings from Server" }, @@ -402,24 +402,24 @@ "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", "enable_music_cache": "Enable Music Cache", "clear_music_cache": "Clear Music Cache", - "music_cache_size": "{{size}} cached", - "music_cache_cleared": "Music cache cleared", - "delete_all_downloaded_songs": "Delete All Downloaded Songs", - "downloaded_songs_size": "{{size}} downloaded", + "music_cache_size": "{{size}} gecached", + "music_cache_cleared": "Muziek cache gewist", + "delete_all_downloaded_songs": "Verwijder alle gedownloade liedjes", + "downloaded_songs_size": "{{size}} gedownload", "downloaded_songs_deleted": "Downloaded songs deleted" }, "intro": { "title": "Intro", "show_intro": "Toon intro", - "reset_intro": "intro opnieuw instellen" + "reset_intro": "Reset Intro" }, "logs": { "logs_title": "Logboek", "export_logs": "Export logs", - "click_for_more_info": "Click for more info", + "click_for_more_info": "Klik voor meer info", "level": "Niveau", "no_logs_available": "Geen logs beschikbaar", - "delete_all_logs": "Verwijder alle logs" + "delete_all_logs": "Alle logs verwijderen" }, "languages": { "title": "Talen", @@ -500,14 +500,14 @@ "play": "Afspelen", "none": "Geen", "track": "Spoor", - "cancel": "Cancel", - "delete": "Delete", - "ok": "OK", - "remove": "Remove", - "next": "Next", - "back": "Back", - "continue": "Continue", - "verifying": "Verifying..." + "cancel": "Annuleren", + "delete": "Verwijderen", + "ok": "Oké", + "remove": "Verwijderen", + "next": "Volgende", + "back": "Terug", + "continue": "Doorgaan", + "verifying": "Verifiëren..." }, "search": { "search": "Zoek...", @@ -521,10 +521,10 @@ "episodes": "Afleveringen", "collections": "Collecties", "actors": "Acteurs", - "artists": "Artists", + "artists": "Artiesten", "albums": "Albums", - "songs": "Songs", - "playlists": "Playlists", + "songs": "Nummers", + "playlists": "Afspeellijsten", "request_movies": "Vraag films aan", "request_series": "Vraag series aan", "recently_added": "Recent Toegevoegd", @@ -572,7 +572,7 @@ "genres": "Genres", "years": "Jaren", "sort_by": "Sorteren op", - "filter_by": "Filter By", + "filter_by": "Filteren op", "sort_order": "Sorteer volgorde", "tags": "Labels" } @@ -719,127 +719,127 @@ "favorites": "Favorieten" }, "music": { - "title": "Music", + "title": "Muziek", "tabs": { - "suggestions": "Suggestions", + "suggestions": "Suggesties", "albums": "Albums", - "artists": "Artists", - "playlists": "Playlists", - "tracks": "tracks" + "artists": "Artiesten", + "playlists": "Afspeellijsten", + "tracks": "Nummers" }, "filters": { - "all": "All" + "all": "Alle" }, - "recently_added": "Recently Added", - "recently_played": "Recently Played", - "frequently_played": "Frequently Played", - "explore": "Explore", + "recently_added": "Recent toegevoegd", + "recently_played": "Onlangs afgespeeld", + "frequently_played": "Vaak afgespeeld", + "explore": "Ontdek", "top_tracks": "Top Tracks", - "play": "Play", + "play": "Afspelen", "shuffle": "Shuffle", "play_top_tracks": "Play Top Tracks", - "no_suggestions": "No suggestions available", - "no_albums": "No albums found", - "no_artists": "No artists found", - "no_playlists": "No playlists found", - "album_not_found": "Album not found", - "artist_not_found": "Artist not found", - "playlist_not_found": "Playlist not found", + "no_suggestions": "Geen suggesties beschikbaar", + "no_albums": "Geen albums gevonden", + "no_artists": "Geen artiesten gevonden", + "no_playlists": "Geen afspeellijsten gevonden", + "album_not_found": "Album niet gevonden", + "artist_not_found": "Artiest niet gevonden", + "playlist_not_found": "Afspeellijst niet gevonden", "track_options": { - "play_next": "Play Next", - "add_to_queue": "Add to Queue", - "add_to_playlist": "Add to Playlist", - "download": "Download", - "downloaded": "Downloaded", - "downloading": "Downloading...", - "cached": "Cached", - "delete_download": "Delete Download", - "delete_cache": "Remove from Cache", - "go_to_artist": "Go to Artist", - "go_to_album": "Go to Album", - "add_to_favorites": "Add to Favorites", - "remove_from_favorites": "Remove from Favorites", - "remove_from_playlist": "Remove from Playlist" + "play_next": "Speel volgende af", + "add_to_queue": "Toevoegen aan wachtrij", + "add_to_playlist": "Voeg toe aan afspeellijst", + "download": "Downloaden", + "downloaded": "Gedownload", + "downloading": "Downloaden...", + "cached": "Gecached", + "delete_download": "Download verwijderen", + "delete_cache": "Verwijderen uit cache", + "go_to_artist": "Ga naar artiest", + "go_to_album": "Ga naar album", + "add_to_favorites": "Toevoegen aan favorieten", + "remove_from_favorites": "Verwijderen uit favorieten", + "remove_from_playlist": "Verwijder uit afspeellijst" }, "playlists": { - "create_playlist": "Create Playlist", - "playlist_name": "Playlist Name", + "create_playlist": "Afspeellijst aanmaken", + "playlist_name": "Afspeellijst naam", "enter_name": "Enter playlist name", - "create": "Create", - "search_playlists": "Search playlists...", - "added_to": "Added to {{name}}", - "added": "Added to playlist", - "removed_from": "Removed from {{name}}", - "removed": "Removed from playlist", + "create": "Aanmaken", + "search_playlists": "Playlist zoeken...", + "added_to": "Toegevoegd aan {{name}}", + "added": "Toegevoegd aan playlist", + "removed_from": "Verwijderd uit {{name}}", + "removed": "Verwijderd uit playlist", "created": "Playlist created", "create_new": "Create New Playlist", "failed_to_add": "Failed to add to playlist", - "failed_to_remove": "Failed to remove from playlist", - "failed_to_create": "Failed to create playlist", - "delete_playlist": "Delete Playlist", - "delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", - "deleted": "Playlist deleted", - "failed_to_delete": "Failed to delete playlist" + "failed_to_remove": "Verwijderen uit afspeellijst is mislukt", + "failed_to_create": "Het maken van de afspeellijst is mislukt", + "delete_playlist": "Afspeellijst verwijderen", + "delete_confirm": "Weet u zeker dat u \"{{name}}\"wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.", + "deleted": "Afspeellijst verwijderd.", + "failed_to_delete": "Verwijderen van afspeellijst mislukt" }, "sort": { - "title": "Sort By", - "alphabetical": "Alphabetical", - "date_created": "Date Created" + "title": "Sorteren op", + "alphabetical": "Alfabetisch", + "date_created": "Aanmaakdatum" } }, "watchlists": { - "title": "Watchlists", - "my_watchlists": "My Watchlists", + "title": "Watchlist", + "my_watchlists": "Mijn watchlists", "public_watchlists": "Public Watchlists", "create_title": "Create Watchlist", "edit_title": "Edit Watchlist", "create_button": "Create Watchlist", - "save_button": "Save Changes", - "delete_button": "Delete", - "remove_button": "Remove", - "cancel_button": "Cancel", - "name_label": "Name", - "name_placeholder": "Enter watchlist name", - "description_label": "Description", - "description_placeholder": "Enter description (optional)", - "is_public_label": "Public Watchlist", - "is_public_description": "Allow others to view this watchlist", - "allowed_type_label": "Content Type", - "sort_order_label": "Default Sort Order", - "empty_title": "No Watchlists", - "empty_description": "Create your first watchlist to start organizing your media", - "empty_watchlist": "This watchlist is empty", - "empty_watchlist_hint": "Add items from your library to this watchlist", - "not_configured_title": "Streamystats Not Configured", - "not_configured_description": "Configure Streamystats in settings to use watchlists", - "go_to_settings": "Go to Settings", - "add_to_watchlist": "Add to Watchlist", - "remove_from_watchlist": "Remove from Watchlist", - "select_watchlist": "Select Watchlist", - "create_new": "Create New Watchlist", + "save_button": "Wijzigingen opslaan", + "delete_button": "Verwijder", + "remove_button": "Verwijderen", + "cancel_button": "Annuleren", + "name_label": "Naam", + "name_placeholder": "Voer naam van kijklijst in", + "description_label": "Beschrijving", + "description_placeholder": "Voer beschrijving in (optioneel)", + "is_public_label": "Openbare Kijklijst", + "is_public_description": "Sta anderen toe om deze kijklijst te bekijken", + "allowed_type_label": "Inhoudstype", + "sort_order_label": "Standaard Sortering", + "empty_title": "Geen Kijklijsten", + "empty_description": "Maak je eerste kijklijst om je media te organiseren", + "empty_watchlist": "Deze watchlist is leeg", + "empty_watchlist_hint": "Voeg items uit je bibliotheek toe aan deze kijklijst", + "not_configured_title": "Streamystats niet geconfigureerd", + "not_configured_description": "Configureer Streamystats in instellingen om kijklijsten te gebruiken", + "go_to_settings": "Ga naar Instellingen", + "add_to_watchlist": "Voeg toe aan kijklijst", + "remove_from_watchlist": "Verwijder van kijklijst", + "select_watchlist": "Selecteer kijklijst", + "create_new": "Nieuwe kijklijst aanmaken", "item": "item", "items": "items", - "public": "Public", - "private": "Private", - "you": "You", - "by_owner": "By another user", - "not_found": "Watchlist not found", - "delete_confirm_title": "Delete Watchlist", - "delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", - "remove_item_title": "Remove from Watchlist", - "remove_item_message": "Remove \"{{name}}\" from this watchlist?", - "loading": "Loading watchlists...", - "no_compatible_watchlists": "No compatible watchlists", - "create_one_first": "Create a watchlist that accepts this content type" + "public": "Publiek", + "private": "Privé", + "you": "Jij", + "by_owner": "Door een andere gebruiker", + "not_found": "Kijklijst niet gevonden", + "delete_confirm_title": "Verwijder kijklijst", + "delete_confirm_message": "Weet u zeker dat u \"{{name}}\"wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.", + "remove_item_title": "Verwijder van watchlist", + "remove_item_message": "Verwijder \"{{name}}\" uit deze watchlist?", + "loading": "Laden van watchlists...", + "no_compatible_watchlists": "Geen compatibele watchlist", + "create_one_first": "Maak een watchlist aan die dit inhoudstype accepteert" }, "playback_speed": { - "title": "Playback Speed", - "apply_to": "Apply To", - "speed": "Speed", + "title": "Afspeelsnelheid", + "apply_to": "Toepassen op", + "speed": "Snelheid", "scope": { - "media": "This media only", - "show": "This show", - "all": "All media (default)" + "media": "Alleen deze media", + "show": "Deze serie", + "all": "Alle media (standaard)" } } } diff --git a/translations/ru.json b/translations/ru.json index 17f526d5b..a49a5409c 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -34,44 +34,44 @@ "search_for_local_servers": "Поиск локальных серверов", "searching": "Поиск...", "servers": "Сервера", - "saved": "Saved", - "session_expired": "Session Expired", - "please_login_again": "Your saved session has expired. Please log in again.", - "remove_saved_login": "Remove Saved Login", - "remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.", - "accounts_count": "{{count}} accounts", - "select_account": "Select Account", - "add_account": "Add Account", - "remove_account_description": "This will remove the saved credentials for {{username}}." + "saved": "Сохранено", + "session_expired": "Сессия истекла", + "please_login_again": "Ваша сессия истекла. Пожалуйста, войдите снова.", + "remove_saved_login": "Удалить сохраненный аккаунт", + "remove_saved_login_description": "Ваши сохранённые данные для входа от этого сервера будут удалены. Вам придётся ввести ваши логин и пароль ещё раз.", + "accounts_count": "{{count}} аккаунтов", + "select_account": "Выбрать аккаунт", + "add_account": "Добавить аккаунт", + "remove_account_description": "Данные для входа {{username}} будут удалены." }, "save_account": { - "title": "Save Account", - "save_for_later": "Save this account", - "security_option": "Security Option", - "no_protection": "No protection", - "no_protection_desc": "Quick login without authentication", - "pin_code": "PIN code", - "pin_code_desc": "4-digit PIN required when switching", - "password": "Re-enter password", - "password_desc": "Password required when switching", - "save_button": "Save", - "cancel_button": "Cancel" + "title": "Сохранить аккаунт", + "save_for_later": "Сохранить этот аккаунт", + "security_option": "Опции безопасности", + "no_protection": "Без защиты", + "no_protection_desc": "Быстрый вход без ввода данных", + "pin_code": "PIN-код", + "pin_code_desc": "При переключении будет требоваться 4-значный PIN", + "password": "Пароль", + "password_desc": "При переключении будет требоваться пароль", + "save_button": "Сохранить", + "cancel_button": "Отмена" }, "pin": { - "enter_pin": "Enter PIN", - "enter_pin_for": "Enter PIN for {{username}}", - "enter_4_digits": "Enter 4 digits", - "invalid_pin": "Invalid PIN", - "setup_pin": "Set Up PIN", - "confirm_pin": "Confirm PIN", - "pins_dont_match": "PINs don't match", - "forgot_pin": "Forgot PIN?", - "forgot_pin_desc": "Your saved credentials will be removed" + "enter_pin": "Введите PIN", + "enter_pin_for": "Введите PIN для {{username}}", + "enter_4_digits": "Введите 4 цифры", + "invalid_pin": "Некорректный PIN", + "setup_pin": "Установить PIN", + "confirm_pin": "Подтвердите PIN", + "pins_dont_match": "PIN-коды не совпадают", + "forgot_pin": "Забыли PIN?", + "forgot_pin_desc": "Ваши данные для входа будут удалены" }, "password": { - "enter_password": "Enter Password", - "enter_password_for": "Enter password for {{username}}", - "invalid_password": "Invalid password" + "enter_password": "Введите пароль", + "enter_password_for": "Введите пароль для {{username}}", + "invalid_password": "Неверный пароль" }, "home": { "checking_server_connection": "Проверка соединения с сервером...", @@ -82,12 +82,12 @@ "go_to_downloads": "В загрузки", "retry": "Повторить", "server_unreachable": "Сервер недоступен", - "server_unreachable_message": "Could not reach the server.\nPlease check your network connection.", + "server_unreachable_message": "Не удалось соединиться с сервером.\nПожалуйста, проверьте настройки сети.", "oops": "Упс!", "error_message": "Что-то пошло не так.\nПожалуйста выйдите и зайдите снова.", - "continue_watching": "Продолжить просмотр", - "next_up": "Следующее", - "continue_and_next_up": "Continue & Next Up", + "continue_watching": "Продолжить", + "next_up": "Далее", + "continue_and_next_up": "Продолжить и Далее", "recently_added_in": "Недавно добавлено в {{libraryName}}", "suggested_movies": "Предложенные фильмы", "suggested_episodes": "Предложенные серии", @@ -110,46 +110,46 @@ "settings_title": "Настройки", "log_out_button": "Выйти", "categories": { - "title": "Categories" + "title": "Категории" }, "playback_controls": { - "title": "Playback & Controls" + "title": "Воспроизведение и управление" }, "audio_subtitles": { - "title": "Audio & Subtitles" + "title": "Аудио и субтитры" }, "appearance": { - "title": "Appearance", - "merge_next_up_continue_watching": "Merge Continue Watching & Next Up", - "hide_remote_session_button": "Hide Remote Session Button" + "title": "Внешний вид", + "merge_next_up_continue_watching": "Объединить «Продолжить» и «Далее»", + "hide_remote_session_button": "Скрыть кнопку «Удалённый сеанс»" }, "network": { - "title": "Network", - "local_network": "Local Network", - "auto_switch_enabled": "Auto-switch when at home", - "auto_switch_description": "Automatically switch to local URL when connected to home WiFi", - "local_url": "Local URL", - "local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)", + "title": "Сеть", + "local_network": "Локальная сеть", + "auto_switch_enabled": "Переключаться дома автоматически", + "auto_switch_description": "Автоматически переключаться на локальный URL при присоединении к домашней WiFi сети", + "local_url": "Локальный URL", + "local_url_hint": "Введите локальный URL вашего сервера (e.g., http://192.168.1.100:8096)", "local_url_placeholder": "http://192.168.1.100:8096", - "home_wifi_networks": "Home WiFi Networks", - "add_current_network": "Add \"{{ssid}}\"", - "not_connected_to_wifi": "Not connected to WiFi", - "no_networks_configured": "No networks configured", - "add_network_hint": "Add your home WiFi network to enable auto-switching", - "current_wifi": "Current WiFi", - "using_url": "Using", - "local": "Local URL", - "remote": "Remote URL", - "not_connected": "Not connected", - "current_server": "Current Server", - "remote_url": "Remote URL", - "active_url": "Active URL", - "not_configured": "Not configured", - "network_added": "Network added", - "network_already_added": "Network already added", - "no_wifi_connected": "Not connected to WiFi", - "permission_denied": "Location permission denied", - "permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings." + "home_wifi_networks": "Домашние WiFi сети", + "add_current_network": "Добавить \"{{ssid}}\"", + "not_connected_to_wifi": "Нет WiFi соединения", + "no_networks_configured": "Нет настроенных сетей", + "add_network_hint": "Добавьте вашу домашнюю сеть WiFi для включения автоматического переключения", + "current_wifi": "Текущая WiFi сеть", + "using_url": "Используется", + "local": "Локальный", + "remote": "Внешний", + "not_connected": "Нет соединения", + "current_server": "Текущий сервер", + "remote_url": "Внешний URL", + "active_url": "Активный URL", + "not_configured": "Не настроено", + "network_added": "Сеть добавлена", + "network_already_added": "Сеть уже добавлена", + "no_wifi_connected": "Нет WiFi соединения", + "permission_denied": "Нет доступа к местоположению", + "permission_denied_explanation": "Разрешение на доступ к местоположению обязательно для обнаружения WiFi сети при автоматическом переключении. Пожалуйста, включите его в настройках." }, "user_info": { "user_info_title": "Информация о пользователе", @@ -170,22 +170,22 @@ }, "media_controls": { "media_controls_title": "Медиа-контроль", - "forward_skip_length": "Длина пропуска вперед", - "rewind_length": "Длина перемотки", + "forward_skip_length": "Шаг перемотки вперёд", + "rewind_length": "Шаг перемотки назад", "seconds_unit": "c" }, "gesture_controls": { "gesture_controls_title": "Управление жестами", - "horizontal_swipe_skip": "Горизонтальный свайп, чтобы пропустить", + "horizontal_swipe_skip": "Горизонтальный свайп для перемотки", "horizontal_swipe_skip_description": "Проведите влево/вправо, когда элементы управления скрыты, чтобы пропустить", "left_side_brightness": "Управление яркостью левой стороны", "left_side_brightness_description": "Смахните вверх/вниз на левой стороне для настройки яркости", "right_side_volume": "Управление громкостью справа", "right_side_volume_description": "Свайп вверх/вниз с правой стороны для настройки громкости", - "hide_volume_slider": "Hide Volume Slider", - "hide_volume_slider_description": "Hide the volume slider in the video player", - "hide_brightness_slider": "Hide Brightness Slider", - "hide_brightness_slider_description": "Hide the brightness slider in the video player" + "hide_volume_slider": "Скрыть индикатор громкости", + "hide_volume_slider_description": "Скрывает индикатор громкости в плеере", + "hide_brightness_slider": "Скрыть индикатор яркости", + "hide_brightness_slider_description": "Скрывает индикатор яркости в плеере" }, "audio": { "audio_title": "Аудио", @@ -195,17 +195,17 @@ "none": "Отсутствует", "language": "Язык", "transcode_mode": { - "title": "Audio Transcoding", - "description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled", - "auto": "Auto", - "stereo": "Force Stereo", - "5_1": "Allow 5.1", - "passthrough": "Passthrough" + "title": "Перекодировка аудио", + "description": "Управляет обработкой пространственного звука (7.1, TrueHD, DTS-HD)", + "auto": "Авто", + "stereo": "Принудительно в стерео", + "5_1": "Разрешить 5.1", + "passthrough": "Ничего не изменять" } }, "subtitles": { "subtitle_title": "Субтитры", - "subtitle_hint": "Настроить субтитры.", + "subtitle_hint": "Настройки отображения субтитров", "subtitle_language": "Язык субтитров", "subtitle_mode": "Режим субтитров", "set_subtitle_track": "Устанавливать субтитры из предыдущего элемента", @@ -226,24 +226,24 @@ "outline_thickness": "Толщина контура", "background_opacity": "Прозрачность фона", "outline_opacity": "Прозрачность контура", - "bold_text": "Bold Text", + "bold_text": "Жирный", "colors": { "Black": "Черный", "Gray": "Серый", - "Silver": "Серебряный", + "Silver": "Серебристый", "White": "Белый", - "Maroon": "Марун", + "Maroon": "Бордовый", "Red": "Красный", - "Fuchsia": "Fuchsia", + "Fuchsia": "Пурпурный", "Yellow": "Жёлтый", - "Olive": "Олив", + "Olive": "Оливковый", "Green": "Зелёный", "Teal": "Бирюзовый", "Lime": "Лаймовый", "Purple": "Фиолетовый", "Navy": "Тёмно-синий", "Blue": "Синий", - "Aqua": "Акваа" + "Aqua": "Голубой" }, "thickness": { "None": "Отсутствует", @@ -251,29 +251,29 @@ "Normal": "Обычный", "Thick": "Толстый" }, - "subtitle_color": "Subtitle Color", - "subtitle_background_color": "Background Color", - "subtitle_font": "Subtitle Font", - "ksplayer_title": "KSPlayer Settings", - "hardware_decode": "Hardware Decoding", - "hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues." + "subtitle_color": "Цвет субтитров", + "subtitle_background_color": "Цвет фона", + "subtitle_font": "Шрифт субтитров", + "ksplayer_title": "Настройки KSPlayer", + "hardware_decode": "Аппаратное декодирование", + "hardware_decode_description": "Использовать аппаратное ускорение для декодирования видео. Выключите, если наблюдаете проблемы с воспроизведением." }, "vlc_subtitles": { - "title": "VLC Subtitle Settings", - "hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.", - "text_color": "Text Color", - "background_color": "Background Color", - "background_opacity": "Background Opacity", - "outline_color": "Outline Color", - "outline_opacity": "Outline Opacity", - "outline_thickness": "Outline Thickness", - "bold": "Bold Text", - "margin": "Bottom Margin" + "title": "Настройки субтитров в VLC", + "hint": "Настройте внешний вид субтитров в VLC плеере. Изменения применятся при следующем воспроизведении.", + "text_color": "Цвет текста", + "background_color": "Цвет фона", + "background_opacity": "Прозрачность фона", + "outline_color": "Цвет контура", + "outline_opacity": "Прозрачность контура", + "outline_thickness": "Толщина контура", + "bold": "Жирный", + "margin": "Отступ снизу" }, "video_player": { - "title": "Video Player", - "video_player": "Video Player", - "video_player_description": "Choose which video player to use on iOS.", + "title": "Видеоплеер", + "video_player": "Видеоплеер", + "video_player_description": "Выберите видеоплеер в iOS.", "ksplayer": "KSPlayer", "vlc": "VLC" }, @@ -294,19 +294,19 @@ "UNKNOWN": "Неизвестное" }, "safe_area_in_controls": "Безопасная зона в элементах управления", - "video_player": "Видео прейер", + "video_player": "Видеоплеер", "video_players": { "VLC_3": "VLC 3", "VLC_4": "VLC 4 (Экспериментальный + PiP)" }, "show_custom_menu_links": "Показать ссылки кастомного меню", - "show_large_home_carousel": "Show Large Home Carousel (beta)", + "show_large_home_carousel": "Показывать большую карусель (beta)", "hide_libraries": "Скрыть библиотеки", "select_liraries_you_want_to_hide": "Выберите Библиотеки, которое хотите спрятать из вкладки Библиотеки и домашней страницы.", "disable_haptic_feedback": "Отключить тактильную обратную связь", "default_quality": "Качество по умолчанию", - "default_playback_speed": "Default Playback Speed", - "auto_play_next_episode": "Auto-play Next Episode", + "default_playback_speed": "Скорость воспроизведения по умолчанию", + "auto_play_next_episode": "Автоматически воспроизводить следующий эпизод", "max_auto_play_episode_count": "Максимальное количество автовоспроизведения эпизодов", "disabled": "Отключено" }, @@ -314,15 +314,15 @@ "downloads_title": "Загрузки" }, "music": { - "title": "Music", - "playback_title": "Playback", - "playback_description": "Configure how music is played.", - "prefer_downloaded": "Prefer Downloaded Songs", - "caching_title": "Caching", - "caching_description": "Automatically cache upcoming tracks for smoother playback.", - "lookahead_enabled": "Enable Look-Ahead Caching", - "lookahead_count": "Tracks to Pre-cache", - "max_cache_size": "Max Cache Size" + "title": "Музыка", + "playback_title": "Воспроизведение", + "playback_description": "Настройте воспроизведение музыки.", + "prefer_downloaded": "Предпочитать скачанные песни", + "caching_title": "Кеширование", + "caching_description": "Автоматически предкешировать следующие треки для стабильного воспроизведения.", + "lookahead_enabled": "Включить предкеширование", + "lookahead_count": "Сколько предкешировать", + "max_cache_size": "Максимальное число предкешированных треков" }, "plugins": { "plugins_title": "Плагины", @@ -357,39 +357,39 @@ "save_button": "Сохранить", "toasts": { "saved": "Сохранено", - "refreshed": "Settings refreshed from server" + "refreshed": "Настройки обновлены с сервера" }, - "refresh_from_server": "Refresh Settings from Server" + "refresh_from_server": "Обновить настройки с сервера" }, "streamystats": { - "enable_streamystats": "Enable Streamystats", - "disable_streamystats": "Disable Streamystats", - "enable_search": "Use for Search", + "enable_streamystats": "Включить Streamystats", + "disable_streamystats": "Выключить Streamystats", + "enable_search": "Использовать в поиске", "url": "URL", "server_url_placeholder": "http(s)://streamystats.example.com", - "streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.", - "read_more_about_streamystats": "Read More About Streamystats.", - "save_button": "Save", - "save": "Save", - "features_title": "Features", - "home_sections_title": "Home Sections", - "enable_movie_recommendations": "Movie Recommendations", - "enable_series_recommendations": "Series Recommendations", - "enable_promoted_watchlists": "Promoted Watchlists", - "hide_watchlists_tab": "Hide Watchlists Tab", - "home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.", - "recommended_movies": "Recommended Movies", - "recommended_series": "Recommended Series", + "streamystats_search_hint": "Введите URL вашего сервера Streamystats. URL должен включать http/https и порт при необходимости.", + "read_more_about_streamystats": "Узнать больше про Streamystats.", + "save_button": "Сохранить", + "save": "Сохранить", + "features_title": "Функции", + "home_sections_title": "Показывать на главной", + "enable_movie_recommendations": "Рекомендации фильмов", + "enable_series_recommendations": "Рекомендации сериалов", + "enable_promoted_watchlists": "Продвигаемые списки просмотра", + "hide_watchlists_tab": "Скрыть вкладку со списками", + "home_sections_hint": "Показывать персонализированные рекомендации и подходящие списки просмотров из Streamystats на главной странице.", + "recommended_movies": "Рекомендованные фильмы", + "recommended_series": "Рекомендованные сериалы", "toasts": { - "saved": "Saved", - "refreshed": "Settings refreshed from server", - "disabled": "Streamystats disabled" + "saved": "Сохранено", + "refreshed": "Настройки обновлены с сервера", + "disabled": "Streamystats отключен" }, - "refresh_from_server": "Refresh Settings from Server" + "refresh_from_server": "Обновить настройки с сервера" }, "kefinTweaks": { - "watchlist_enabler": "Enable our Watchlist integration", - "watchlist_button": "Toggle Watchlist integration" + "watchlist_enabler": "Включить интеграцию со списками просмотра", + "watchlist_button": "Изменить интеграцию со списками просмотра" } }, "storage": { @@ -398,18 +398,18 @@ "device_usage": "Устройство {{availableSpace}}%", "size_used": "{{used}} из {{total}} использовано", "delete_all_downloaded_files": "Удалить все загруженные файлы", - "music_cache_title": "Music Cache", - "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", - "enable_music_cache": "Enable Music Cache", - "clear_music_cache": "Clear Music Cache", - "music_cache_size": "{{size}} cached", - "music_cache_cleared": "Music cache cleared", - "delete_all_downloaded_songs": "Delete All Downloaded Songs", - "downloaded_songs_size": "{{size}} downloaded", - "downloaded_songs_deleted": "Downloaded songs deleted" + "music_cache_title": "Кеш музыки", + "music_cache_description": "Автоматически прекешировать песни по мере прослушивания для плавного воспроизведения и поддержки отсутствия интернета", + "enable_music_cache": "Кешировать музыку", + "clear_music_cache": "Очистить кеш музыки", + "music_cache_size": "{{size}} кешировано", + "music_cache_cleared": "Кеш музыки очищен", + "delete_all_downloaded_songs": "Удалить все скачанные песни", + "downloaded_songs_size": "{{size}} скачано", + "downloaded_songs_deleted": "Скачанные песни удалены" }, "intro": { - "title": "Intro", + "title": "Вступление", "show_intro": "Показать вступление", "reset_intro": "Сбросить вступление" }, @@ -441,24 +441,24 @@ "tvseries": "Сериалы", "movies": "Фильмы", "queue": "Очередь", - "other_media": "Другие медиа", - "queue_hint": "Очередь и загрузки будут удалены при перезагрузке приложения", + "other_media": "Прочие файлы", + "queue_hint": "Очередь очистится после перезапуска", "no_items_in_queue": "Нет элементов в очереди", - "no_downloaded_items": "Нет загруженых предметов", + "no_downloaded_items": "Нет загруженных файлов", "delete_all_movies_button": "Удалить все фильмы", "delete_all_tvseries_button": "Удалить все сериалы", "delete_all_button": "Удалить все", - "delete_all_other_media_button": "Удалить другой материал", - "active_download": "Активно загружается", + "delete_all_other_media_button": "Удалить прочие файлы", + "active_download": "Загружается", "no_active_downloads": "Нет активных загрузок", - "active_downloads": "Активные загрузки", + "active_downloads": "Активные", "new_app_version_requires_re_download": "Новая версия приложения требует повторной загрузки", "new_app_version_requires_re_download_description": "Новая версия приложения требует повторной загрузки. Пожалуйста удалите всё и попробуйте заново.", "back": "Назад", "delete": "Удалить", "something_went_wrong": "Что-то пошло не так", "could_not_get_stream_url_from_jellyfin": "Не удалось получить ссылку трансляции из Jellyfin", - "eta": "ETA {{eta}}", + "eta": "Осталось {{eta}}", "toasts": { "you_are_not_allowed_to_download_files": "Нет разрешения на скачивание файлов.", "deleted_all_movies_successfully": "Все фильмы были успешно удалены!", @@ -467,64 +467,64 @@ "failed_to_delete_all_tvseries": "Возникла ошибка при удалении всех сериалов", "deleted_media_successfully": "Другие носители успешно удалены!", "failed_to_delete_media": "Не удалось удалить другой файл", - "download_deleted": "Загрузка удалена", + "download_deleted": "Удалено", "download_cancelled": "Загрузка отменена", "could_not_delete_download": "Не удалось удалить загрузку", - "download_paused": "Загрузка приостановлена", + "download_paused": "На паузе", "could_not_pause_download": "Не удалось приостановить загрузку", - "download_resumed": "Загрузка возобновлена", + "download_resumed": "Продолжено", "could_not_resume_download": "Не удалось продолжить загрузку", - "download_completed": "Загрузка завершена", - "download_failed": "Download Failed", + "download_completed": "Завершено", + "download_failed": "Не удалось загрузить", "download_failed_for_item": "Загрузка {{item}} провалилась с ошибкой: {{error}}", "download_completed_for_item": "{{item}} успешно загружен", "download_started_for_item": "Загрузка началась для {{item}}", "failed_to_start_download": "Не удалось начать загрузку", - "item_already_downloading": "{{item}} is already downloading", - "all_files_deleted": "All Downloads Deleted Successfully", - "files_deleted_by_type": "{{count}} {{type}} deleted", + "item_already_downloading": "{{item}} уже загружается", + "all_files_deleted": "Все загрузки удалены", + "files_deleted_by_type": "{{count}} {{type}} удалён(о)", "all_files_folders_and_jobs_deleted_successfully": "Все файлы, папки, и задачи были успешно удалены", "failed_to_clean_cache_directory": "Не удалось очистить директорию кэша", "could_not_get_download_url_for_item": "Не удалось получить URL загрузки для {{itemName}}", "go_to_downloads": "В загрузки", - "file_deleted": "{{item}} deleted" + "file_deleted": "{{item}} удалён" } } }, "common": { "select": "Выбрать", - "no_trailer_available": "Прицеп недоступен", + "no_trailer_available": "Трейлер недоступен", "video": "Видео", "audio": "Звук", "subtitle": "Субтитры", - "play": "Играть", - "none": "None", - "track": "Track", - "cancel": "Cancel", - "delete": "Delete", - "ok": "OK", - "remove": "Remove", - "next": "Next", - "back": "Back", - "continue": "Continue", - "verifying": "Verifying..." + "play": "Воспроизвести", + "none": "Отсутствует", + "track": "Трек", + "cancel": "Отмена", + "delete": "Удалить", + "ok": "ОК", + "remove": "Удалить", + "next": "Вперед", + "back": "Назад", + "continue": "Продолжить", + "verifying": "Проверка..." }, "search": { "search": "Поиск...", - "x_items": "{{count}} предметов", + "x_items": "{{count}} элементов", "library": "Библиотека", "discover": "Найти новое", - "no_results": "Нет результатов", - "no_results_found_for": "Не было результатов при поиске", + "no_results": "Ничего не найдено", + "no_results_found_for": "Ничего не найдено по запросу", "movies": "Фильмы", "series": "Сериалы", "episodes": "Серии", "collections": "Коллекции", "actors": "Актеры", - "artists": "Artists", - "albums": "Albums", - "songs": "Songs", - "playlists": "Playlists", + "artists": "Артисты", + "albums": "Альбомы", + "songs": "Песни", + "playlists": "Плейлисты", "request_movies": "Запросить фильмы", "request_series": "Запросить сериалы", "recently_added": "Недавно добавлено", @@ -553,7 +553,7 @@ "no_results": "Нет результатов", "no_libraries_found": "Библиотеки не найдены", "item_types": { - "movies": "фильмы", + "movies": "Фильмы", "series": "Сериалы", "boxsets": "Коллекции", "items": "элементы" @@ -571,9 +571,9 @@ "filters": { "genres": "Жанры", "years": "Года", - "sort_by": "Сортировать по", - "filter_by": "Filter By", - "sort_order": "Порядок сортировки", + "sort_by": "Сортировка", + "filter_by": "Фильтр", + "sort_order": "Порядок", "tags": "Тэги" } }, @@ -604,14 +604,14 @@ "index": "Индекс:", "continue_watching": "Продолжить просмотр", "go_back": "Назад", - "downloaded_file_title": "You have this file downloaded", - "downloaded_file_message": "Do you want to play the downloaded file?", - "downloaded_file_yes": "Yes", - "downloaded_file_no": "No", - "downloaded_file_cancel": "Cancel" + "downloaded_file_title": "Этот файл уже скачан", + "downloaded_file_message": "Хотите воспроизвести скачанный файл?", + "downloaded_file_yes": "Да", + "downloaded_file_no": "Нет", + "downloaded_file_cancel": "Отмена" }, "item_card": { - "next_up": "Следующее", + "next_up": "Далее", "no_items_to_display": "Нет элементов для отображения", "cast_and_crew": "Актеры и съемочная группа", "series": "Серии", @@ -644,7 +644,7 @@ } }, "live_tv": { - "next": "Следующая", + "next": "Далее", "previous": "Предыдущая", "coming_soon": "Скоро", "on_now": "Сейчас в эфире", @@ -675,7 +675,7 @@ "series_type": "Тип сериала", "release_dates": "Дата релиза", "first_air_date": "Первая дата выхода в эфир", - "next_air_date": "Следующая дата выхода в эфир", + "next_air_date": "Ближайшая дата выхода в эфир", "revenue": "Прибыль", "budget": "Бюджет", "original_language": "Оригинальный язык", @@ -693,10 +693,10 @@ "number_episodes": "{{episode_number}} серий", "born": "Рожден", "appearances": "Появления", - "approve": "Approve", - "decline": "Decline", - "requested_by": "Requested by {{user}}", - "unknown_user": "Unknown User", + "approve": "Одобрить", + "decline": "Отклонить", + "requested_by": "Запрошено {{user}}", + "unknown_user": "Неизвестный пользователь", "toasts": { "jellyseer_does_not_meet_requirements": "Сервер Jellyseerr не соответствует минимальным требованиям версии! Пожалуйста, обновите до версии не ниже 2.0.0", "jellyseerr_test_failed": "Тест Jellyseerr не пройден. Попробуйте еще раз.", @@ -705,141 +705,141 @@ "requested_item": "Запрошено {{item}}!", "you_dont_have_permission_to_request": "У вас нет разрешения на запрос!", "something_went_wrong_requesting_media": "Что-то пошло не так при запросе медиафайлов!", - "request_approved": "Request Approved!", - "request_declined": "Request Declined!", - "failed_to_approve_request": "Failed to Approve Request", - "failed_to_decline_request": "Failed to Decline Request" + "request_approved": "Запрос одобрен!", + "request_declined": "Запрос отклонён!", + "failed_to_approve_request": "Не удалось одобрить запрос", + "failed_to_decline_request": "Не удалось отклонить запрос" } }, "tabs": { - "home": "Дом", + "home": "Главная", "search": "Поиск", "library": "Библиотека", - "custom_links": "Кастомные ссылки", + "custom_links": "Ссылки", "favorites": "Избранное" }, "music": { - "title": "Music", + "title": "Музыка", "tabs": { - "suggestions": "Suggestions", - "albums": "Albums", - "artists": "Artists", - "playlists": "Playlists", - "tracks": "tracks" + "suggestions": "Рекомендации", + "albums": "Альбомы", + "artists": "Исполнители", + "playlists": "Плейлисты", + "tracks": "треки" }, "filters": { - "all": "All" + "all": "Все" }, - "recently_added": "Recently Added", - "recently_played": "Recently Played", - "frequently_played": "Frequently Played", - "explore": "Explore", - "top_tracks": "Top Tracks", - "play": "Play", - "shuffle": "Shuffle", - "play_top_tracks": "Play Top Tracks", - "no_suggestions": "No suggestions available", - "no_albums": "No albums found", - "no_artists": "No artists found", - "no_playlists": "No playlists found", - "album_not_found": "Album not found", - "artist_not_found": "Artist not found", - "playlist_not_found": "Playlist not found", + "recently_added": "Недавно добавлено", + "recently_played": "Недавно воспроизведено", + "frequently_played": "Часто играет", + "explore": "Найти новое", + "top_tracks": "Топ", + "play": "Воспроизвести", + "shuffle": "Перемешать", + "play_top_tracks": "Воспроизвести топ", + "no_suggestions": "Нет рекомендаций", + "no_albums": "Альбомы не найдены", + "no_artists": "Исполнители не найдены", + "no_playlists": "Плейлисты не найдены", + "album_not_found": "Альбом не найден", + "artist_not_found": "Исполнитель не найден", + "playlist_not_found": "Плейлист не найден", "track_options": { - "play_next": "Play Next", - "add_to_queue": "Add to Queue", - "add_to_playlist": "Add to Playlist", - "download": "Download", - "downloaded": "Downloaded", - "downloading": "Downloading...", - "cached": "Cached", - "delete_download": "Delete Download", - "delete_cache": "Remove from Cache", - "go_to_artist": "Go to Artist", - "go_to_album": "Go to Album", - "add_to_favorites": "Add to Favorites", - "remove_from_favorites": "Remove from Favorites", - "remove_from_playlist": "Remove from Playlist" + "play_next": "Далее", + "add_to_queue": "Добавить в очередь", + "add_to_playlist": "Добавить в плейлист", + "download": "Скачать", + "downloaded": "Скачано", + "downloading": "Скачивается...", + "cached": "Кешировано", + "delete_download": "Удалить загрузку", + "delete_cache": "Удалить из кеша", + "go_to_artist": "К исполнителю", + "go_to_album": "К альбому", + "add_to_favorites": "В избранное", + "remove_from_favorites": "Удалить из избранного", + "remove_from_playlist": "Удалить из плейлиста" }, "playlists": { - "create_playlist": "Create Playlist", - "playlist_name": "Playlist Name", - "enter_name": "Enter playlist name", - "create": "Create", - "search_playlists": "Search playlists...", - "added_to": "Added to {{name}}", - "added": "Added to playlist", - "removed_from": "Removed from {{name}}", - "removed": "Removed from playlist", - "created": "Playlist created", - "create_new": "Create New Playlist", - "failed_to_add": "Failed to add to playlist", - "failed_to_remove": "Failed to remove from playlist", - "failed_to_create": "Failed to create playlist", - "delete_playlist": "Delete Playlist", - "delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", - "deleted": "Playlist deleted", - "failed_to_delete": "Failed to delete playlist" + "create_playlist": "Создать плейлист", + "playlist_name": "Название плейлиста", + "enter_name": "Введите название плейлиста", + "create": "Создать", + "search_playlists": "Поиск плейлистов...", + "added_to": "Добавлено в {{name}}", + "added": "Добавлено в плейлист", + "removed_from": "Удалено из {{name}}", + "removed": "Удалено из плейлиста", + "created": "Плейлист создан", + "create_new": "Добавить новый плейлист", + "failed_to_add": "Не удалось добавить в плейлист", + "failed_to_remove": "Не удалось удалить из плейлиста", + "failed_to_create": "Не удалось создать плейлист", + "delete_playlist": "Удалить плейлист", + "delete_confirm": "Вы уверены, что хотите удалить \"{{name}}\"? Это действие необратимо.", + "deleted": "Плейлист удалён", + "failed_to_delete": "Не удалось удалить плейлист" }, "sort": { - "title": "Sort By", - "alphabetical": "Alphabetical", - "date_created": "Date Created" + "title": "Сортировка", + "alphabetical": "По алфавиту", + "date_created": "По дате создания" } }, "watchlists": { - "title": "Watchlists", - "my_watchlists": "My Watchlists", - "public_watchlists": "Public Watchlists", - "create_title": "Create Watchlist", - "edit_title": "Edit Watchlist", - "create_button": "Create Watchlist", - "save_button": "Save Changes", - "delete_button": "Delete", - "remove_button": "Remove", - "cancel_button": "Cancel", - "name_label": "Name", - "name_placeholder": "Enter watchlist name", - "description_label": "Description", - "description_placeholder": "Enter description (optional)", - "is_public_label": "Public Watchlist", - "is_public_description": "Allow others to view this watchlist", - "allowed_type_label": "Content Type", - "sort_order_label": "Default Sort Order", - "empty_title": "No Watchlists", - "empty_description": "Create your first watchlist to start organizing your media", - "empty_watchlist": "This watchlist is empty", - "empty_watchlist_hint": "Add items from your library to this watchlist", - "not_configured_title": "Streamystats Not Configured", - "not_configured_description": "Configure Streamystats in settings to use watchlists", - "go_to_settings": "Go to Settings", - "add_to_watchlist": "Add to Watchlist", - "remove_from_watchlist": "Remove from Watchlist", - "select_watchlist": "Select Watchlist", - "create_new": "Create New Watchlist", - "item": "item", - "items": "items", - "public": "Public", - "private": "Private", - "you": "You", - "by_owner": "By another user", - "not_found": "Watchlist not found", - "delete_confirm_title": "Delete Watchlist", - "delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", - "remove_item_title": "Remove from Watchlist", - "remove_item_message": "Remove \"{{name}}\" from this watchlist?", - "loading": "Loading watchlists...", - "no_compatible_watchlists": "No compatible watchlists", - "create_one_first": "Create a watchlist that accepts this content type" + "title": "Списки просмотров", + "my_watchlists": "Мои списки", + "public_watchlists": "Публичные списки", + "create_title": "Создать список", + "edit_title": "Редактировать список", + "create_button": "Создать список", + "save_button": "Сохранить изменения", + "delete_button": "Удалить", + "remove_button": "Удалить", + "cancel_button": "Отмена", + "name_label": "Название", + "name_placeholder": "Введите название списка", + "description_label": "Описание", + "description_placeholder": "Введите описание (не обязательно)", + "is_public_label": "Публичный", + "is_public_description": "Разрешить остальным пользователям видеть этот список", + "allowed_type_label": "Тип контента", + "sort_order_label": "Сортировка по умолчанию", + "empty_title": "Нет списков", + "empty_description": "Создайте ваш первый список для управления вашими медиа", + "empty_watchlist": "Этот список пуст", + "empty_watchlist_hint": "Добавляйте элементы из библиотеки в этот список", + "not_configured_title": "Streamystats не настроен", + "not_configured_description": "Настройте Streamystats для использования функционала списков", + "go_to_settings": "В настройки", + "add_to_watchlist": "Добавить в список просмотра", + "remove_from_watchlist": "Удалить из списка просмотра", + "select_watchlist": "Выбрать список", + "create_new": "Создать новый список", + "item": "элемент", + "items": "элементы", + "public": "Публичный", + "private": "Личный", + "you": "Ваш", + "by_owner": "Другим пользователем", + "not_found": "Список не найден", + "delete_confirm_title": "Удалить список", + "delete_confirm_message": "Вы уверены, что хотите удалить список \"{{name}}\"? Это действие необратимо.", + "remove_item_title": "Удалить из списка", + "remove_item_message": "Удалить \"{{name}}\" из списка?", + "loading": "Загрузка списков...", + "no_compatible_watchlists": "Нет совместимых списков", + "create_one_first": "Создайте список просмотра с подходящим типом контента" }, "playback_speed": { - "title": "Playback Speed", - "apply_to": "Apply To", - "speed": "Speed", + "title": "Скорость воспроизведения", + "apply_to": "Применять к", + "speed": "Скорость", "scope": { - "media": "This media only", - "show": "This show", - "all": "All media (default)" + "media": "Только в этот раз", + "show": "Ко всему сериалу", + "all": "Ко всем файлам (по умолчанию)" } } } diff --git a/translations/sv.json b/translations/sv.json index 6614d1426..2c95fe776 100644 --- a/translations/sv.json +++ b/translations/sv.json @@ -42,7 +42,9 @@ "accounts_count": "{{count}} konton", "select_account": "Välj konto", "add_account": "Lägg till konto", - "remove_account_description": "Detta kommer att ta bort de sparade uppgifterna för {{username}}." + "remove_account_description": "Detta kommer att ta bort de sparade uppgifterna för {{username}}.", + "remove_server": "Ta bort server", + "remove_server_description": "Detta kommer att ta bort {{server}} och alla sparade konton från din lista." }, "save_account": { "title": "Spara konto", @@ -86,6 +88,7 @@ "oops": "Hoppsan!", "error_message": "Något gick fel.\nLogga ut och in igen.", "continue_watching": "Fortsätt titta", + "continue": "Fortsätt", "next_up": "Näst på tur", "continue_and_next_up": "Fortsätt titta & nästa avsnitt", "recently_added_in": "Nyligen tillagt i {{libraryName}}", @@ -121,7 +124,16 @@ "appearance": { "title": "Utseende", "merge_next_up_continue_watching": "Slå ihop Fortsätt titta och nästa avsnitt", - "hide_remote_session_button": "Dölj fjärrsessionsknapp" + "hide_remote_session_button": "Dölj fjärrsessionsknapp", + "show_home_backdrop": "Dynamisk hembakgrund", + "show_hero_carousel": "Hjältekarusell", + "show_series_poster_on_episode": "Visa serieaffisch på avsnitt", + "display_size": "Visningsstorlek", + "display_size_small": "Liten", + "display_size_default": "Standard", + "display_size_large": "Stor", + "display_size_extra_large": "Extra stor", + "theme_music": "Temamusik" }, "network": { "title": "Nätverk", @@ -174,6 +186,16 @@ "rewind_length": "Bakåthoppsintervall", "seconds_unit": "s" }, + "buffer": { + "title": "Bufferinställningar", + "cache_mode": "Cacheläge", + "cache_auto": "Auto", + "cache_yes": "Aktiverad", + "cache_no": "Inaktiverad", + "buffer_duration": "Buffertlängd", + "max_cache_size": "Max cachestorlek", + "max_backward_cache": "Max bakåtcache" + }, "gesture_controls": { "gesture_controls_title": "Gestkontroller", "horizontal_swipe_skip": "Horisontell Svepning för att Hoppa Fram/Bak", @@ -256,7 +278,23 @@ "subtitle_font": "Typsnitt för undertexter", "ksplayer_title": "KSPlayer-inställningar", "hardware_decode": "Hårdvaruavkodning", - "hardware_decode_description": "Använd hårdvaruacceleration för videoavkodning. Inaktivera om du upplever uppspelningsproblem." + "hardware_decode_description": "Använd hårdvaruacceleration för videoavkodning. Inaktivera om du upplever uppspelningsproblem.", + "opensubtitles_title": "OpenSubtitles", + "opensubtitles_hint": "Ange din OpenSubtitles API-nyckel för att aktivera klientbaserad undertextsökning som reserv när din Jellyfin-server inte har en undertextleverantör konfigurerad.", + "opensubtitles_api_key": "API-nyckel", + "opensubtitles_api_key_placeholder": "Ange API-nyckel...", + "opensubtitles_get_key": "Skaffa din gratis API-nyckel på opensubtitles.com/en/consumers", + "mpv_subtitle_scale": "Undertextskala", + "mpv_subtitle_margin_y": "Vertikal marginal", + "mpv_subtitle_align_x": "Horisontell justering", + "mpv_subtitle_align_y": "Vertikal justering", + "align": { + "left": "Vänster", + "center": "Mitten", + "right": "Höger", + "top": "Toppen", + "bottom": "Botten" + } }, "vlc_subtitles": { "title": "VLC undertextsinställningar", @@ -333,7 +371,7 @@ "server_url_placeholder": "Seerr URL", "password": "Lösenord", "password_placeholder": "Ange lösenord för Jellyfin användare {{username}}", - "login_button": "Login", + "login_button": "Logga in", "total_media_requests": "Totalt antal mediaförfrågningar", "movie_quota_limit": "Gräns för filmkvot", "movie_quota_days": "Filmkvot Dagar", @@ -492,6 +530,7 @@ } }, "common": { + "no_results": "Inga resultat", "select": "Välj", "no_trailer_available": "Ingen trailer tillgänglig", "video": "Video", @@ -501,13 +540,17 @@ "none": "Ingen", "track": "Spår", "cancel": "Avbryt", + "stop": "Stoppa", "delete": "Ta bort", "ok": "OK", "remove": "Radera", "next": "Nästa", "back": "Tillbaka", "continue": "Fortsätt", - "verifying": "Verifierar..." + "verifying": "Verifierar...", + "login": "Logga in", + "refresh": "Uppdatera", + "seeAll": "Visa alla" }, "search": { "search": "Sök...", @@ -556,6 +599,7 @@ "movies": "Filmer", "series": "Serier", "boxsets": "Box Set", + "playlists": "Spellistor", "items": "Artiklar" }, "options": { @@ -574,7 +618,11 @@ "sort_by": "Sortera efter", "filter_by": "Filtrera På", "sort_order": "Sorteringsordning", - "tags": "Etiketter" + "tags": "Etiketter", + "all": "Alla", + "reset": "Återställ", + "asc": "Stigande", + "desc": "Fallande" } }, "favorites": { @@ -591,6 +639,7 @@ "no_links": "Inga Länkar" }, "player": { + "live": "LIVE", "error": "Fel", "failed_to_get_stream_url": "Kunde inte hämta stream-URL", "an_error_occured_while_playing_the_video": "Ett fel uppstod vid uppspelning av videon. Kontrollera loggarna i inställningarna.", @@ -608,7 +657,29 @@ "downloaded_file_message": "Vill du spela upp den nedladdade filen?", "downloaded_file_yes": "Ja", "downloaded_file_no": "Nej", - "downloaded_file_cancel": "Avbryt" + "downloaded_file_cancel": "Avbryt", + "swipe_down_settings": "Svep nedåt för inställningar", + "ends_at": "slutar", + "search_subtitles": "Sök undertexter", + "subtitle_tracks": "Spår", + "subtitle_search": "Sök & ladda ner", + "download": "Ladda ner", + "subtitle_download_hint": "Nedladdade undertexter sparas i ditt bibliotek", + "using_jellyfin_server": "Använder Jellyfin-server", + "language": "Språk", + "results": "Resultat", + "searching": "Söker...", + "search_failed": "Sökningen misslyckades", + "no_subtitle_provider": "Ingen undertextleverantör konfigurerad på servern", + "no_subtitles_found": "Inga undertexter hittades", + "add_opensubtitles_key_hint": "Lägg till OpenSubtitles API-nyckel i inställningar för klientsidesökning som reserv", + "settings": "Inställningar", + "skip_intro": "Hoppa över intro", + "skip_credits": "Hoppa över eftertexter", + "stopPlayback": "Stoppa uppspelning", + "stopPlayingTitle": "Sluta spela \"{{title}}\"?", + "stopPlayingConfirm": "Är du säker på att du vill stoppa uppspelningen?", + "downloaded": "Nedladdad" }, "item_card": { "next_up": "Näst på tur", @@ -617,6 +688,11 @@ "series": "Serier", "seasons": "Säsonger", "season": "Säsong ", + "from_this_series": "Från den här serien", + "more_from_this_season": "Från denna säsong", + "view_series": "Visa serien", + "view_season": "Visa säsongen", + "select_season": "Välj säsong", "no_episodes_for_this_season": "Inga avsnitt för den här säsongen", "overview": "Översikt", "more_with": "Mer med {{name}}", @@ -627,10 +703,21 @@ "media_options": "Medieinställningar", "quality": "Kvalitet", "audio": "Ljud", - "subtitles": "Undertext", + "subtitles": { + "label": "Undertext", + "none": "Ingen", + "tracks": "Spår" + }, "show_more": "Visa Mer", "show_less": "Visa Mindre", + "left": "kvar", + "more_info": "Mer info", + "director": "Regissör", + "cast": "Skådespelare", + "technical_details": "Tekniska detaljer", "appeared_in": "Förekommer I", + "movies": "Filmer", + "shows": "Serier", "could_not_load_item": "Kunde Inte Ladda Artikeln", "none": "Inget", "download": { @@ -641,7 +728,13 @@ "download_x_item": "Ladda Ner {{item_count}} Objekt", "download_unwatched_only": "Endast Osedda", "download_button": "Ladda ner" - } + }, + "mark_played": "Markera som sedd", + "mark_unplayed": "Markera som osedd", + "resume_playback": "Återuppta uppspelning", + "resume_playback_description": "Vill du fortsätta där du slutade eller börja om från början?", + "play_from_start": "Spela från början", + "continue_from": "Fortsätt från {{time}}" }, "live_tv": { "next": "Nästa", @@ -652,7 +745,18 @@ "movies": "Filmer", "sports": "Sport", "for_kids": "För barn", - "news": "Nyheter" + "news": "Nyheter", + "page_of": "Sida {{current}} av {{total}}", + "no_programs": "Inga program tillgängliga", + "no_channels": "Inga kanaler tillgängliga", + "tabs": { + "programs": "Program", + "guide": "Guide", + "channels": "Kanaler", + "recordings": "Inspelningar", + "schedule": "Schema", + "series": "Serier" + } }, "jellyseerr": { "confirm": "Bekräfta", @@ -689,7 +793,7 @@ "quality_profile": "Kvalitetsprofil", "root_folder": "Rotkatalog", "season_all": "Säsong (alla)", - "season_number": "Säsong {{seasonNumber}}", + "season_number": "Säsong {{season_number}}", "number_episodes": "{{episode_number}} Avsnitt", "born": "Född", "appearances": "Framträdanden", @@ -697,6 +801,12 @@ "decline": "Avvisa", "requested_by": "Begärt av {{user}}", "unknown_user": "Okänd användare", + "select": "Välj", + "request_all": "Begär alla", + "request_seasons": "Begär säsonger", + "select_seasons": "Välj säsonger", + "request_selected": "Begär valda", + "n_selected": "{{count}} valda", "toasts": { "jellyseer_does_not_meet_requirements": "Seerr-servern uppfyller inte minimikrav för version! Vänligen uppdatera till minst 2.0.0", "jellyseerr_test_failed": "Seerr test misslyckades. Försök igen.", @@ -716,7 +826,8 @@ "search": "Sök", "library": "Bibliotek", "custom_links": "Egna länkar", - "favorites": "Favoriter" + "favorites": "Favoriter", + "settings": "Inställningar" }, "music": { "title": "Musik", diff --git a/translations/tr.json b/translations/tr.json index 15bbebf82..667c2039a 100644 --- a/translations/tr.json +++ b/translations/tr.json @@ -7,88 +7,88 @@ "username_placeholder": "Kullanıcı adı", "password_placeholder": "Şifre", "login_button": "Giriş yap", - "quick_connect": "Hızlı Bağlantı", + "quick_connect": "Hızlı Bağlan", "enter_code_to_login": "Giriş yapmak için {{code}} kodunu girin", - "failed_to_initiate_quick_connect": "Quick Connect başlatılamadı", + "failed_to_initiate_quick_connect": "Hızlı Bağlan başlatılamadı", "got_it": "Anlaşıldı", "connection_failed": "Bağlantı başarısız", - "could_not_connect_to_server": "Sunucuya bağlanılamadı. Lütfen URL'yi ve ağ bağlantınızı kontrol edin", + "could_not_connect_to_server": "Sunucuya bağlanılamadı. Lütfen URL'yi ve ağ bağlantınızı kontrol edin.", "an_unexpected_error_occured": "Beklenmedik bir hata oluştu", - "change_server": "Sunucuyu değiştir", + "change_server": "Sunucu değiştir", "invalid_username_or_password": "Geçersiz kullanıcı adı veya şifre", "user_does_not_have_permission_to_log_in": "Kullanıcının giriş yapma izni yok", - "server_is_taking_too_long_to_respond_try_again_later": "Sunucu yanıt vermekte çok uzun sürüyor, lütfen tekrar deneyin", - "server_received_too_many_requests_try_again_later": "Sunucu çok fazla istek aldı, lütfen tekrar deneyin.", + "server_is_taking_too_long_to_respond_try_again_later": "Sunucunun yanıt vermesi çok uzun sürüyor, lütfen daha sonra tekrar deneyin", + "server_received_too_many_requests_try_again_later": "Sunucu çok fazla istek aldı, lütfen daha sonra tekrar deneyin.", "there_is_a_server_error": "Sunucu hatası var", - "an_unexpected_error_occured_did_you_enter_the_correct_url": "Beklenmedik bir hata oluştu. Sunucu URL'sini doğru girdiğinizden emin oldunuz mu?", - "too_old_server_text": "Unsupported Jellyfin Server Discovered", - "too_old_server_description": "Please update Jellyfin to the latest version" + "an_unexpected_error_occured_did_you_enter_the_correct_url": "Beklenmedik bir hata oluştu. Sunucu URL'sini doğru girdiğinizden emin misiniz?", + "too_old_server_text": "Desteklenmeyen Jellyfin Sunucu sürümü bulundu.", + "too_old_server_description": "Lütfen Jellyfin'i en son sürüme güncelleyin." }, "server": { - "enter_url_to_jellyfin_server": "Jellyfin sunucunusun URL'sini girin", + "enter_url_to_jellyfin_server": "Jellyfin sunucunusun URL adresini girin", "server_url_placeholder": "http(s)://sunucunuz.com", "connect_button": "Bağlan", "previous_servers": "Önceki sunucular", "clear_button": "Temizle", - "swipe_to_remove": "Swipe to remove", + "swipe_to_remove": "Kaldırmak için kaydırın", "search_for_local_servers": "Yerel sunucuları ara", "searching": "Aranıyor...", "servers": "Sunucular", - "saved": "Saved", - "session_expired": "Session Expired", - "please_login_again": "Your saved session has expired. Please log in again.", - "remove_saved_login": "Remove Saved Login", - "remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.", - "accounts_count": "{{count}} accounts", - "select_account": "Select Account", - "add_account": "Add Account", - "remove_account_description": "This will remove the saved credentials for {{username}}." + "saved": "Kaydedildi", + "session_expired": "Oturum süresi doldu", + "please_login_again": "Kaydedilmiş oturumunuzun süresi doldu. Lütfen tekrar giriş yapın.", + "remove_saved_login": "Kayıtlı oturumu kaldır", + "remove_saved_login_description": "Bu sunucu için kaydedilmiş kimlik bilgileriniz kaldırılacaktır. Bir sonraki sefere kullanıcı adı ve şifrenizi yeniden girmeniz gerekecek.", + "accounts_count": "{{count}} hesap", + "select_account": "Hesap Seç", + "add_account": "Hesap Ekle", + "remove_account_description": "{{username}} için kayıtlı bilgiler kaldırılacaktır." }, "save_account": { - "title": "Save Account", - "save_for_later": "Save this account", - "security_option": "Security Option", + "title": "Hesabı Kaydet", + "save_for_later": "Bu hesabı kaydet", + "security_option": "Güvenlik Seçeneği", "no_protection": "No protection", - "no_protection_desc": "Quick login without authentication", - "pin_code": "PIN code", - "pin_code_desc": "4-digit PIN required when switching", - "password": "Re-enter password", - "password_desc": "Password required when switching", - "save_button": "Save", - "cancel_button": "Cancel" + "no_protection_desc": "Kimlik doğrulamasız hızlı giriş", + "pin_code": "PIN kodu", + "pin_code_desc": "Geçiş yaparken 4 haneli PIN kodu gereklidir", + "password": "Şifrenizi tekrar girin ", + "password_desc": "Geçiş yaparken şifre gereklidir", + "save_button": "Kaydet", + "cancel_button": "Vazgeç" }, "pin": { - "enter_pin": "Enter PIN", - "enter_pin_for": "Enter PIN for {{username}}", - "enter_4_digits": "Enter 4 digits", - "invalid_pin": "Invalid PIN", - "setup_pin": "Set Up PIN", - "confirm_pin": "Confirm PIN", - "pins_dont_match": "PINs don't match", - "forgot_pin": "Forgot PIN?", - "forgot_pin_desc": "Your saved credentials will be removed" + "enter_pin": "PIN kodunu girin", + "enter_pin_for": "{{username}} için PIN kodunu girin", + "enter_4_digits": "4 hane girin", + "invalid_pin": "Geçersiz PIN kodu", + "setup_pin": "PIN kodunu ayarla", + "confirm_pin": "PIN kodunu onayla", + "pins_dont_match": "PIN kodları eşleşmiyor", + "forgot_pin": "PIN kodunu mu unuttunuz?", + "forgot_pin_desc": "Kayıtlı bilgileriniz kaldırılacaktır" }, "password": { - "enter_password": "Enter Password", - "enter_password_for": "Enter password for {{username}}", - "invalid_password": "Invalid password" + "enter_password": "Şifrenizi girin", + "enter_password_for": "{{username}} için şifrenizi girin", + "invalid_password": "Geçersiz şifre" }, "home": { - "checking_server_connection": "Checking server connection...", + "checking_server_connection": "Sunucu bağlantısı kontrol ediliyor...", "no_internet": "İnternet Yok", "no_items": "Öge Yok", - "no_internet_message": "Endişelenmeyin, hala\ndownloaded içerik izleyebilirsiniz.", - "checking_server_connection_message": "Checking connection to server", - "go_to_downloads": "İndirmelere Git", - "retry": "Retry", - "server_unreachable": "Server Unreachable", - "server_unreachable_message": "Could not reach the server.\nPlease check your network connection.", + "no_internet_message": "Endişelenmeyin, indirilmiş içerikleri izleyebilirsiniz.", + "checking_server_connection_message": "Sunucuya bağlantı kontrol ediliyor", + "go_to_downloads": "İndirilenlere git", + "retry": "Tekrar dene", + "server_unreachable": "Sunucuya ulaşılamıyor", + "server_unreachable_message": "Sunucuya bağlanılamadı. Lütfen ağ bağlantınızı kontrol edin.", "oops": "Hups!", - "error_message": "Bir şeyler ters gitti.\nLütfen çıkış yapın ve tekrar giriş yapın.", + "error_message": "Bir şeyler ters gitti.\nLütfen çıkış yapıp tekrar giriş yapın.", "continue_watching": "İzlemeye Devam Et", "next_up": "Sonraki", - "continue_and_next_up": "Continue & Next Up", - "recently_added_in": "{{libraryName}}'de Yakınlarda Eklendi", + "continue_and_next_up": "İzlemeye Devam Et & Sıradakiler", + "recently_added_in": "{{libraryName}} Kütüphanesine Son Eklenenler", "suggested_movies": "Önerilen Filmler", "suggested_episodes": "Önerilen Bölümler", "intro": { @@ -110,52 +110,52 @@ "settings_title": "Ayarlar", "log_out_button": "Çıkış Yap", "categories": { - "title": "Categories" + "title": "Kategoriler" }, "playback_controls": { - "title": "Playback & Controls" + "title": "Oynatma & Kontroller" }, "audio_subtitles": { - "title": "Audio & Subtitles" + "title": "Ses & Altyazılar" }, "appearance": { - "title": "Appearance", - "merge_next_up_continue_watching": "Merge Continue Watching & Next Up", - "hide_remote_session_button": "Hide Remote Session Button" + "title": "Görünüm", + "merge_next_up_continue_watching": "İzlemeye Devam Et & Sıradakiler'i birleştir", + "hide_remote_session_button": "Uzak Oturum Butonunu Gizle" }, "network": { - "title": "Network", - "local_network": "Local Network", - "auto_switch_enabled": "Auto-switch when at home", - "auto_switch_description": "Automatically switch to local URL when connected to home WiFi", - "local_url": "Local URL", - "local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)", + "title": "Ağ", + "local_network": "Yerel Ağ", + "auto_switch_enabled": "Evdeyken otomatik geçiş yap", + "auto_switch_description": "Ev WiFi'sine bağlanınca otomatik olarak yerek URL adresine geçiş yap", + "local_url": "Yerel URL Adresi", + "local_url_hint": "Yerel sunucu adresinizi girin (http://192.168.1.100:8096, gibi)", "local_url_placeholder": "http://192.168.1.100:8096", - "home_wifi_networks": "Home WiFi Networks", - "add_current_network": "Add \"{{ssid}}\"", - "not_connected_to_wifi": "Not connected to WiFi", - "no_networks_configured": "No networks configured", - "add_network_hint": "Add your home WiFi network to enable auto-switching", - "current_wifi": "Current WiFi", - "using_url": "Using", - "local": "Local URL", - "remote": "Remote URL", - "not_connected": "Not connected", - "current_server": "Current Server", - "remote_url": "Remote URL", - "active_url": "Active URL", - "not_configured": "Not configured", - "network_added": "Network added", - "network_already_added": "Network already added", - "no_wifi_connected": "Not connected to WiFi", - "permission_denied": "Location permission denied", - "permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings." + "home_wifi_networks": "Ev WiFi ağları", + "add_current_network": "\"{{ssid}}\"'yi ekle", + "not_connected_to_wifi": "WiFi'a bağlı değil", + "no_networks_configured": "Herhangi bir ağ ayarlanmadı", + "add_network_hint": "Otomatik geçişi etkinleştirmek için ev WiFi'nizi ekleyin", + "current_wifi": "Şu anki WiFi", + "using_url": "Kullanılıyor", + "local": "Yerel URL Adresi", + "remote": "Uzak URL Adresi", + "not_connected": "Bağlı değil", + "current_server": "Geçerli Sunucu", + "remote_url": "Uzak URL Adresi", + "active_url": "Aktif URL Adresi", + "not_configured": "Yapılandırılmamış", + "network_added": "Ağ eklendi", + "network_already_added": "Ağ zaten eklendi", + "no_wifi_connected": "WiFi'a bağlı değil", + "permission_denied": "Konum izni reddedildi", + "permission_denied_explanation": "Otomatik geçiş yapabilmek için WiFi ağını algılayabilmek için konum izni gereklidir. Lütfen Ayarlarda etkinleştirin." }, "user_info": { "user_info_title": "Kullanıcı Bilgisi", "user": "Kullanıcı", "server": "Sunucu", - "token": "Token", + "token": "Erişim Anahtarı", "app_version": "Uygulama Sürümü" }, "quick_connect": { @@ -172,20 +172,20 @@ "media_controls_title": "Medya Kontrolleri", "forward_skip_length": "İleri Sarma Uzunluğu", "rewind_length": "Geri Sarma Uzunluğu", - "seconds_unit": "s" + "seconds_unit": "sn" }, "gesture_controls": { - "gesture_controls_title": "Gesture Controls", - "horizontal_swipe_skip": "Horizontal Swipe to Skip", - "horizontal_swipe_skip_description": "Swipe left/right when controls are hidden to skip", - "left_side_brightness": "Left Side Brightness Control", - "left_side_brightness_description": "Swipe up/down on left side to adjust brightness", - "right_side_volume": "Right Side Volume Control", - "right_side_volume_description": "Swipe up/down on right side to adjust volume", - "hide_volume_slider": "Hide Volume Slider", - "hide_volume_slider_description": "Hide the volume slider in the video player", - "hide_brightness_slider": "Hide Brightness Slider", - "hide_brightness_slider_description": "Hide the brightness slider in the video player" + "gesture_controls_title": "Hareketle Kontrol", + "horizontal_swipe_skip": "Atlamak için yatay kaydırma", + "horizontal_swipe_skip_description": "Kontroller gizliyken sola/sağa kaydırarak atlama", + "left_side_brightness": "Sol Taraf Parlaklık Kontrolü", + "left_side_brightness_description": "Sol tarafta aşağı/yukarı kaydırarak parlaklık ayarı", + "right_side_volume": "Sağ Taraf Ses Kontrolü", + "right_side_volume_description": "Sağ tarafta aşağı/yukarı kaydırarak ses ayarı", + "hide_volume_slider": "Ses Ayarını Gizle", + "hide_volume_slider_description": "Video oynatıcıda ses ayarını gizle", + "hide_brightness_slider": "Parlaklık Ayarını Gizle", + "hide_brightness_slider_description": "Video oynatıcıda parlaklık ayarını gizle" }, "audio": { "audio_title": "Ses", @@ -195,12 +195,12 @@ "none": "Yok", "language": "Dil", "transcode_mode": { - "title": "Audio Transcoding", - "description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled", - "auto": "Auto", - "stereo": "Force Stereo", - "5_1": "Allow 5.1", - "passthrough": "Passthrough" + "title": "Ses Kod Dönüştürmesi", + "description": "Surround sesin (7.1, TrueHD, DTS-HD) nasıl işleneceğini kontrol eder.", + "auto": "Oto", + "stereo": "Stereo'ya zorla", + "5_1": "5.1'e izin ver", + "passthrough": "Doğrudan geçiş" } }, "subtitles": { @@ -220,60 +220,60 @@ "None": "Yok", "OnlyForced": "Sadece Zorunlu" }, - "text_color": "Text Color", - "background_color": "Background Color", - "outline_color": "Outline Color", - "outline_thickness": "Outline Thickness", - "background_opacity": "Background Opacity", - "outline_opacity": "Outline Opacity", - "bold_text": "Bold Text", + "text_color": "Metin Rengi", + "background_color": "Arkaplan Rengi", + "outline_color": "Kenarlık Rengi", + "outline_thickness": "Kenarlık kalınlığı", + "background_opacity": "Arkaplan Opaklığı", + "outline_opacity": "Kenarlık Opaklığı", + "bold_text": "Kalın Metin", "colors": { - "Black": "Black", - "Gray": "Gray", - "Silver": "Silver", - "White": "White", - "Maroon": "Maroon", - "Red": "Red", - "Fuchsia": "Fuchsia", - "Yellow": "Yellow", - "Olive": "Olive", - "Green": "Green", - "Teal": "Teal", - "Lime": "Lime", - "Purple": "Purple", - "Navy": "Navy", - "Blue": "Blue", - "Aqua": "Aqua" + "Black": "Siyah", + "Gray": "Gri", + "Silver": "Gümüş", + "White": "Beyaz", + "Maroon": "Kestane", + "Red": "Kırmızı", + "Fuchsia": "Fuşya", + "Yellow": "Sarı", + "Olive": "Zeytin yeşili", + "Green": "Yeşil", + "Teal": "Deniz mavisi", + "Lime": "Limon", + "Purple": "Mor", + "Navy": "Lacivert", + "Blue": "Mavi", + "Aqua": "Açık Mavi" }, "thickness": { "None": "Hiçbiri", - "Thin": "Thin", + "Thin": "İnce", "Normal": "Normal", - "Thick": "Thick" + "Thick": "Kalın" }, - "subtitle_color": "Subtitle Color", - "subtitle_background_color": "Background Color", - "subtitle_font": "Subtitle Font", - "ksplayer_title": "KSPlayer Settings", - "hardware_decode": "Hardware Decoding", - "hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues." + "subtitle_color": "Altyazı Rengi", + "subtitle_background_color": "Arkaplan Rengi", + "subtitle_font": "Altyazı Yazı Tipi", + "ksplayer_title": "KSPlayer Ayarları", + "hardware_decode": "Donanımsal Kod Çözme", + "hardware_decode_description": "Video kod çözme için donanımsal hızlandırma kullan. Oynatma sorunları yaşıyorsanız devre dışı bırakın." }, "vlc_subtitles": { - "title": "VLC Subtitle Settings", - "hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.", - "text_color": "Text Color", - "background_color": "Background Color", - "background_opacity": "Background Opacity", - "outline_color": "Outline Color", - "outline_opacity": "Outline Opacity", - "outline_thickness": "Outline Thickness", - "bold": "Bold Text", - "margin": "Bottom Margin" + "title": "VLC Altyazı Ayarları", + "hint": "VLC oynatıcı için altyazı görünümünü değiştirin. Değişiklikler bir sonraki oynatmada etkili olacak.", + "text_color": "Metin Rengi", + "background_color": "Arkaplan Rengi", + "background_opacity": "Arkaplan Opaklığı", + "outline_color": "Kenarlık Rengi", + "outline_opacity": "Kenarlık Opaklığı", + "outline_thickness": "Kenarlık Kalınlığı", + "bold": "Kalın Metin", + "margin": "Alt Kenar Boşluğu" }, "video_player": { - "title": "Video Player", - "video_player": "Video Player", - "video_player_description": "Choose which video player to use on iOS.", + "title": "Video oynatıcısı", + "video_player": "Video oynatıcısı", + "video_player_description": "iOS'da hangi video oynatıcının kullanılacağını seçin.", "ksplayer": "KSPlayer", "vlc": "VLC" }, @@ -297,7 +297,7 @@ "video_player": "Video player", "video_players": { "VLC_3": "VLC 3", - "VLC_4": "VLC 4 (Experimental + PiP)" + "VLC_4": "VLC 4 (Deneysel + PiP)" }, "show_custom_menu_links": "Özel Menü Bağlantılarını Göster", "show_large_home_carousel": "Show Large Home Carousel (beta)", @@ -305,24 +305,24 @@ "select_liraries_you_want_to_hide": "Kütüphane sekmesinden ve ana sayfa bölümlerinden gizlemek istediğiniz kütüphaneleri seçin.", "disable_haptic_feedback": "Dokunsal Geri Bildirimi Devre Dışı Bırak", "default_quality": "Varsayılan kalite", - "default_playback_speed": "Default Playback Speed", - "auto_play_next_episode": "Auto-play Next Episode", - "max_auto_play_episode_count": "Max Auto Play Episode Count", + "default_playback_speed": "Varsayılan Oynatma Hızı", + "auto_play_next_episode": "Otomatik Sonraki Bölümü Oynat", + "max_auto_play_episode_count": "En Fazla Otomatik Oynatılacak Bölüm Sayısı", "disabled": "Devre dışı" }, "downloads": { "downloads_title": "İndirmeler" }, "music": { - "title": "Music", - "playback_title": "Playback", - "playback_description": "Configure how music is played.", - "prefer_downloaded": "Prefer Downloaded Songs", - "caching_title": "Caching", - "caching_description": "Automatically cache upcoming tracks for smoother playback.", + "title": "Müzik", + "playback_title": "Oynatma", + "playback_description": "Müziğin nasıl çalınacağını ayarlayın.", + "prefer_downloaded": "İndirilmiş Şarkıları Tercih Et", + "caching_title": "Önbellekleme", + "caching_description": "Akıcı oynatım için gelecek şarkıları otomatik önbelleğe al.", "lookahead_enabled": "Enable Look-Ahead Caching", - "lookahead_count": "Tracks to Pre-cache", - "max_cache_size": "Max Cache Size" + "lookahead_count": "Önden Önbelleklenecek Parça Sayısı", + "max_cache_size": "Maksimum Önbellek Boyutu" }, "plugins": { "plugins_title": "Eklentiler", @@ -345,7 +345,7 @@ "order_by": { "DEFAULT": "Varsayılan", "VOTE_COUNT_AND_AVERAGE": "Vote count and average", - "POPULARITY": "Popularity" + "POPULARITY": "Popülerlik" } }, "marlin_search": { @@ -357,35 +357,35 @@ "save_button": "Kaydet", "toasts": { "saved": "Kaydedildi", - "refreshed": "Settings refreshed from server" + "refreshed": "Ayarlar sunucudan yeniden alındı" }, - "refresh_from_server": "Refresh Settings from Server" + "refresh_from_server": "Ayarları Sunucudan Yeniden Al" }, "streamystats": { - "enable_streamystats": "Enable Streamystats", - "disable_streamystats": "Disable Streamystats", - "enable_search": "Use for Search", - "url": "URL", + "enable_streamystats": "Streamystats'ı Etkinleştir", + "disable_streamystats": "Streamystats'ı Devre Dışı Bırak", + "enable_search": "Arama için kullan", + "url": "URL Adresi", "server_url_placeholder": "http(s)://streamystats.example.com", - "streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.", - "read_more_about_streamystats": "Read More About Streamystats.", - "save_button": "Save", - "save": "Save", - "features_title": "Features", + "streamystats_search_hint": "Streamystats sunucu URL'sini girin. URL, http veya https içermeli ve isteğe bağlı olarak portu içerebilir.", + "read_more_about_streamystats": "Streamystats hakkında daha fazla bilgi.", + "save_button": "Kaydet", + "save": "Kaydet", + "features_title": "Özellikler", "home_sections_title": "Home Sections", - "enable_movie_recommendations": "Movie Recommendations", - "enable_series_recommendations": "Series Recommendations", + "enable_movie_recommendations": "Film Önerileri", + "enable_series_recommendations": "Dizi Önerileri", "enable_promoted_watchlists": "Promoted Watchlists", "hide_watchlists_tab": "Hide Watchlists Tab", "home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.", - "recommended_movies": "Recommended Movies", - "recommended_series": "Recommended Series", + "recommended_movies": "Önerilen Filmler", + "recommended_series": "Önerilen Diziler", "toasts": { - "saved": "Saved", - "refreshed": "Settings refreshed from server", - "disabled": "Streamystats disabled" + "saved": "Kaydedildi", + "refreshed": "Ayarlar sunucudan yeniden alındı", + "disabled": "Streamystats devre dışı" }, - "refresh_from_server": "Refresh Settings from Server" + "refresh_from_server": "Ayarları Sunucudan Yeniden Al" }, "kefinTweaks": { "watchlist_enabler": "Enable our Watchlist integration", @@ -398,18 +398,18 @@ "device_usage": "Cihaz {{availableSpace}}%", "size_used": "{{used}} / {{total}} kullanıldı", "delete_all_downloaded_files": "Tüm indirilen dosyaları sil", - "music_cache_title": "Music Cache", + "music_cache_title": "Müzik Ön Belleği", "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", - "enable_music_cache": "Enable Music Cache", - "clear_music_cache": "Clear Music Cache", - "music_cache_size": "{{size}} cached", - "music_cache_cleared": "Music cache cleared", - "delete_all_downloaded_songs": "Delete All Downloaded Songs", - "downloaded_songs_size": "{{size}} downloaded", - "downloaded_songs_deleted": "Downloaded songs deleted" + "enable_music_cache": "Müzik Ön Belleğini Etkinleştir", + "clear_music_cache": "Müzik Ön Belleğini Temizle", + "music_cache_size": "{{size}} ön belleklendi", + "music_cache_cleared": "Müzik ön belleği temizlendi", + "delete_all_downloaded_songs": "Tüm İndirilen Müzikleri Sil", + "downloaded_songs_size": "{{size}} indirildi", + "downloaded_songs_deleted": "İndirilen müzikler silindi" }, "intro": { - "title": "Intro", + "title": "Giriş", "show_intro": "Tanıtımı Göster", "reset_intro": "Tanıtımı Sıfırla" }, @@ -417,7 +417,7 @@ "logs_title": "Günlükler", "export_logs": "Export logs", "click_for_more_info": "Click for more info", - "level": "Level", + "level": "Düzey", "no_logs_available": "Günlükler mevcut değil", "delete_all_logs": "Tüm günlükleri sil" }, @@ -433,22 +433,22 @@ } }, "sessions": { - "title": "Sessions", - "no_active_sessions": "No Active Sessions" + "title": "Oturumlar", + "no_active_sessions": "Aktif Oturum Yok" }, "downloads": { "downloads_title": "İndirilenler", "tvseries": "Diziler", "movies": "Filmler", "queue": "Sıra", - "other_media": "Other media", + "other_media": "Diğer medya", "queue_hint": "Sıra ve indirmeler uygulama yeniden başlatıldığında kaybolacaktır", "no_items_in_queue": "Sırada öğe yok", "no_downloaded_items": "İndirilen öğe yok", "delete_all_movies_button": "Tüm Filmleri Sil", "delete_all_tvseries_button": "Tüm Dizileri Sil", "delete_all_button": "Tümünü Sil", - "delete_all_other_media_button": "Delete other media", + "delete_all_other_media_button": "Diğer medyayı sil", "active_download": "Aktif indirme", "no_active_downloads": "Aktif indirme yok", "active_downloads": "Aktif indirmeler", @@ -465,49 +465,49 @@ "failed_to_delete_all_movies": "Filmler silinemedi", "deleted_all_tvseries_successfully": "Tüm diziler başarıyla silindi!", "failed_to_delete_all_tvseries": "Diziler silinemedi", - "deleted_media_successfully": "Deleted other media Successfully!", + "deleted_media_successfully": "Diğer medya başarıyla silindi!", "failed_to_delete_media": "Failed to Delete other media", - "download_deleted": "Download Deleted", + "download_deleted": "İndirme silindi", "download_cancelled": "İndirme iptal edildi", - "could_not_delete_download": "Could Not Delete Download", - "download_paused": "Download Paused", - "could_not_pause_download": "Could Not Pause Download", - "download_resumed": "Download Resumed", - "could_not_resume_download": "Could Not Resume Download", + "could_not_delete_download": "İndirme Silinemedi", + "download_paused": "İndirme Duraklatıldı", + "could_not_pause_download": "İndirme Duraklatılamadı", + "download_resumed": "İndirme Devam Ediyor", + "could_not_resume_download": "İndirme Devam Ettirilemedi", "download_completed": "İndirme tamamlandı", - "download_failed": "Download Failed", + "download_failed": "İndirme başarısız oldu", "download_failed_for_item": "{{item}} için indirme başarısız oldu - {{error}}", "download_completed_for_item": "{{item}} için indirme tamamlandı", - "download_started_for_item": "Download Started for {{item}}", - "failed_to_start_download": "Failed to start download", - "item_already_downloading": "{{item}} is already downloading", - "all_files_deleted": "All Downloads Deleted Successfully", - "files_deleted_by_type": "{{count}} {{type}} deleted", + "download_started_for_item": "{{item}} için indirme başladı", + "failed_to_start_download": "İndirme başlatılamadı", + "item_already_downloading": "{{item}} zaten indiriliyor", + "all_files_deleted": "Bütün indirilenler başarıyla silindi", + "files_deleted_by_type": "{{count}} {{type}} silindi", "all_files_folders_and_jobs_deleted_successfully": "Tüm dosyalar, klasörler ve işler başarıyla silindi", - "failed_to_clean_cache_directory": "Failed to clean cache directory", - "could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}", + "failed_to_clean_cache_directory": "Önbellek dizini temizlenemedi", + "could_not_get_download_url_for_item": "{{itemName}} için indirme URL'si alınamadı", "go_to_downloads": "İndirmelere git", - "file_deleted": "{{item}} deleted" + "file_deleted": "{{item}} silindi" } } }, "common": { - "select": "Select", - "no_trailer_available": "No trailer available", + "select": "Seç", + "no_trailer_available": "Fragman mevcut değil", "video": "Video", "audio": "Ses", "subtitle": "Altyazı", - "play": "Play", - "none": "None", - "track": "Track", - "cancel": "Cancel", - "delete": "Delete", - "ok": "OK", - "remove": "Remove", - "next": "Next", - "back": "Back", - "continue": "Continue", - "verifying": "Verifying..." + "play": "Oynat", + "none": "Hiçbiri", + "track": "Parça", + "cancel": "Vazgeç", + "delete": "Sil", + "ok": "Tamam", + "remove": "Kaldır", + "next": "Sonraki", + "back": "Geri", + "continue": "Devam", + "verifying": "Doğrulanıyor..." }, "search": { "search": "Ara...", @@ -521,10 +521,10 @@ "episodes": "Bölümler", "collections": "Koleksiyonlar", "actors": "Oyuncular", - "artists": "Artists", - "albums": "Albums", - "songs": "Songs", - "playlists": "Playlists", + "artists": "Sanatçılar", + "albums": "Albümler", + "songs": "Şarkılar", + "playlists": "Çalma listeleri", "request_movies": "Film Talep Et", "request_series": "Dizi Talep Et", "recently_added": "Son Eklenenler", @@ -572,7 +572,7 @@ "genres": "Türler", "years": "Yıllar", "sort_by": "Sırala", - "filter_by": "Filter By", + "filter_by": "Filtrele", "sort_order": "Sıralama düzeni", "tags": "Etiketler" } @@ -604,11 +604,11 @@ "index": "İndeks:", "continue_watching": "İzlemeye devam et", "go_back": "Geri", - "downloaded_file_title": "You have this file downloaded", - "downloaded_file_message": "Do you want to play the downloaded file?", - "downloaded_file_yes": "Yes", - "downloaded_file_no": "No", - "downloaded_file_cancel": "Cancel" + "downloaded_file_title": "Bu dosya indirilmiş", + "downloaded_file_message": "İndirilmiş dosyayı oynatmak ister misiniz?", + "downloaded_file_yes": "Evet", + "downloaded_file_no": "Hayır", + "downloaded_file_cancel": "Vazgeç" }, "item_card": { "next_up": "Sıradaki", @@ -624,7 +624,7 @@ "no_similar_items_found": "Benzer öge bulunamadı", "video": "Video", "more_details": "Daha fazla detay", - "media_options": "Media Options", + "media_options": "Medya Seçenekleri", "quality": "Kalite", "audio": "Ses", "subtitles": "Altyazı", @@ -639,7 +639,7 @@ "download_episode": "Bölümü indir", "download_movie": "Filmi indir", "download_x_item": "{{item_count}} tane ögeyi indir", - "download_unwatched_only": "Unwatched Only", + "download_unwatched_only": "Yalnızca İzlenmemişler", "download_button": "İndir" } }, @@ -693,10 +693,10 @@ "number_episodes": "Bölüm {{episode_number}}", "born": "Doğum", "appearances": "Görünmeler", - "approve": "Approve", - "decline": "Decline", - "requested_by": "Requested by {{user}}", - "unknown_user": "Unknown User", + "approve": "Onayla", + "decline": "Reddet", + "requested_by": "{{user}} tarafından istendi", + "unknown_user": "Bilinmeyen Kullanıcı", "toasts": { "jellyseer_does_not_meet_requirements": "Jellyseerr sunucusu minimum sürüm gereksinimlerini karşılamıyor! Lütfen en az 2.0.0 sürümüne güncelleyin", "jellyseerr_test_failed": "Jellyseerr testi başarısız oldu. Lütfen tekrar deneyin.", @@ -705,10 +705,10 @@ "requested_item": "{{item}} talep edildi!", "you_dont_have_permission_to_request": "İstek göndermeye izniniz yok!", "something_went_wrong_requesting_media": "Medya talep edilirken bir şeyler ters gitti!", - "request_approved": "Request Approved!", - "request_declined": "Request Declined!", - "failed_to_approve_request": "Failed to Approve Request", - "failed_to_decline_request": "Failed to Decline Request" + "request_approved": "İstek Onaylandı!", + "request_declined": "İstek Reddedildi!", + "failed_to_approve_request": "İsteği Onaylama Başarısız Oldu", + "failed_to_decline_request": "İsteği Reddetme Başarısız Oldu" } }, "tabs": { @@ -719,127 +719,127 @@ "favorites": "Favoriler" }, "music": { - "title": "Music", + "title": "Müzik", "tabs": { - "suggestions": "Suggestions", - "albums": "Albums", - "artists": "Artists", - "playlists": "Playlists", - "tracks": "tracks" + "suggestions": "Öneriler", + "albums": "Albümler", + "artists": "Sanatçılar", + "playlists": "Çalma listeleri", + "tracks": "parçalar" }, "filters": { - "all": "All" + "all": "Tümü" }, - "recently_added": "Recently Added", - "recently_played": "Recently Played", - "frequently_played": "Frequently Played", - "explore": "Explore", - "top_tracks": "Top Tracks", - "play": "Play", - "shuffle": "Shuffle", - "play_top_tracks": "Play Top Tracks", - "no_suggestions": "No suggestions available", - "no_albums": "No albums found", - "no_artists": "No artists found", - "no_playlists": "No playlists found", - "album_not_found": "Album not found", - "artist_not_found": "Artist not found", - "playlist_not_found": "Playlist not found", + "recently_added": "Son Eklenenler", + "recently_played": "Son Oynatılanlar", + "frequently_played": "Sık Oynatılanlar", + "explore": "Keşfet", + "top_tracks": "En Popülar Parçalar", + "play": "Oynat", + "shuffle": "Karıştır", + "play_top_tracks": "En Çok Oynatılan Parçaları Oynat", + "no_suggestions": "Öneri mevcut değil", + "no_albums": "Hiç albüm bulunamadı", + "no_artists": "Hiç sanatçı bulunamadı", + "no_playlists": "Hiç çalma listesi bulunamadı", + "album_not_found": "Albüm bulunamadı", + "artist_not_found": "Sanatçı bulunamadı", + "playlist_not_found": "Çalma listesi bulunamadı", "track_options": { - "play_next": "Play Next", - "add_to_queue": "Add to Queue", - "add_to_playlist": "Add to Playlist", - "download": "Download", - "downloaded": "Downloaded", - "downloading": "Downloading...", - "cached": "Cached", - "delete_download": "Delete Download", - "delete_cache": "Remove from Cache", - "go_to_artist": "Go to Artist", - "go_to_album": "Go to Album", - "add_to_favorites": "Add to Favorites", - "remove_from_favorites": "Remove from Favorites", - "remove_from_playlist": "Remove from Playlist" + "play_next": "Sıradakini Çal", + "add_to_queue": "Sıraya Ekle", + "add_to_playlist": "Çalma listesine ekle", + "download": "İndir", + "downloaded": "İndirildi", + "downloading": "İndiriliyor...", + "cached": "Önbellekte", + "delete_download": "İndirmeyi Sil", + "delete_cache": "Ön bellekten kaldır", + "go_to_artist": "Sanatçıya Git", + "go_to_album": "Albüme Git", + "add_to_favorites": "Favorilere Ekle", + "remove_from_favorites": "Favorilerden Kaldır", + "remove_from_playlist": "Çalma Listesinden Kaldır" }, "playlists": { - "create_playlist": "Create Playlist", - "playlist_name": "Playlist Name", - "enter_name": "Enter playlist name", - "create": "Create", - "search_playlists": "Search playlists...", - "added_to": "Added to {{name}}", - "added": "Added to playlist", - "removed_from": "Removed from {{name}}", - "removed": "Removed from playlist", - "created": "Playlist created", - "create_new": "Create New Playlist", - "failed_to_add": "Failed to add to playlist", - "failed_to_remove": "Failed to remove from playlist", - "failed_to_create": "Failed to create playlist", - "delete_playlist": "Delete Playlist", - "delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", - "deleted": "Playlist deleted", - "failed_to_delete": "Failed to delete playlist" + "create_playlist": "Çalma Listesi Oluştur", + "playlist_name": "Çalma Listesi Adı", + "enter_name": "Çalma listesi adı girin", + "create": "Oluştur", + "search_playlists": "Çalma listelerini ara...", + "added_to": "Şu çalma listesine eklendi: {{name}}", + "added": "Çalma listesine eklendi", + "removed_from": "Şu çalma listesinden kaldırıldı: {{name}}", + "removed": "Çalma listesinden kaldır", + "created": "Çalma listesi oluşturuldu", + "create_new": "Yeni Çalma Listesi Oluştur", + "failed_to_add": "Çalma listesine eklenemedi", + "failed_to_remove": "Çalma listesinden kaldırılamadı", + "failed_to_create": "Çalma listesi oluşturulamadı", + "delete_playlist": "Çalma Listesini Sil", + "delete_confirm": "\"{{name}}\" adlı çalma listesini silmek istediğinize emin misiniz? Bu işlem geri alınamaz.", + "deleted": "Çalma listesi silindi", + "failed_to_delete": "Çalma listesi oluşturulamadı" }, "sort": { - "title": "Sort By", - "alphabetical": "Alphabetical", - "date_created": "Date Created" + "title": "Sırala", + "alphabetical": "Alfabetik", + "date_created": "Oluşturulma Tarihi" } }, "watchlists": { - "title": "Watchlists", - "my_watchlists": "My Watchlists", - "public_watchlists": "Public Watchlists", - "create_title": "Create Watchlist", - "edit_title": "Edit Watchlist", - "create_button": "Create Watchlist", - "save_button": "Save Changes", - "delete_button": "Delete", - "remove_button": "Remove", - "cancel_button": "Cancel", + "title": "İzleme listeleri", + "my_watchlists": "İzleme listelerim", + "public_watchlists": "Herkese açık izleme listeleri", + "create_title": "İzleme listesi oluştur", + "edit_title": "İzleme listesini düzenle", + "create_button": "İzleme listesi oluştur", + "save_button": "Değişiklikleri Kaydet", + "delete_button": "Sil", + "remove_button": "Kaldır", + "cancel_button": "Vazgeç", "name_label": "Name", - "name_placeholder": "Enter watchlist name", - "description_label": "Description", - "description_placeholder": "Enter description (optional)", - "is_public_label": "Public Watchlist", - "is_public_description": "Allow others to view this watchlist", - "allowed_type_label": "Content Type", - "sort_order_label": "Default Sort Order", - "empty_title": "No Watchlists", + "name_placeholder": "İzleme listesi adını girin", + "description_label": "Açıklama", + "description_placeholder": "Açıklama girin (isteğe bağlı)", + "is_public_label": "Herkese açık izleme listesi", + "is_public_description": "Başkalarının da bu izleme listesini görmesine izin ver", + "allowed_type_label": "İçerik Türü", + "sort_order_label": "Varsayılan Sıralama", + "empty_title": "İzleme listesi yok", "empty_description": "Create your first watchlist to start organizing your media", - "empty_watchlist": "This watchlist is empty", - "empty_watchlist_hint": "Add items from your library to this watchlist", - "not_configured_title": "Streamystats Not Configured", - "not_configured_description": "Configure Streamystats in settings to use watchlists", - "go_to_settings": "Go to Settings", - "add_to_watchlist": "Add to Watchlist", - "remove_from_watchlist": "Remove from Watchlist", - "select_watchlist": "Select Watchlist", - "create_new": "Create New Watchlist", - "item": "item", - "items": "items", - "public": "Public", - "private": "Private", - "you": "You", - "by_owner": "By another user", - "not_found": "Watchlist not found", - "delete_confirm_title": "Delete Watchlist", + "empty_watchlist": "Bu izleme listesi boş", + "empty_watchlist_hint": "Kütüphanenizdeki nesneleri bu izleme listesine ekleyin", + "not_configured_title": "Streamystats ayarlanmamış", + "not_configured_description": "İzleme listelerini kullanmak için ayarlardan Streamystats'ı ayarlayın", + "go_to_settings": "Ayarlara git", + "add_to_watchlist": "İzleme Listesine Ekle", + "remove_from_watchlist": "İzleme Listesinden Kaldır", + "select_watchlist": "İzleme Listesi Seç", + "create_new": "Yeni İzleme Listesi Oluştur", + "item": "öğe", + "items": "öğeler", + "public": "Herkese Açık", + "private": "Özel", + "you": "Siz", + "by_owner": "Başka kullanıcı tarafından", + "not_found": "İzleme listesi bulunamadı", + "delete_confirm_title": "İzleme listesini sil", "delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", - "remove_item_title": "Remove from Watchlist", - "remove_item_message": "Remove \"{{name}}\" from this watchlist?", - "loading": "Loading watchlists...", - "no_compatible_watchlists": "No compatible watchlists", - "create_one_first": "Create a watchlist that accepts this content type" + "remove_item_title": "İzleme Listesinden Kaldır", + "remove_item_message": "{{name}} bu izleme listesinden kaldırılsın mı?", + "loading": "İzleme listeleri yükleniyor...", + "no_compatible_watchlists": "Uyumlu izleme listesi yok", + "create_one_first": "Bu içerik türünü kabul eden bir izleme listesi oluşturun" }, "playback_speed": { - "title": "Playback Speed", - "apply_to": "Apply To", - "speed": "Speed", + "title": "Oynatma Hızı", + "apply_to": "Şuna Uygula", + "speed": "Hız", "scope": { - "media": "This media only", - "show": "This show", - "all": "All media (default)" + "media": "Yalnızca bu medyada", + "show": "Bu dizide", + "all": "Bütün medyalarda (varsayılan)" } } } diff --git a/utils/atoms/downloadedSubtitles.ts b/utils/atoms/downloadedSubtitles.ts new file mode 100644 index 000000000..69d17a9f8 --- /dev/null +++ b/utils/atoms/downloadedSubtitles.ts @@ -0,0 +1,162 @@ +/** + * Downloaded Subtitles Storage + * + * Persists metadata about client-side downloaded subtitles (from OpenSubtitles). + * Subtitle files are stored in Paths.cache/streamyfin-subtitles/ directory. + * Filenames are prefixed with itemId for organization: {itemId}_{filename} + * + * While files are in cache, metadata is persisted in MMKV so subtitles survive + * app restarts (unless cache is manually cleared by the user). + * + * TV platform only. + */ + +import { storage } from "../mmkv"; + +// MMKV storage key +const DOWNLOADED_SUBTITLES_KEY = "downloadedSubtitles.json"; + +/** + * Metadata for a downloaded subtitle file + */ +export interface DownloadedSubtitle { + /** Unique identifier (uuid) */ + id: string; + /** Jellyfin item ID */ + itemId: string; + /** Local file path in documents directory */ + filePath: string; + /** Display name */ + name: string; + /** 3-letter language code */ + language: string; + /** File format (srt, ass, etc.) */ + format: string; + /** Source provider */ + source: "opensubtitles"; + /** Unix timestamp when downloaded */ + downloadedAt: number; +} + +/** + * Storage structure for downloaded subtitles + */ +interface DownloadedSubtitlesStorage { + /** Map of itemId to array of downloaded subtitles */ + byItemId: Record; +} + +/** + * Load the storage from MMKV + */ +function loadStorage(): DownloadedSubtitlesStorage { + try { + const data = storage.getString(DOWNLOADED_SUBTITLES_KEY); + if (data) { + return JSON.parse(data) as DownloadedSubtitlesStorage; + } + } catch { + // Ignore parse errors, return empty storage + } + return { byItemId: {} }; +} + +/** + * Save the storage to MMKV + */ +function saveStorage(data: DownloadedSubtitlesStorage): void { + try { + storage.set(DOWNLOADED_SUBTITLES_KEY, JSON.stringify(data)); + } catch (error) { + console.error("Failed to save downloaded subtitles:", error); + } +} + +/** + * Get all downloaded subtitles for a specific Jellyfin item + */ +export function getSubtitlesForItem(itemId: string): DownloadedSubtitle[] { + const data = loadStorage(); + return data.byItemId[itemId] ?? []; +} + +/** + * Add a downloaded subtitle to storage + */ +export function addDownloadedSubtitle(subtitle: DownloadedSubtitle): void { + const data = loadStorage(); + + // Initialize array for item if it doesn't exist + if (!data.byItemId[subtitle.itemId]) { + data.byItemId[subtitle.itemId] = []; + } + + // Check if subtitle with same id already exists and update it + const existingIndex = data.byItemId[subtitle.itemId].findIndex( + (s) => s.id === subtitle.id, + ); + + if (existingIndex !== -1) { + // Update existing entry + data.byItemId[subtitle.itemId][existingIndex] = subtitle; + } else { + // Add new entry + data.byItemId[subtitle.itemId].push(subtitle); + } + + saveStorage(data); +} + +/** + * Remove a downloaded subtitle from storage + */ +export function removeDownloadedSubtitle( + itemId: string, + subtitleId: string, +): void { + const data = loadStorage(); + + if (data.byItemId[itemId]) { + data.byItemId[itemId] = data.byItemId[itemId].filter( + (s) => s.id !== subtitleId, + ); + + // Clean up empty arrays + if (data.byItemId[itemId].length === 0) { + delete data.byItemId[itemId]; + } + + saveStorage(data); + } +} + +/** + * Remove all downloaded subtitles for a specific item + */ +export function removeAllSubtitlesForItem(itemId: string): void { + const data = loadStorage(); + + if (data.byItemId[itemId]) { + delete data.byItemId[itemId]; + saveStorage(data); + } +} + +/** + * Check if a subtitle file already exists for an item by language + */ +export function hasSubtitleForLanguage( + itemId: string, + language: string, +): boolean { + const subtitles = getSubtitlesForItem(itemId); + return subtitles.some((s) => s.language === language); +} + +/** + * Get all downloaded subtitles across all items + */ +export function getAllDownloadedSubtitles(): DownloadedSubtitle[] { + const data = loadStorage(); + return Object.values(data.byItemId).flat(); +} diff --git a/utils/atoms/selectedTVServer.ts b/utils/atoms/selectedTVServer.ts new file mode 100644 index 000000000..c5d25479c --- /dev/null +++ b/utils/atoms/selectedTVServer.ts @@ -0,0 +1,60 @@ +import { atom } from "jotai"; +import { storage } from "../mmkv"; + +const STORAGE_KEY = "selectedTVServer"; + +export interface SelectedTVServerState { + address: string; + name?: string; +} + +/** + * Load the selected TV server from MMKV storage. + */ +function loadSelectedTVServer(): SelectedTVServerState | null { + const stored = storage.getString(STORAGE_KEY); + if (stored) { + try { + return JSON.parse(stored) as SelectedTVServerState; + } catch { + return null; + } + } + return null; +} + +/** + * Save the selected TV server to MMKV storage. + */ +function saveSelectedTVServer(server: SelectedTVServerState | null): void { + if (server) { + storage.set(STORAGE_KEY, JSON.stringify(server)); + } else { + storage.remove(STORAGE_KEY); + } +} + +/** + * Base atom holding the selected TV server state. + */ +const baseSelectedTVServerAtom = atom( + loadSelectedTVServer(), +); + +/** + * Derived atom that persists changes to MMKV storage. + */ +export const selectedTVServerAtom = atom( + (get) => get(baseSelectedTVServerAtom), + (_get, set, newValue: SelectedTVServerState | null) => { + saveSelectedTVServer(newValue); + set(baseSelectedTVServerAtom, newValue); + }, +); + +/** + * Clear the selected TV server (used when changing servers). + */ +export function clearSelectedTVServer(): void { + storage.remove(STORAGE_KEY); +} diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 28f7d1b46..8e2bfcf5b 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -134,6 +134,14 @@ export enum VideoPlayer { MPV = 0, } +// TV Typography scale presets +export enum TVTypographyScale { + Small = "small", + Default = "default", + Large = "large", + ExtraLarge = "extraLarge", +} + // Audio transcoding mode - controls how surround audio is handled // This controls server-side transcoding behavior for audio streams. // MPV decodes via FFmpeg and supports most formats, but mobile devices @@ -146,6 +154,22 @@ export enum AudioTranscodeMode { AllowAll = "passthrough", // Direct play all audio formats } +// Inactivity timeout for TV - auto logout after period of no activity +export enum InactivityTimeout { + Disabled = 0, + OneMinute = 60000, + FiveMinutes = 300000, + FifteenMinutes = 900000, + ThirtyMinutes = 1800000, + OneHour = 3600000, + FourHours = 14400000, + TwentyFourHours = 86400000, +} + +// MPV cache mode - controls how caching is enabled +export type MpvCacheMode = "auto" | "yes" | "no"; +export type MpvVoDriver = "gpu-next" | "gpu"; + export type Settings = { home?: Home | null; deviceProfile?: "Expo" | "Native" | "Old"; @@ -191,6 +215,15 @@ export type Settings = { mpvSubtitleAlignX?: "left" | "center" | "right"; mpvSubtitleAlignY?: "top" | "center" | "bottom"; mpvSubtitleFontSize?: number; + mpvSubtitleBackgroundEnabled?: boolean; + mpvSubtitleBackgroundOpacity?: number; // 0-100 + // MPV buffer/cache settings + mpvCacheEnabled?: MpvCacheMode; + mpvCacheSeconds?: number; + mpvDemuxerMaxBytes?: number; // MB + mpvDemuxerMaxBackBytes?: number; // MB + // MPV video output driver (Android only) + mpvVoDriver?: MpvVoDriver; // Gesture controls enableHorizontalSwipeSkip: boolean; enableLeftSideBrightnessSwipe: boolean; @@ -198,8 +231,13 @@ export type Settings = { hideVolumeSlider: boolean; hideBrightnessSlider: boolean; usePopularPlugin: boolean; - showLargeHomeCarousel: boolean; mergeNextUpAndContinueWatching: boolean; + // TV-specific settings + showHomeBackdrop: boolean; + showTVHeroCarousel: boolean; + tvTypographyScale: TVTypographyScale; + showSeriesPosterOnEpisode: boolean; + tvThemeMusicEnabled: boolean; // Appearance hideRemoteSessionButton: boolean; hideWatchlistsTab: boolean; @@ -211,6 +249,10 @@ export type Settings = { preferLocalAudio: boolean; // Audio transcoding mode audioTranscodeMode: AudioTranscodeMode; + // OpenSubtitles API key for client-side subtitle fetching + openSubtitlesApiKey?: string; + // TV-only: Inactivity timeout for auto-logout + inactivityTimeout: InactivityTimeout; }; export interface Lockable { @@ -276,6 +318,15 @@ export const defaultValues: Settings = { mpvSubtitleAlignX: undefined, mpvSubtitleAlignY: undefined, mpvSubtitleFontSize: undefined, + mpvSubtitleBackgroundEnabled: false, + mpvSubtitleBackgroundOpacity: 75, + // MPV buffer/cache defaults + mpvCacheEnabled: "auto", + mpvCacheSeconds: 10, + mpvDemuxerMaxBytes: 150, // MB + mpvDemuxerMaxBackBytes: 50, // MB + // MPV video output driver defaults (Android only) + mpvVoDriver: "gpu-next", // Gesture controls enableHorizontalSwipeSkip: true, enableLeftSideBrightnessSwipe: true, @@ -283,8 +334,13 @@ export const defaultValues: Settings = { hideVolumeSlider: false, hideBrightnessSlider: false, usePopularPlugin: true, - showLargeHomeCarousel: false, mergeNextUpAndContinueWatching: false, + // TV-specific settings + showHomeBackdrop: true, + showTVHeroCarousel: true, + tvTypographyScale: TVTypographyScale.Default, + showSeriesPosterOnEpisode: false, + tvThemeMusicEnabled: true, // Appearance hideRemoteSessionButton: false, hideWatchlistsTab: false, @@ -296,6 +352,8 @@ export const defaultValues: Settings = { preferLocalAudio: true, // Audio transcoding mode audioTranscodeMode: AudioTranscodeMode.Auto, + // TV-only: Inactivity timeout (disabled by default) + inactivityTimeout: InactivityTimeout.Disabled, }; const loadSettings = (): Partial => { @@ -341,6 +399,14 @@ export const pluginSettingsAtom = atom( loadPluginSettings(), ); +const hasMeaningfulSettingValue = (value: unknown) => + value !== undefined && value !== null && value !== ""; + +const getEffectiveSettingValue = ( + settings: Partial | null | undefined, + settingsKey: K, +) => settings?.[settingsKey] ?? defaultValues[settingsKey]; + export const useSettings = () => { const api = useAtomValue(apiAtom); const [_settings, setSettings] = useAtom(settingsAtom); @@ -381,12 +447,13 @@ export const useSettings = () => { for (const [key, setting] of Object.entries(newPluginSettings)) { if (setting && !setting.locked && setting.value !== undefined) { const settingsKey = key as keyof Settings; - // Apply if forceOverride is true, or if user hasn't explicitly set this value - if ( - forceOverride || - _settings[settingsKey] === undefined || - _settings[settingsKey] === "" - ) { + const effectiveValue = getEffectiveSettingValue( + _settings, + settingsKey, + ); + // Apply if forceOverride is true, or if neither persisted settings + // nor app defaults provide a meaningful value. + if (forceOverride || !hasMeaningfulSettingValue(effectiveValue)) { (updates as any)[settingsKey] = setting.value; } } @@ -438,28 +505,22 @@ export const useSettings = () => { // We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting. // If admin sets locked to false but provides a value, - // use user settings first and fallback on admin setting if required. + // use persisted settings first, then app defaults, and only fallback on the + // plugin value when neither provides a meaningful value. const settings: Settings = useMemo(() => { - const unlockedPluginDefaults: Partial = {}; const overrideSettings = Object.entries(pluginSettings ?? {}).reduce< Partial >((acc, [key, setting]) => { if (setting) { const { value, locked } = setting; const settingsKey = key as keyof Settings; - - // Make sure we override default settings with plugin settings when they are not locked. - if ( - !locked && - value !== undefined && - _settings?.[settingsKey] !== value - ) { - (unlockedPluginDefaults as any)[settingsKey] = value; - } + const effectiveValue = getEffectiveSettingValue(_settings, settingsKey); (acc as any)[settingsKey] = locked ? value - : (_settings?.[settingsKey] ?? value); + : hasMeaningfulSettingValue(effectiveValue) + ? effectiveValue + : value; } return acc; }, {}); diff --git a/utils/atoms/tvAccountActionModal.ts b/utils/atoms/tvAccountActionModal.ts new file mode 100644 index 000000000..c9532a7fb --- /dev/null +++ b/utils/atoms/tvAccountActionModal.ts @@ -0,0 +1,14 @@ +import { atom } from "jotai"; +import type { + SavedServer, + SavedServerAccount, +} from "@/utils/secureCredentials"; + +export type TVAccountActionModalState = { + server: SavedServer; + account: SavedServerAccount; + onLogin: () => void; + onDelete: () => void; +} | null; + +export const tvAccountActionModalAtom = atom(null); diff --git a/utils/atoms/tvAccountSelectModal.ts b/utils/atoms/tvAccountSelectModal.ts new file mode 100644 index 000000000..9fd8bf20e --- /dev/null +++ b/utils/atoms/tvAccountSelectModal.ts @@ -0,0 +1,14 @@ +import { atom } from "jotai"; +import type { + SavedServer, + SavedServerAccount, +} from "@/utils/secureCredentials"; + +export type TVAccountSelectModalState = { + server: SavedServer; + onAccountAction: (account: SavedServerAccount) => void; + onAddAccount: () => void; + onDeleteServer: () => void; +} | null; + +export const tvAccountSelectModalAtom = atom(null); diff --git a/utils/atoms/tvOptionModal.ts b/utils/atoms/tvOptionModal.ts new file mode 100644 index 000000000..74bc2ab54 --- /dev/null +++ b/utils/atoms/tvOptionModal.ts @@ -0,0 +1,18 @@ +import { atom } from "jotai"; + +export type TVOptionItem = { + label: string; + sublabel?: string; + value: T; + selected: boolean; +}; + +export type TVOptionModalState = { + title: string; + options: TVOptionItem[]; + onSelect: (value: any) => void; + cardWidth?: number; + cardHeight?: number; +} | null; + +export const tvOptionModalAtom = atom(null); diff --git a/utils/atoms/tvRequestModal.ts b/utils/atoms/tvRequestModal.ts new file mode 100644 index 000000000..a1cd6ea7f --- /dev/null +++ b/utils/atoms/tvRequestModal.ts @@ -0,0 +1,13 @@ +import { atom } from "jotai"; +import type { MediaType } from "@/utils/jellyseerr/server/constants/media"; +import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; + +export type TVRequestModalState = { + requestBody: MediaRequestBody; + title: string; + id: number; + mediaType: MediaType; + onRequested: () => void; +} | null; + +export const tvRequestModalAtom = atom(null); diff --git a/utils/atoms/tvSeasonSelectModal.ts b/utils/atoms/tvSeasonSelectModal.ts new file mode 100644 index 000000000..d43a13ecf --- /dev/null +++ b/utils/atoms/tvSeasonSelectModal.ts @@ -0,0 +1,18 @@ +import { atom } from "jotai"; +import type { MediaStatus } from "@/utils/jellyseerr/server/constants/media"; + +export type TVSeasonSelectModalState = { + seasons: Array<{ + id: number; + seasonNumber: number; + episodeCount: number; + status: MediaStatus; + }>; + title: string; + mediaId: number; + tvdbId?: number; + hasAdvancedRequestPermission: boolean; + onRequested: () => void; +} | null; + +export const tvSeasonSelectModalAtom = atom(null); diff --git a/utils/atoms/tvSeriesSeasonModal.ts b/utils/atoms/tvSeriesSeasonModal.ts new file mode 100644 index 000000000..99e92193e --- /dev/null +++ b/utils/atoms/tvSeriesSeasonModal.ts @@ -0,0 +1,14 @@ +import { atom } from "jotai"; + +export type TVSeriesSeasonModalState = { + seasons: Array<{ + label: string; + value: number; + selected: boolean; + }>; + selectedSeasonIndex: number | string; + itemId: string; + onSeasonSelect: (seasonIndex: number) => void; +} | null; + +export const tvSeriesSeasonModalAtom = atom(null); diff --git a/utils/atoms/tvSubtitleModal.ts b/utils/atoms/tvSubtitleModal.ts new file mode 100644 index 000000000..3a940c12f --- /dev/null +++ b/utils/atoms/tvSubtitleModal.ts @@ -0,0 +1,16 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { atom } from "jotai"; +import type { Track } from "@/components/video-player/controls/types"; + +export type TVSubtitleModalState = { + item: BaseItemDto; + mediaSourceId?: string | null; + subtitleTracks: Track[]; + currentSubtitleIndex: number; + onDisableSubtitles?: () => void; + onServerSubtitleDownloaded?: () => void; + onLocalSubtitleDownloaded?: (path: string) => void; + refreshSubtitleTracks?: () => Promise; +} | null; + +export const tvSubtitleModalAtom = atom(null); diff --git a/utils/atoms/tvUserSwitchModal.ts b/utils/atoms/tvUserSwitchModal.ts new file mode 100644 index 000000000..2df72df10 --- /dev/null +++ b/utils/atoms/tvUserSwitchModal.ts @@ -0,0 +1,12 @@ +import { atom } from "jotai"; +import type { SavedServerAccount } from "@/utils/secureCredentials"; + +export type TVUserSwitchModalState = { + serverUrl: string; + serverName: string; + accounts: SavedServerAccount[]; + currentUserId: string; + onAccountSelect: (account: SavedServerAccount) => void; +} | null; + +export const tvUserSwitchModalAtom = atom(null); diff --git a/utils/chapters.test.ts b/utils/chapters.test.ts new file mode 100644 index 000000000..875bc7e2a --- /dev/null +++ b/utils/chapters.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "bun:test"; +import { + chapterMarkers, + chapterNameAt, + chapterStartsMs, + currentChapterIndex, + formatChapterTime, + sortedChapters, +} from "./chapters"; + +// Helper: a ChapterInfo with a start in milliseconds. +const ch = (ms: number, name?: string) => ({ + StartPositionTicks: ms * 10000, + Name: name, +}); + +describe("chapterMarkers", () => { + test("maps chapters to position + percent", () => { + expect(chapterMarkers([ch(0), ch(30_000), ch(60_000)], 120_000)).toEqual([ + { positionMs: 0, percent: 0 }, + { positionMs: 30_000, percent: 25 }, + { positionMs: 60_000, percent: 50 }, + ]); + }); + + test("drops chapters past the duration", () => { + expect(chapterMarkers([ch(0), ch(200_000)], 120_000)).toEqual([ + { positionMs: 0, percent: 0 }, + ]); + }); + + test("returns [] when duration is 0 or chapters missing", () => { + expect(chapterMarkers([ch(0)], 0)).toEqual([]); + expect(chapterMarkers(null, 120_000)).toEqual([]); + expect(chapterMarkers(undefined, 120_000)).toEqual([]); + }); + + test("excludes a chapter exactly at the duration", () => { + expect(chapterMarkers([ch(0), ch(120_000)], 120_000)).toEqual([ + { positionMs: 0, percent: 0 }, + ]); + }); + + test("skips chapters with no StartPositionTicks", () => { + expect( + chapterMarkers([{ StartPositionTicks: undefined }, ch(30_000)], 120_000), + ).toEqual([{ positionMs: 30_000, percent: 25 }]); + }); +}); + +describe("currentChapterIndex", () => { + const chapters = [ch(0), ch(30_000), ch(60_000)]; + test("returns the chapter containing the position", () => { + expect(currentChapterIndex(0, chapters)).toBe(0); + expect(currentChapterIndex(15_000, chapters)).toBe(0); + expect(currentChapterIndex(30_000, chapters)).toBe(1); + expect(currentChapterIndex(90_000, chapters)).toBe(2); + }); + test("returns -1 before the first chapter and for no chapters", () => { + expect(currentChapterIndex(-5, chapters)).toBe(-1); + expect(currentChapterIndex(10_000, [])).toBe(-1); + expect(currentChapterIndex(10_000, null)).toBe(-1); + }); +}); + +describe("sortedChapters", () => { + test("pairs each chapter with its ms start, sorted ascending", () => { + const a = ch(60_000, "C"); + const b = ch(0, "A"); + const c = ch(30_000, "B"); + expect(sortedChapters([a, b, c])).toEqual([ + { chapter: b, positionMs: 0 }, + { chapter: c, positionMs: 30_000 }, + { chapter: a, positionMs: 60_000 }, + ]); + }); + test("returns [] for null/undefined", () => { + expect(sortedChapters(null)).toEqual([]); + expect(sortedChapters(undefined)).toEqual([]); + }); +}); + +describe("chapterStartsMs", () => { + test("returns sorted ms positions", () => { + expect(chapterStartsMs([ch(60_000), ch(0), ch(30_000)])).toEqual([ + 0, 30_000, 60_000, + ]); + }); + + test("skips entries without StartPositionTicks", () => { + expect( + chapterStartsMs([ch(30_000), { StartPositionTicks: undefined }, ch(0)]), + ).toEqual([0, 30_000]); + }); + + test("returns [] for null/undefined/empty", () => { + expect(chapterStartsMs(null)).toEqual([]); + expect(chapterStartsMs(undefined)).toEqual([]); + expect(chapterStartsMs([])).toEqual([]); + }); +}); + +describe("chapterNameAt", () => { + const named = [ + { StartPositionTicks: 0, Name: "Intro" }, + { StartPositionTicks: 30_000 * 10000, Name: "Action" }, + { StartPositionTicks: 60_000 * 10000, Name: "Outro" }, + ]; + + test("returns the chapter name for the active position", () => { + expect(chapterNameAt(0, named)).toBe("Intro"); + expect(chapterNameAt(15_000, named)).toBe("Intro"); + expect(chapterNameAt(45_000, named)).toBe("Action"); + expect(chapterNameAt(90_000, named)).toBe("Outro"); + }); + + test("returns null before the first chapter", () => { + expect(chapterNameAt(-1, named)).toBeNull(); + }); + + test("returns null for null/undefined/empty chapters", () => { + expect(chapterNameAt(10_000, null)).toBeNull(); + expect(chapterNameAt(10_000, undefined)).toBeNull(); + expect(chapterNameAt(10_000, [])).toBeNull(); + }); + + test("returns null when the active chapter has no Name", () => { + expect(chapterNameAt(15_000, [ch(0), ch(30_000)])).toBeNull(); + }); +}); + +describe("formatChapterTime", () => { + test("formats m:ss and h:mm:ss", () => { + expect(formatChapterTime(65_000)).toBe("1:05"); + expect(formatChapterTime(3_725_000)).toBe("1:02:05"); + expect(formatChapterTime(-100)).toBe("0:00"); + }); +}); diff --git a/utils/chapters.ts b/utils/chapters.ts new file mode 100644 index 000000000..8b0e0e7bc --- /dev/null +++ b/utils/chapters.ts @@ -0,0 +1,97 @@ +/** + * Pure helpers for Jellyfin chapter markers. Dependency-free so they are + * unit-testable under `bun test`. + */ + +import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models"; +import { ticksToMs } from "@/utils/time"; + +export interface ChapterMarker { + /** Chapter start, in milliseconds. */ + positionMs: number; + /** Chapter start as a percentage (0-100) of the media duration. */ + percent: number; +} + +export interface ChapterEntry { + chapter: ChapterInfo; + /** Chapter start, in milliseconds. */ + positionMs: number; +} + +/** Chapters paired with their millisecond start, sorted ascending by start. */ +export const sortedChapters = ( + chapters: ChapterInfo[] | null | undefined, +): ChapterEntry[] => + (chapters ?? []) + .filter((c) => c.StartPositionTicks != null) + .map((chapter) => ({ + chapter, + positionMs: ticksToMs(chapter.StartPositionTicks), + })) + .sort((a, b) => a.positionMs - b.positionMs); + +/** Chapter start positions in milliseconds, ascending. */ +export const chapterStartsMs = ( + chapters: ChapterInfo[] | null | undefined, +): number[] => + (chapters ?? []) + .filter((c) => c.StartPositionTicks != null) + .map((c) => ticksToMs(c.StartPositionTicks)) + .sort((a, b) => a - b); + +/** Chapter markers within [0, durationMs]; empty when duration is unknown. */ +export const chapterMarkers = ( + chapters: ChapterInfo[] | null | undefined, + durationMs: number, +): ChapterMarker[] => { + if (durationMs <= 0) return []; + return chapterStartsMs(chapters) + .filter((ms) => ms >= 0 && ms < durationMs) + .map((ms) => ({ positionMs: ms, percent: (ms / durationMs) * 100 })); +}; + +/** Index of the chapter containing `positionMs`, or -1 if before the first. */ +export const currentChapterIndex = ( + positionMs: number, + chapters: ChapterInfo[] | null | undefined, +): number => { + const starts = chapterStartsMs(chapters); + let index = -1; + for (let i = 0; i < starts.length; i++) { + if (positionMs >= starts[i]) index = i; + else break; + } + return index; +}; + +/** Name of the chapter containing `positionMs`, or null if none / unnamed. */ +export const chapterNameAt = ( + positionMs: number, + chapters: ChapterInfo[] | null | undefined, +): string | null => { + // Sort once, derive both the active index and the entry from the same array + // — `chapterNameAt` runs on every playback tick, so paying for one `sort()` + // instead of two is worth the duplication of the index loop here. + const sorted = sortedChapters(chapters); + let idx = -1; + for (let i = 0; i < sorted.length; i++) { + if (positionMs >= sorted[i].positionMs) idx = i; + else break; + } + if (idx < 0) return null; + const name = sorted[idx]?.chapter.Name; + return name && name.length > 0 ? name : null; +}; + +/** `m:ss` (or `h:mm:ss` past an hour) label for a millisecond position. */ +export const formatChapterTime = (positionMs: number): string => { + const total = Math.max(0, Math.floor(positionMs / 1000)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + const pad = (n: number) => String(n).padStart(2, "0"); + return hours > 0 + ? `${hours}:${pad(minutes)}:${pad(seconds)}` + : `${minutes}:${pad(seconds)}`; +}; diff --git a/utils/jellyfin/audio/getAudioStreamUrl.ts b/utils/jellyfin/audio/getAudioStreamUrl.ts index df140d035..f8eb26292 100644 --- a/utils/jellyfin/audio/getAudioStreamUrl.ts +++ b/utils/jellyfin/audio/getAudioStreamUrl.ts @@ -1,7 +1,7 @@ import type { Api } from "@jellyfin/sdk"; import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; -import trackPlayerProfile from "@/utils/profiles/trackplayer"; +import trackPlayerProfile from "../../profiles/trackplayer"; export interface AudioStreamResult { url: string; diff --git a/utils/jellyfin/getDefaultPlaySettings.ts b/utils/jellyfin/getDefaultPlaySettings.ts index d5ac50328..b4da6b1e1 100644 --- a/utils/jellyfin/getDefaultPlaySettings.ts +++ b/utils/jellyfin/getDefaultPlaySettings.ts @@ -12,7 +12,9 @@ import type { BaseItemDto, MediaSourceInfo, + MediaStream, } from "@jellyfin/sdk/lib/generated-client"; +import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; import { BITRATES } from "@/components/BitrateSelector"; import { type Settings } from "../atoms/settings"; import { @@ -34,20 +36,152 @@ export interface PreviousIndexes { subtitleIndex?: number; } +export interface PlaySettingsOptions { + /** Apply language preferences from settings (used on TV) */ + applyLanguagePreferences?: boolean; +} + +/** + * Find a track by language code. + * + * @param streams - Available media streams + * @param languageCode - ISO 639-2 three-letter language code (e.g., "eng", "swe") + * @param streamType - Type of stream to search ("Audio" or "Subtitle") + * @param forcedOnly - If true, only match forced subtitles + * @returns The stream index if found, undefined otherwise + */ +function findTrackByLanguage( + streams: MediaStream[], + languageCode: string | undefined, + streamType: "Audio" | "Subtitle", + forcedOnly = false, +): number | undefined { + if (!languageCode) return undefined; + + const candidates = streams.filter((s) => { + if (s.Type !== streamType) return false; + if (forcedOnly && !s.IsForced) return false; + // Match on ThreeLetterISOLanguageName (ISO 639-2) + return ( + s.Language?.toLowerCase() === languageCode.toLowerCase() || + // Fallback: some Jellyfin servers use two-letter codes in Language field + s.Language?.toLowerCase() === languageCode.substring(0, 2).toLowerCase() + ); + }); + + // Prefer default track if multiple match + const defaultTrack = candidates.find((s) => s.IsDefault); + return defaultTrack?.Index ?? candidates[0]?.Index; +} + +/** + * Apply subtitle mode logic to determine the final subtitle index. + * + * @param streams - Available media streams + * @param settings - User settings containing subtitleMode + * @param defaultIndex - The current default subtitle index + * @param audioLanguage - The selected audio track's language (for Smart mode) + * @param subtitleLanguageCode - The user's preferred subtitle language + * @returns The final subtitle index (-1 for disabled) + */ +function applySubtitleMode( + streams: MediaStream[], + settings: Settings, + defaultIndex: number, + audioLanguage: string | undefined, + subtitleLanguageCode: string | undefined, +): number { + const subtitleStreams = streams.filter((s) => s.Type === "Subtitle"); + const mode = settings.subtitleMode ?? SubtitlePlaybackMode.Default; + + switch (mode) { + case SubtitlePlaybackMode.None: + // Always disable subtitles + return -1; + + case SubtitlePlaybackMode.OnlyForced: { + // Only show forced subtitles, prefer matching language + const forcedMatch = findTrackByLanguage( + streams, + subtitleLanguageCode, + "Subtitle", + true, + ); + if (forcedMatch !== undefined) return forcedMatch; + // Fallback to any forced subtitle + const anyForced = subtitleStreams.find((s) => s.IsForced); + return anyForced?.Index ?? -1; + } + + case SubtitlePlaybackMode.Always: { + // Always enable subtitles, prefer language match + const alwaysMatch = findTrackByLanguage( + streams, + subtitleLanguageCode, + "Subtitle", + ); + if (alwaysMatch !== undefined) return alwaysMatch; + // Fallback to first available or current default + return subtitleStreams[0]?.Index ?? defaultIndex; + } + + case SubtitlePlaybackMode.Smart: { + // Enable subtitles only when audio language differs from subtitle preference + if (audioLanguage && subtitleLanguageCode) { + const audioLang = audioLanguage.toLowerCase(); + const subLang = subtitleLanguageCode.toLowerCase(); + // If audio matches subtitle preference, disable subtitles + if ( + audioLang === subLang || + audioLang.startsWith(subLang.substring(0, 2)) || + subLang.startsWith(audioLang.substring(0, 2)) + ) { + return -1; + } + } + // Audio doesn't match preference, enable subtitles + const smartMatch = findTrackByLanguage( + streams, + subtitleLanguageCode, + "Subtitle", + ); + return smartMatch ?? subtitleStreams[0]?.Index ?? -1; + } + default: + // Use language preference if set, else keep Jellyfin default + if (subtitleLanguageCode) { + const langMatch = findTrackByLanguage( + streams, + subtitleLanguageCode, + "Subtitle", + ); + if (langMatch !== undefined) return langMatch; + } + return defaultIndex; + } +} + /** * Get default play settings for an item. * * @param item - The media item to play * @param settings - User settings (language preferences, bitrate, etc.) * @param previous - Optional previous track selections to carry over (for sequential play) + * @param options - Optional flags to control behavior (e.g., applyLanguagePreferences for TV) */ export function getDefaultPlaySettings( - item: BaseItemDto, + item: BaseItemDto | null | undefined, settings: Settings | null, previous?: { indexes?: PreviousIndexes; source?: MediaSourceInfo }, + options?: PlaySettingsOptions, ): PlaySettings { const bitrate = settings?.defaultBitrate ?? BITRATES[0]; + // Handle undefined/null item + if (!item) { + return { item: {} as BaseItemDto, bitrate }; + } + // Live TV programs don't have media sources if (item.Type === "Program") { return { item, bitrate }; @@ -60,6 +194,10 @@ export function getDefaultPlaySettings( let audioIndex = mediaSource?.DefaultAudioStreamIndex; let subtitleIndex = mediaSource?.DefaultSubtitleStreamIndex ?? -1; + // Track whether we matched previous selections (for language preference fallback) + let matchedPreviousAudio = false; + let matchedPreviousSubtitle = false; + // Try to match previous selections (sequential play) if (previous?.indexes && previous?.source && settings) { if ( @@ -67,14 +205,22 @@ export function getDefaultPlaySettings( previous.indexes.subtitleIndex !== undefined ) { const ranker = new StreamRanker(new SubtitleStreamRanker()); - const result = { DefaultSubtitleStreamIndex: subtitleIndex }; + const result = { + DefaultSubtitleStreamIndex: subtitleIndex, + matched: false, + }; ranker.rankStream( previous.indexes.subtitleIndex, previous.source, streams, result, ); - subtitleIndex = result.DefaultSubtitleStreamIndex; + // Use the ranker's explicit match signal — this also covers a deliberate + // "subtitles off" (-1) and the case where the match equals the default. + if (result.matched) { + subtitleIndex = result.DefaultSubtitleStreamIndex; + matchedPreviousSubtitle = true; + } } if ( @@ -82,14 +228,58 @@ export function getDefaultPlaySettings( previous.indexes.audioIndex !== undefined ) { const ranker = new StreamRanker(new AudioStreamRanker()); - const result = { DefaultAudioStreamIndex: audioIndex }; + const result = { DefaultAudioStreamIndex: audioIndex, matched: false }; ranker.rankStream( previous.indexes.audioIndex, previous.source, streams, result, ); - audioIndex = result.DefaultAudioStreamIndex; + // Use the ranker's explicit match signal + if (result.matched) { + audioIndex = result.DefaultAudioStreamIndex; + matchedPreviousAudio = true; + } + } + } + + // Apply language preferences when enabled (TV) and no previous selection matched + if (options?.applyLanguagePreferences && settings) { + const audioLanguageCode = + settings.defaultAudioLanguage?.ThreeLetterISOLanguageName ?? undefined; + const subtitleLanguageCode = + settings.defaultSubtitleLanguage?.ThreeLetterISOLanguageName ?? undefined; + + // Apply audio language preference if no previous selection matched + if (!matchedPreviousAudio && audioLanguageCode) { + const langMatch = findTrackByLanguage( + streams, + audioLanguageCode, + "Audio", + ); + if (langMatch !== undefined) { + audioIndex = langMatch; + } + } + + // Get the selected audio track's language for Smart mode + const selectedAudioTrack = streams.find( + (s) => s.Type === "Audio" && s.Index === audioIndex, + ); + const selectedAudioLanguage = + selectedAudioTrack?.Language ?? + selectedAudioTrack?.DisplayTitle ?? + undefined; + + // Apply subtitle mode logic if no previous selection matched + if (!matchedPreviousSubtitle) { + subtitleIndex = applySubtitleMode( + streams, + settings, + subtitleIndex, + selectedAudioLanguage, + subtitleLanguageCode, + ); } } diff --git a/utils/jellyfin/image/getUserImageUrl.ts b/utils/jellyfin/image/getUserImageUrl.ts new file mode 100644 index 000000000..89e51f6a1 --- /dev/null +++ b/utils/jellyfin/image/getUserImageUrl.ts @@ -0,0 +1,32 @@ +/** + * Retrieves the profile image URL for a Jellyfin user. + * + * @param serverAddress - The Jellyfin server base URL. + * @param userId - The user's ID. + * @param primaryImageTag - The user's primary image tag (required for the image to exist). + * @param width - The desired image width (default: 280). + * @returns The image URL or null if no image tag is provided. + */ +export const getUserImageUrl = ({ + serverAddress, + userId, + primaryImageTag, + width = 280, +}: { + serverAddress: string; + userId: string; + primaryImageTag?: string | null; + width?: number; +}): string | null => { + if (!primaryImageTag) { + return null; + } + + const params = new URLSearchParams({ + tag: primaryImageTag, + quality: "90", + width: String(width), + }); + + return `${serverAddress}/Users/${userId}/Images/Primary?${params.toString()}`; +}; diff --git a/utils/jellyfin/media/getDownloadUrl.ts b/utils/jellyfin/media/getDownloadUrl.ts index 223c73c14..a63353b2b 100644 --- a/utils/jellyfin/media/getDownloadUrl.ts +++ b/utils/jellyfin/media/getDownloadUrl.ts @@ -7,7 +7,7 @@ import { Bitrate } from "@/components/BitrateSelector"; import { type AudioTranscodeModeType, generateDeviceProfile, -} from "@/utils/profiles/native"; +} from "../../profiles/native"; import { getDownloadStreamUrl, getStreamUrl } from "./getStreamUrl"; export const getDownloadUrl = async ({ diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index c21247201..8fe02df0c 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -5,13 +5,14 @@ import type { } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models/base-item-kind"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; -import { generateDownloadProfile } from "@/utils/profiles/download"; -import type { AudioTranscodeModeType } from "@/utils/profiles/native"; +import { generateDownloadProfile } from "../../profiles/download"; +import type { AudioTranscodeModeType } from "../../profiles/native"; interface StreamResult { url: string; sessionId: string | null; mediaSource: MediaSourceInfo | undefined; + requiredHttpHeaders?: Record; } /** @@ -50,10 +51,24 @@ const getPlaybackUrl = ( return `${api.basePath}${transcodeUrl}`; } + // Handle remote/external streams (like live TV with external URLs) + // These have Protocol "Http" and IsRemote true, with the actual URL in Path + if ( + mediaSource?.IsRemote && + mediaSource?.Protocol === "Http" && + mediaSource?.Path + ) { + console.log("Video is remote stream, using direct Path:", mediaSource.Path); + return mediaSource.Path; + } + // Fall back to direct play + // Use the mediaSource's actual container when available (important for live TV + // where the container may be ts/hls, not mp4) + const container = params.container || mediaSource?.Container || "mp4"; const streamParams = new URLSearchParams({ static: params.static || "true", - container: params.container || "mp4", + container, mediaSourceId: mediaSource?.Id || "", subtitleStreamIndex: params.subtitleStreamIndex?.toString() || "", audioStreamIndex: params.audioStreamIndex?.toString() || "", @@ -163,6 +178,7 @@ export const getStreamUrl = async ({ url: string | null; sessionId: string | null; mediaSource: MediaSourceInfo | undefined; + requiredHttpHeaders?: Record; } | null> => { if (!api || !userId || !item?.Id) { console.warn("Missing required parameters for getStreamUrl"); @@ -210,6 +226,9 @@ export const getStreamUrl = async ({ url, sessionId: sessionId || null, mediaSource, + requiredHttpHeaders: mediaSource?.RequiredHttpHeaders as + | Record + | undefined, }; } @@ -254,6 +273,9 @@ export const getStreamUrl = async ({ url, sessionId: sessionId || null, mediaSource, + requiredHttpHeaders: mediaSource?.RequiredHttpHeaders as + | Record + | undefined, }; }; diff --git a/utils/opensubtitles/api.ts b/utils/opensubtitles/api.ts new file mode 100644 index 000000000..230591985 --- /dev/null +++ b/utils/opensubtitles/api.ts @@ -0,0 +1,289 @@ +/** + * OpenSubtitles REST API Client + * Docs: https://opensubtitles.stoplight.io/docs/opensubtitles-api + * + * This is a fallback for when the Jellyfin server doesn't have a subtitle provider configured. + */ + +const OPENSUBTITLES_API_URL = "https://api.opensubtitles.com/api/v1"; + +export interface OpenSubtitlesSearchParams { + /** IMDB ID (without "tt" prefix) */ + imdbId?: string; + /** Title for text search */ + query?: string; + /** Year of release */ + year?: number; + /** ISO 639-2B language code (e.g., "eng", "spa") */ + languages?: string; + /** Season number for TV shows */ + seasonNumber?: number; + /** Episode number for TV shows */ + episodeNumber?: number; +} + +export interface OpenSubtitlesFile { + file_id: number; + file_name: string; +} + +export interface OpenSubtitlesFeatureDetails { + imdb_id: number; + title: string; + year: number; + feature_type: string; + season_number?: number; + episode_number?: number; +} + +export interface OpenSubtitlesAttributes { + subtitle_id: string; + language: string; + download_count: number; + hearing_impaired: boolean; + ai_translated: boolean; + machine_translated: boolean; + fps: number; + format: string; + from_trusted: boolean; + foreign_parts_only: boolean; + release: string; + files: OpenSubtitlesFile[]; + feature_details: OpenSubtitlesFeatureDetails; + ratings: number; +} + +export interface OpenSubtitlesResult { + id: string; + type: string; + attributes: OpenSubtitlesAttributes; +} + +export interface OpenSubtitlesSearchResponse { + total_count: number; + total_pages: number; + page: number; + data: OpenSubtitlesResult[]; +} + +export interface OpenSubtitlesDownloadResponse { + link: string; + file_name: string; + requests: number; + remaining: number; + message: string; + reset_time: string; + reset_time_utc: string; +} + +export class OpenSubtitlesApiError extends Error { + constructor( + message: string, + public statusCode?: number, + public response?: unknown, + ) { + super(message); + this.name = "OpenSubtitlesApiError"; + } +} + +/** + * Mapping between ISO 639-1 (2-letter) and ISO 639-2B (3-letter) language codes + */ +const ISO_639_MAPPING: Record = { + en: "eng", + es: "spa", + fr: "fre", + de: "ger", + it: "ita", + pt: "por", + ru: "rus", + ja: "jpn", + ko: "kor", + zh: "chi", + ar: "ara", + pl: "pol", + nl: "dut", + sv: "swe", + no: "nor", + da: "dan", + fi: "fin", + tr: "tur", + cs: "cze", + el: "gre", + he: "heb", + hu: "hun", + ro: "rum", + th: "tha", + vi: "vie", + id: "ind", + ms: "may", + bg: "bul", + hr: "hrv", + sk: "slo", + sl: "slv", + uk: "ukr", +}; + +// Reverse mapping: 3-letter to 2-letter +const ISO_639_REVERSE: Record = Object.fromEntries( + Object.entries(ISO_639_MAPPING).map(([k, v]) => [v, k]), +); + +/** + * Convert ISO 639-2B (3-letter) to ISO 639-1 (2-letter) language code + * OpenSubtitles REST API uses 2-letter codes + */ +function toIso6391(code: string): string { + if (code.length === 2) return code; + return ISO_639_REVERSE[code.toLowerCase()] || code; +} + +/** + * OpenSubtitles API client for direct subtitle fetching + */ +export class OpenSubtitlesApi { + private apiKey: string; + private userAgent: string; + + constructor(apiKey: string, userAgent = "streamyfin v1.0") { + this.apiKey = apiKey; + this.userAgent = userAgent; + } + + private async request( + endpoint: string, + options: RequestInit = {}, + ): Promise { + const url = `${OPENSUBTITLES_API_URL}${endpoint}`; + const headers: HeadersInit = { + "Api-Key": this.apiKey, + "Content-Type": "application/json", + "User-Agent": this.userAgent, + ...options.headers, + }; + + const response = await fetch(url, { + ...options, + headers, + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new OpenSubtitlesApiError( + `OpenSubtitles API error: ${response.status} ${response.statusText}`, + response.status, + errorBody, + ); + } + + return response.json(); + } + + /** + * Search for subtitles + * Rate limit: 40 requests / 10 seconds + */ + async search( + params: OpenSubtitlesSearchParams, + ): Promise { + const queryParams = new URLSearchParams(); + + if (params.imdbId) { + // Ensure IMDB ID has "tt" prefix + const imdbId = params.imdbId.startsWith("tt") + ? params.imdbId + : `tt${params.imdbId}`; + queryParams.set("imdb_id", imdbId); + } + if (params.query) { + queryParams.set("query", params.query); + } + if (params.year) { + queryParams.set("year", params.year.toString()); + } + if (params.languages) { + // Convert 3-letter codes to 2-letter codes (API uses ISO 639-1) + const lang = + params.languages.length === 3 + ? toIso6391(params.languages) + : params.languages; + queryParams.set("languages", lang); + } + if (params.seasonNumber !== undefined) { + queryParams.set("season_number", params.seasonNumber.toString()); + } + if (params.episodeNumber !== undefined) { + queryParams.set("episode_number", params.episodeNumber.toString()); + } + + return this.request( + `/subtitles?${queryParams.toString()}`, + ); + } + + /** + * Get download link for a subtitle file + * Rate limits: + * - Anonymous: 5 downloads/day + * - Authenticated: 10 downloads/day (can be increased) + */ + async download(fileId: number): Promise { + return this.request("/download", { + method: "POST", + body: JSON.stringify({ file_id: fileId }), + }); + } +} + +/** + * Convert ISO 639-2B (3-letter) to ISO 639-1 (2-letter) language code + * Exported for external use + */ +export { toIso6391 }; + +/** + * Convert ISO 639-1 (2-letter) to ISO 639-2B (3-letter) language code + */ +export function toIso6392B(code: string): string { + if (code.length === 3) return code; + return ISO_639_MAPPING[code.toLowerCase()] || code; +} + +/** + * Common subtitle languages for display + */ +export const COMMON_SUBTITLE_LANGUAGES = [ + { code: "eng", name: "English" }, + { code: "spa", name: "Spanish" }, + { code: "fre", name: "French" }, + { code: "ger", name: "German" }, + { code: "ita", name: "Italian" }, + { code: "por", name: "Portuguese" }, + { code: "rus", name: "Russian" }, + { code: "jpn", name: "Japanese" }, + { code: "kor", name: "Korean" }, + { code: "chi", name: "Chinese" }, + { code: "ara", name: "Arabic" }, + { code: "pol", name: "Polish" }, + { code: "dut", name: "Dutch" }, + { code: "swe", name: "Swedish" }, + { code: "nor", name: "Norwegian" }, + { code: "dan", name: "Danish" }, + { code: "fin", name: "Finnish" }, + { code: "tur", name: "Turkish" }, + { code: "cze", name: "Czech" }, + { code: "gre", name: "Greek" }, + { code: "heb", name: "Hebrew" }, + { code: "hun", name: "Hungarian" }, + { code: "rom", name: "Romanian" }, + { code: "tha", name: "Thai" }, + { code: "vie", name: "Vietnamese" }, + { code: "ind", name: "Indonesian" }, + { code: "may", name: "Malay" }, + { code: "bul", name: "Bulgarian" }, + { code: "hrv", name: "Croatian" }, + { code: "slo", name: "Slovak" }, + { code: "slv", name: "Slovenian" }, + { code: "ukr", name: "Ukrainian" }, +]; diff --git a/utils/pairingService.ts b/utils/pairingService.ts new file mode 100644 index 000000000..d666f6b6a --- /dev/null +++ b/utils/pairingService.ts @@ -0,0 +1,144 @@ +import dgram from "react-native-udp"; + +const PAIRING_PORT = 54322; +const PAIRING_MESSAGE_TYPE = "streamyfin-pair-response"; + +export interface PairingCredentials { + serverUrl: string; + username: string; + password: string; +} + +export function generatePairingCode(): string { + return String(Math.floor(100000 + Math.random() * 900000)); +} + +export function startPairingListener( + code: string, + onCredentialsReceived: (credentials: PairingCredentials) => void, + onError?: (error: string) => void, +): () => void { + let active = true; + + const socket = dgram.createSocket({ + type: "udp4", + reusePort: true, + debug: __DEV__, + }); + + socket.on("error", (err) => { + if (__DEV__) console.error("[PairingService] Socket error:", err); + onError?.(err.message); + cleanup(); + }); + + socket.bind(PAIRING_PORT, () => { + if (__DEV__) + console.log("[PairingService] Listening on port", PAIRING_PORT); + }); + + socket.on("message", (msg) => { + if (!active) return; + + try { + const data = JSON.parse(new TextDecoder().decode(msg)); + + if (data.type !== PAIRING_MESSAGE_TYPE) return; + if (data.code !== code) return; + + if (!data.server_url || !data.username || !data.password) { + if (__DEV__) + console.error("[PairingService] Missing fields in pairing response"); + return; + } + + active = false; + onCredentialsReceived({ + serverUrl: data.server_url, + username: data.username, + password: data.password, + }); + cleanup(); + } catch (error) { + if (__DEV__) + console.error("[PairingService] Error parsing message:", error); + } + }); + + function cleanup() { + active = false; + try { + socket.close(); + } catch { + // Socket may already be closed + } + } + + return cleanup; +} + +export function sendCredentialsToTV( + code: string, + serverUrl: string, + username: string, + password: string, +): Promise { + return new Promise((resolve, reject) => { + const socket = dgram.createSocket({ + type: "udp4", + reusePort: true, + debug: __DEV__, + }); + + const message = JSON.stringify({ + type: PAIRING_MESSAGE_TYPE, + code, + server_url: serverUrl, + username, + password, + }); + + const messageBuffer = new TextEncoder().encode(message); + + socket.on("error", (err) => { + reject(err); + try { + socket.close(); + } catch { + // Ignore + } + }); + + socket.bind(0, () => { + try { + socket.setBroadcast(true); + socket.send( + messageBuffer, + 0, + messageBuffer.length, + PAIRING_PORT, + "255.255.255.255", + (err) => { + try { + socket.close(); + } catch { + // Ignore + } + if (err) { + reject(err); + } else { + resolve(); + } + }, + ); + } catch (error) { + try { + socket.close(); + } catch { + // Ignore + } + reject(error); + } + }); + }); +} diff --git a/utils/profiles/index.ts b/utils/profiles/index.ts new file mode 100644 index 000000000..9ec48ada3 --- /dev/null +++ b/utils/profiles/index.ts @@ -0,0 +1,6 @@ +export { chromecast } from "./chromecast"; +export { chromecasth265 } from "./chromecasth265"; +export { generateDownloadProfile } from "./download"; +export * from "./native"; +export { default } from "./native"; +export { default as trackPlayerProfile } from "./trackplayer"; diff --git a/utils/profiles/native.d.ts b/utils/profiles/native.d.ts deleted file mode 100644 index 43489710e..000000000 --- a/utils/profiles/native.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -export type PlatformType = "ios" | "android"; -export type PlayerType = "mpv"; -export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough"; - -export interface ProfileOptions { - /** Target platform */ - platform?: PlatformType; - /** Video player being used */ - player?: PlayerType; - /** Audio transcoding mode */ - audioMode?: AudioTranscodeModeType; -} - -export function generateDeviceProfile(options?: ProfileOptions): any; - -declare const _default: any; -export default _default; diff --git a/utils/profiles/native.js b/utils/profiles/native.ts similarity index 83% rename from utils/profiles/native.js rename to utils/profiles/native.ts index ec74f4b60..9d7224ff9 100644 --- a/utils/profiles/native.js +++ b/utils/profiles/native.ts @@ -7,22 +7,24 @@ import { Platform } from "react-native"; import MediaTypes from "../../constants/MediaTypes"; import { getSubtitleProfiles } from "./subtitles"; -/** - * @typedef {"ios" | "android"} PlatformType - * @typedef {"mpv"} PlayerType - * @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType - * - * @typedef {Object} ProfileOptions - * @property {PlatformType} [platform] - Target platform - * @property {PlayerType} [player] - Video player being used (MPV only) - * @property {AudioTranscodeModeType} [audioMode] - Audio transcoding mode - */ +export type PlatformType = "ios" | "android"; +export type PlayerType = "mpv"; +export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough"; + +export interface ProfileOptions { + /** Target platform */ + platform?: PlatformType; + /** Video player being used */ + player?: PlayerType; + /** Audio transcoding mode */ + audioMode?: AudioTranscodeModeType; +} /** * Audio direct play profiles for standalone audio items in MPV player. * These define which audio file formats can be played directly without transcoding. */ -const getAudioDirectPlayProfile = (platform) => { +const getAudioDirectPlayProfile = (platform: PlatformType) => { if (platform === "ios") { // iOS audio formats supported by MPV return { @@ -44,7 +46,7 @@ const getAudioDirectPlayProfile = (platform) => { * Audio codec profiles for standalone audio items in MPV player. * These define codec constraints for audio file playback. */ -const getAudioCodecProfile = (platform) => { +const getAudioCodecProfile = (platform: PlatformType) => { if (platform === "ios") { // iOS audio codec constraints for MPV return { @@ -66,12 +68,11 @@ const getAudioCodecProfile = (platform) => { * MPV (via FFmpeg) can decode all audio codecs including TrueHD and DTS-HD MA. * The audioMode setting only controls the maximum channel count - MPV will * decode and downmix as needed. - * - * @param {PlatformType} platform - * @param {AudioTranscodeModeType} audioMode - * @returns {{ directPlayCodec: string, maxAudioChannels: string }} */ -const getVideoAudioCodecs = (platform, audioMode) => { +const getVideoAudioCodecs = ( + platform: PlatformType, + audioMode: AudioTranscodeModeType, +): { directPlayCodec: string; maxAudioChannels: string } => { // Base codecs const baseCodecs = "aac,mp3,flac,opus,vorbis"; @@ -120,12 +121,9 @@ const getVideoAudioCodecs = (platform, audioMode) => { /** * Generates a device profile for Jellyfin playback. - * - * @param {ProfileOptions} [options] - Profile configuration options - * @returns {Object} Jellyfin device profile */ -export const generateDeviceProfile = (options = {}) => { - const platform = options.platform || Platform.OS; +export const generateDeviceProfile = (options: ProfileOptions = {}) => { + const platform = (options.platform || Platform.OS) as PlatformType; const audioMode = options.audioMode || "auto"; const { directPlayCodec, maxAudioChannels } = getVideoAudioCodecs( diff --git a/utils/scaleSize.ts b/utils/scaleSize.ts new file mode 100644 index 000000000..09cc9a568 --- /dev/null +++ b/utils/scaleSize.ts @@ -0,0 +1,9 @@ +import { Dimensions } from "react-native"; + +const { width: W, height: H } = Dimensions.get("window"); + +export const scaleSize = (size: number): number => { + const widthRatio = W / 1920; + const heightRatio = H / 1080; + return size * Math.min(widthRatio, heightRatio); +}; diff --git a/utils/secureCredentials.ts b/utils/secureCredentials.ts index f64a56d7e..bb5f7713e 100644 --- a/utils/secureCredentials.ts +++ b/utils/secureCredentials.ts @@ -22,6 +22,7 @@ export interface ServerCredential { savedAt: number; securityType: AccountSecurityType; pinHash?: string; + primaryImageTag?: string; } /** @@ -32,6 +33,7 @@ export interface SavedServerAccount { username: string; securityType: AccountSecurityType; savedAt: number; + primaryImageTag?: string; } /** @@ -131,6 +133,7 @@ export async function saveAccountCredential( username: credential.username, securityType: credential.securityType, savedAt: credential.savedAt, + primaryImageTag: credential.primaryImageTag, }); } @@ -224,7 +227,7 @@ export async function clearAllCredentials(): Promise { /** * Add or update an account in a server's accounts list. */ -function addAccountToServer( +export function addAccountToServer( serverUrl: string, serverName: string, account: SavedServerAccount, @@ -475,19 +478,32 @@ export async function migrateToMultiAccount(): Promise { } /** - * Update account's token after successful login. + * Update account's token and optionally other fields after successful login. */ export async function updateAccountToken( serverUrl: string, userId: string, newToken: string, + primaryImageTag?: string, ): Promise { const credential = await getAccountCredential(serverUrl, userId); if (credential) { credential.token = newToken; credential.savedAt = Date.now(); + if (primaryImageTag !== undefined) { + credential.primaryImageTag = primaryImageTag; + } const key = credentialKey(serverUrl, userId); await SecureStore.setItemAsync(key, JSON.stringify(credential)); + + // Also update the account info in the server list + addAccountToServer(serverUrl, credential.serverName, { + userId: credential.userId, + username: credential.username, + securityType: credential.securityType, + savedAt: credential.savedAt, + primaryImageTag: credential.primaryImageTag, + }); } } diff --git a/utils/streamRanker.ts b/utils/streamRanker.ts index 8121adea9..6e90cfc0e 100644 --- a/utils/streamRanker.ts +++ b/utils/streamRanker.ts @@ -13,6 +13,42 @@ abstract class StreamRankerStrategy { trackOptions: any, ): void; + /** + * Score how well a candidate stream matches the previously selected stream. + * Overridable so subtitle ranking can add mode (forced / hearing-impaired) + * awareness without changing audio behavior. + */ + protected computeScore( + prevStream: MediaStream, + stream: MediaStream, + prevRelIndex: number, + newRelIndex: number, + ): number { + let score = 0; + + if (prevStream.Codec === stream.Codec) { + score += 1; + } + if (prevRelIndex === newRelIndex) { + score += 1; + } + if ( + prevStream.DisplayTitle && + prevStream.DisplayTitle === stream.DisplayTitle + ) { + score += 2; + } + if ( + prevStream.Language && + prevStream.Language !== "und" && + prevStream.Language === stream.Language + ) { + score += 2; + } + + return score; + } + protected rank( prevIndex: number, prevSource: MediaSourceInfo, @@ -22,6 +58,9 @@ abstract class StreamRankerStrategy { if (prevIndex === -1) { console.debug("AutoSet Subtitle - No Stream Set"); trackOptions[`Default${this.streamType}StreamIndex`] = -1; + // A deliberate "off" selection is a valid match to retain — flag it so + // callers don't fall back to language preferences / subtitle mode. + trackOptions.matched = true; return; } @@ -63,27 +102,12 @@ abstract class StreamRankerStrategy { continue; } - let score = 0; - - if (prevStream.Codec === stream.Codec) { - score += 1; - } - if (prevRelIndex === newRelIndex) { - score += 1; - } - if ( - prevStream.DisplayTitle && - prevStream.DisplayTitle === stream.DisplayTitle - ) { - score += 2; - } - if ( - prevStream.Language && - prevStream.Language !== "und" && - prevStream.Language === stream.Language - ) { - score += 2; - } + const score = this.computeScore( + prevStream, + stream, + prevRelIndex, + newRelIndex, + ); console.debug( `AutoSet ${this.streamType} - Score ${score} for ${stream.Index} - ${stream.DisplayTitle}`, @@ -101,6 +125,7 @@ abstract class StreamRankerStrategy { `AutoSet ${this.streamType} - Using ${bestStreamIndex} score ${bestStreamScore}.`, ); trackOptions[`Default${this.streamType}StreamIndex`] = bestStreamIndex; + trackOptions.matched = true; } else { console.debug( `AutoSet ${this.streamType} - Threshold not met. Using default.`, @@ -112,6 +137,67 @@ abstract class StreamRankerStrategy { class SubtitleStreamRanker extends StreamRankerStrategy { streamType = "Subtitle"; + /** + * Subtitle scoring that retains both language and mode across episodes. + * + * - When the previous track has a language: a language match is weighted high + * (+3) so it clears the threshold even when codec / title / position differ, + * and mode (forced / hearing-impaired) acts as a tiebreaker among + * same-language tracks. Different-language candidates get no language or mode + * points, so they can never be selected on mode alone (no cross-language + * hijack). + * - When the previous track has NO usable language (common for SRT/SUBRIP): + * language can't help, so mode (forced / hearing-impaired) + codec + relative + * position become the identity signal. Without this, unlabeled subtitles + * score only codec+relIndex (≤2) and the selection is silently lost. + */ + protected computeScore( + prevStream: MediaStream, + stream: MediaStream, + prevRelIndex: number, + newRelIndex: number, + ): number { + let score = 0; + + if (prevStream.Codec === stream.Codec) { + score += 1; + } + if (prevRelIndex === newRelIndex) { + score += 1; + } + if ( + prevStream.DisplayTitle && + prevStream.DisplayTitle === stream.DisplayTitle + ) { + score += 2; + } + + const prevHasLanguage = + !!prevStream.Language && prevStream.Language !== "und"; + const languageMatches = + prevHasLanguage && prevStream.Language === stream.Language; + + if (languageMatches) { + score += 3; + } else if (prevHasLanguage) { + // Previous track had a language but this candidate's differs — do not award + // mode points, so a different language is never matched on mode alone. + return score; + } + + // Either the language matched, or the previous track had no language (so mode + // is the primary identity). Normalize the flags to booleans since + // IsForced / IsHearingImpaired may be undefined. + if (!!prevStream.IsForced === !!stream.IsForced) { + score += 2; + } + if (!!prevStream.IsHearingImpaired === !!stream.IsHearingImpaired) { + score += 1; + } + + return score; + } + rankStream( prevIndex: number, prevSource: MediaSourceInfo, @@ -156,4 +242,4 @@ class StreamRanker { } } -export { StreamRanker, SubtitleStreamRanker, AudioStreamRanker }; +export { AudioStreamRanker, StreamRanker, SubtitleStreamRanker }; diff --git a/utils/topshelf/cache.ts b/utils/topshelf/cache.ts new file mode 100644 index 000000000..78f220c8e --- /dev/null +++ b/utils/topshelf/cache.ts @@ -0,0 +1,57 @@ +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Platform } from "react-native"; +import { clearTopShelfCache, writeTopShelfCache } from "@/modules"; +import { + buildTVDiscoveryPayload, + type TVDiscoveryPayload, +} from "@/utils/tvDiscovery/payload"; + +export function updateTopShelfCache({ + api, + sections, +}: { + api: Api | null | undefined; + sections: Array<{ title: string; items: BaseItemDto[] | undefined }>; +}): void { + if (Platform.OS !== "ios" || !Platform.isTV) return; + + const payload = buildTVDiscoveryPayload({ api, sections }); + if (!payload) { + clearTopShelfCacheSafely(); + return; + } + + writeTopShelfPayload(payload, api?.accessToken || undefined); +} + +export function writeTopShelfPayload( + payload: TVDiscoveryPayload, + apiKey?: string, +): void { + if (Platform.OS !== "ios" || !Platform.isTV) return; + + try { + const didWrite = writeTopShelfCache(JSON.stringify(payload), apiKey); + + if (!didWrite) { + console.warn("[TopShelf] Native cache writer is unavailable"); + } + } catch (error) { + console.warn("[TopShelf] Failed to write cache", error); + } +} + +export function clearTopShelfCacheSafely(): void { + if (Platform.OS !== "ios" || !Platform.isTV) return; + + try { + const didClear = clearTopShelfCache(); + + if (!didClear) { + console.warn("[TopShelf] Native cache clearer is unavailable"); + } + } catch (error) { + console.warn("[TopShelf] Failed to clear cache", error); + } +} diff --git a/utils/tvDiscovery/payload.ts b/utils/tvDiscovery/payload.ts new file mode 100644 index 000000000..a9ac0fc7b --- /dev/null +++ b/utils/tvDiscovery/payload.ts @@ -0,0 +1,173 @@ +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; + +const TV_DISCOVERY_ITEM_LIMIT = 12; +const TV_DISCOVERY_SECTION_LIMIT = 3; + +export interface TVDiscoveryItem { + id: string; + itemType?: string; + title: string; + subtitle?: string; + imageUrl?: string; + route: string; + playRoute?: string; +} + +export interface TVDiscoverySection { + title: string; + items: TVDiscoveryItem[]; +} + +export interface TVDiscoveryPayload { + version: 1; + updatedAt: string; + sections: TVDiscoverySection[]; +} + +function getTVDiscoveryImage( + item: BaseItemDto, + api: Api, +): { url: string } | undefined { + const baseUrl = api.basePath; + + // 1. Episode backdrop + const episodeBackdrop = item.BackdropImageTags?.[0]; + if (item.Id && episodeBackdrop) { + return { + url: + `${baseUrl}/Items/${item.Id}/Images/Backdrop/0` + + `?fillWidth=1920` + + `&fillHeight=1080` + + `&quality=90` + + `&tag=${encodeURIComponent(episodeBackdrop)}`, + }; + } + + // 2. Series backdrop + if (item.SeriesId) { + return { + url: + `${baseUrl}/Items/${item.SeriesId}/Images/Backdrop` + + `?fillWidth=1920` + + `&fillHeight=1080` + + `&quality=90`, + }; + } + + // 3. Generic item backdrop + const backdrop = item.BackdropImageTags?.[0]; + if (item.Id && backdrop) { + return { + url: + `${baseUrl}/Items/${item.Id}/Images/Backdrop/0` + + `?fillWidth=1920` + + `&fillHeight=1080` + + `&quality=90` + + `&tag=${encodeURIComponent(backdrop)}`, + }; + } + + // 4. Last resort: crop poster into landscape + const primaryTag = item.ImageTags?.Primary; + if (item.Id && primaryTag) { + return { + url: + `${baseUrl}/Items/${item.Id}/Images/Primary` + + `?fillWidth=1920` + + `&fillHeight=1080` + + `&quality=90` + + `&tag=${encodeURIComponent(primaryTag)}`, + }; + } + + return undefined; +} + +function formatEpisodeNumber(item: BaseItemDto): string | undefined { + const season = item.ParentIndexNumber; + const episode = item.IndexNumber; + + if (season != null && episode != null) { + return `S${season} • E${episode}`; + } + + if (season != null) return `Season ${season}`; + if (episode != null) return `Episode ${episode}`; + + return undefined; +} + +function getTVDiscoveryTitle(item: BaseItemDto): string { + if (item.Type === "Episode") { + const episodeNumber = formatEpisodeNumber(item); + + if (item.SeriesName && episodeNumber) { + return `${item.SeriesName} - ${episodeNumber}`; + } + + if (item.SeriesName) return item.SeriesName; + if (episodeNumber) return episodeNumber; + return item.Name || ""; + } + + return item.Name || ""; +} + +function getTVDiscoverySubtitle(item: BaseItemDto): string | undefined { + if (item.Type === "Episode") return undefined; + + return item.ProductionYear ? String(item.ProductionYear) : item.Type; +} + +function sectionFromItems( + title: string, + items: BaseItemDto[] | undefined, + api: Api, +): TVDiscoverySection | null { + const payloadItems = (items || []) + .filter((item) => item.Id && item.Name) + .slice(0, TV_DISCOVERY_ITEM_LIMIT) + .map((item) => { + const image = getTVDiscoveryImage(item, api); + return { + id: item.Id!, + itemType: item.Type || undefined, + title: getTVDiscoveryTitle(item), + subtitle: getTVDiscoverySubtitle(item), + imageUrl: image?.url, + route: `streamyfin://topshelf/item?id=${encodeURIComponent(item.Id!)}&type=${encodeURIComponent(item.Type || "")}`, + playRoute: `streamyfin://topshelf/play?id=${encodeURIComponent(item.Id!)}`, + }; + }); + + if (payloadItems.length === 0) return null; + + return { + title, + items: payloadItems, + }; +} + +export function buildTVDiscoveryPayload({ + api, + sections, +}: { + api: Api | null | undefined; + sections: Array<{ title: string; items: BaseItemDto[] | undefined }>; +}): TVDiscoveryPayload | null { + if (!api) return null; + + const payloadSections = sections + .map((section) => sectionFromItems(section.title, section.items, api)) + .filter((section): section is TVDiscoverySection => section !== null) + .slice(0, TV_DISCOVERY_SECTION_LIMIT); + + if (payloadSections.length === 0) return null; + + return { + version: 1, + updatedAt: new Date().toISOString(), + sections: payloadSections, + }; +} diff --git a/utils/tvDiscovery/sync.ts b/utils/tvDiscovery/sync.ts new file mode 100644 index 000000000..4b92602fc --- /dev/null +++ b/utils/tvDiscovery/sync.ts @@ -0,0 +1,88 @@ +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Platform } from "react-native"; +import { clearTvRecommendations, syncTvRecommendations } from "@/modules"; +import { + clearTopShelfCacheSafely, + writeTopShelfPayload, +} from "@/utils/topshelf/cache"; +import { buildTVDiscoveryPayload } from "./payload"; + +export function updateTVDiscovery({ + api, + sections, +}: { + api: Api | null | undefined; + sections: Array<{ title: string; items: BaseItemDto[] | undefined }>; +}): void { + if (!Platform.isTV) return; + + const payload = buildTVDiscoveryPayload({ api, sections }); + + if (!payload) { + console.log("[TVDiscovery] No payload generated; clearing TV discovery"); + clearTVDiscoverySafely(); + return; + } + + const sectionSummary = payload.sections + .map((section) => `${section.title}:${section.items.length}`) + .join(", "); + console.log( + `[TVDiscovery] Sync payload prepared for ${Platform.OS} TV with ${payload.sections.length} section(s): ${sectionSummary}`, + ); + + if (Platform.OS === "ios") { + writeTopShelfPayload(payload, api?.accessToken || undefined); + return; + } + + if (Platform.OS === "android") { + try { + const didSync = syncTvRecommendations(JSON.stringify(payload)); + + console.log(`[TVDiscovery] Android sync result: ${didSync}`); + + if (!didSync) { + console.warn( + "[TVDiscovery] Android recommendations sync is unavailable", + ); + } + } catch (error) { + console.warn( + "[TVDiscovery] Failed to sync Android recommendations", + error, + ); + } + } +} + +export function clearTVDiscoverySafely(): void { + if (!Platform.isTV) return; + + console.log(`[TVDiscovery] Clearing TV discovery for ${Platform.OS} TV`); + + if (Platform.OS === "ios") { + clearTopShelfCacheSafely(); + return; + } + + if (Platform.OS === "android") { + try { + const didClear = clearTvRecommendations(); + + console.log(`[TVDiscovery] Android clear result: ${didClear}`); + + if (!didClear) { + console.warn( + "[TVDiscovery] Android recommendations clearer is unavailable", + ); + } + } catch (error) { + console.warn( + "[TVDiscovery] Failed to clear Android recommendations", + error, + ); + } + } +} diff --git a/utils/useReactNavigationQuery.ts b/utils/useReactNavigationQuery.ts index 1cbe40e86..648debca9 100644 --- a/utils/useReactNavigationQuery.ts +++ b/utils/useReactNavigationQuery.ts @@ -1,10 +1,10 @@ -import { useFocusEffect } from "@react-navigation/core"; import { type QueryKey, type UseQueryOptions, type UseQueryResult, useQuery, } from "@tanstack/react-query"; +import { useFocusEffect } from "expo-router/react-navigation"; import { useCallback } from "react"; export function useReactNavigationQuery<