Compare commits

..

88 Commits

Author SHA1 Message Date
Fredrik Burmester
1da49d29d7 style(tv): update settings to use apple tv-style white and green accents 2026-01-18 16:25:00 +01:00
Fredrik Burmester
7af4b913d7 fix(tv): add keyboard focus to text inputs and polish poster styling 2026-01-18 16:13:53 +01:00
Fredrik Burmester
a667723d93 fix(tv): improve subtitle modal loading state and card consistency 2026-01-18 15:25:40 +01:00
Fredrik Burmester
94bfa26041 feat(tv): add opensubtitles api key setting to tv interface 2026-01-18 15:25:31 +01:00
Fredrik Burmester
d545ca3584 fix(tv): modals 2026-01-18 15:22:44 +01:00
Fredrik Burmester
773701d0c1 fix: translations 2026-01-18 14:52:45 +01:00
Fredrik Burmester
a3f7d0c275 feat(tv): add metadata refresh button to item details page 2026-01-18 14:52:06 +01:00
Fredrik Burmester
5b7ded08cc refactor(tv): extract shared components to reduce code duplication 2026-01-18 14:45:18 +01:00
Fredrik Burmester
60dd00ad7e fix: close button modals 2026-01-18 14:14:23 +01:00
Fredrik Burmester
ec653cae15 docs: hdr 2026-01-18 13:53:19 +01:00
Fredrik Burmester
18bc45ea0a feat: open subtitles 2026-01-18 13:20:17 +01:00
Fredrik Burmester
ebb33854d7 wip 2026-01-18 12:37:12 +01:00
Fredrik Burmester
9efa2bbaa2 wip: hdr 2026-01-18 11:58:32 +01:00
Fredrik Burmester
c515d037cf refactor(tv): unify subtitle track selector and search into tabbed sheet 2026-01-18 11:13:57 +01:00
Fredrik Burmester
ee3a288fa0 wip 2026-01-18 10:38:06 +01:00
Fredrik Burmester
c0171aa656 feat(tv): add actor detail page with dynamic backdrop crossfade 2026-01-17 09:32:47 +01:00
Fredrik Burmester
41d3e61261 feat(tv): add bidirectional focus navigation between options and cast list 2026-01-17 09:10:27 +01:00
Fredrik Burmester
8f74c3edc7 feat(tv): actors and stuff 2026-01-16 23:36:15 +01:00
Fredrik Burmester
56ffec3173 fix(player): add null guards for item in play settings 2026-01-16 21:26:56 +01:00
Fredrik Burmester
9509a427c8 wip 2026-01-16 21:22:23 +01:00
Fredrik Burmester
cfcfb486bf wip 2026-01-16 21:21:58 +01:00
Fredrik Burmester
407ea69425 fix(tv): add opening animations to bottom sheet option selectors 2026-01-16 21:03:06 +01:00
Fredrik Burmester
e1e91ea1a6 fix: sheet 2026-01-16 21:00:46 +01:00
Fredrik Burmester
e7ea8a2c3b fix: remove back button 2026-01-16 19:51:27 +01:00
Fredrik Burmester
9f1791ce93 wip 2026-01-16 19:05:25 +01:00
Fredrik Burmester
38cb7068ef style(search): remove redundant search label on TV search page 2026-01-16 19:04:13 +01:00
Fredrik Burmester
cc154f0c16 fix(tv): fix subtitle sheet issues on TV
- Hide subtitle button when no subtitle tracks available
- Add back/menu button handling to close option sheets
2026-01-16 18:57:38 +01:00
Fredrik Burmester
866aa44277 wip: controls next up 2026-01-16 17:16:08 +01:00
Fredrik Burmester
ff3f88c53b wip 2026-01-16 15:59:26 +01:00
Fredrik Burmester
3fd76b1356 wip 2026-01-16 15:29:12 +01:00
Fredrik Burmester
a86df6c46b wip 2026-01-16 14:48:08 +01:00
Fredrik Burmester
bdd284b9a6 fix(i18n): add missing common.login translation key 2026-01-16 13:22:26 +01:00
Fredrik Burmester
fff7d4459f feat(tv): improve settings focus management with disabled props pattern 2026-01-16 13:17:12 +01:00
Fredrik Burmester
b85549016d style(tv): increase top padding on item content page 2026-01-16 13:15:53 +01:00
Fredrik Burmester
6c35608404 fix(tv): regenerate icons with proper aspect ratios 2026-01-16 13:09:30 +01:00
Fredrik Burmester
74e3465a84 feat(tv): add tv card design to watchlist detail page 2026-01-16 13:06:12 +01:00
Fredrik Burmester
be32d933bb feat(tv): add option selector for playback settings 2026-01-16 13:00:26 +01:00
Fredrik Burmester
db89295d9b feat(player): add Apple TV remote play/pause and AirPlay support
- Add playPause event handling in useRemoteControl hook
- Configure AVAudioSession for tvOS with longFormAudio policy
- Add AVInitialRouteSharingPolicy to enable AirPlay suggestions
2026-01-16 12:42:13 +01:00
Fredrik Burmester
8d90fe3a8b fix(tv): implement remote control seeking on tv interface 2026-01-16 12:40:37 +01:00
Fredrik Burmester
4880392197 fix(tv): login form 2026-01-16 12:19:47 +01:00
Fredrik Burmester
e10a99cc48 wip: build for tv 2026-01-16 10:47:48 +01:00
Fredrik Burmester
55b897883b wip 2026-01-16 10:06:41 +01:00
Fredrik Burmester
fe26a74451 wip: home page 2026-01-16 09:11:27 +01:00
Fredrik Burmester
4cdbab7d19 wip 2026-01-16 08:57:22 +01:00
Fredrik Burmester
3e695def23 wip 2026-01-16 08:57:19 +01:00
Fredrik Burmester
15e4c18d54 fix(tvos): settings 2026-01-16 08:42:53 +01:00
Fredrik Burmester
87169480a1 chore 2026-01-16 08:32:05 +01:00
Fredrik Burmester
bd9467b09e fix: remove music provider for tv 2026-01-16 08:32:02 +01:00
Fredrik Burmester
6216e7fdb7 fix: items content for tv 2026-01-16 08:31:53 +01:00
Fredrik Burmester
6d2e897c9f fix: badge for tv 2026-01-16 08:31:44 +01:00
Fredrik Burmester
ad5148daad fix: login stuff for tv 2026-01-16 08:31:37 +01:00
Fredrik Burmester
c1e12d5898 fix: login page for tv 2026-01-16 08:30:50 +01:00
Fredrik Burmester
7416c8297a fix: hide music bar 2026-01-16 08:30:40 +01:00
Fredrik Burmester
9727bec7ab fix: hide header buttons 2026-01-16 08:30:33 +01:00
Fredrik Burmester
6ba767a848 fix: tvos 2026-01-16 08:04:23 +01:00
Fredrik Burmester
4ad103acb6 fix: conditionals for tv to build / run 2026-01-16 08:04:09 +01:00
renovate[bot]
36304ad58e chore(deps): Update dependency react-native-nitro-modules to v0.33.1 (#1340)
Some checks failed
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 16:48:51 +01:00
renovate[bot]
baeb83581e chore(deps): Update actions/setup-node action to v6.2.0 (#1372)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 15:22:19 +01:00
Fredrik Burmester
05b7a4c50d fix: downloads should work when connecting through QC 2026-01-15 07:54:08 +01:00
Fredrik Burmester
28b67f3ad6 fix(mpv): handle audio track selection for transcoded streams on iOS 2026-01-15 07:53:15 +01:00
Chris
51cd195bfe Reverting Jellyseer/Seer logo update
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Final logo not yet released, holding off until further updates
2026-01-14 20:23:39 +01:00
Chris
0184e266a0 Update Jellyseer logo to Seer logo
Updated logo in order to reflecting the new branding
2026-01-14 20:17:25 +01:00
Gauvain
ae658aa5b0 fix: remove Android emulator detection from MPV player (#1369) 2026-01-14 16:46:17 +01:00
Alex
81f79a54af fix(mpv): Add progress throttling for mpv (#1366)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-14 13:14:52 +01:00
renovate[bot]
ca1b640a61 chore(deps): Update dependency @babel/core to v7.28.6 (#1363)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 11:41:57 +01:00
renovate[bot]
e771949c95 chore(deps): Update dependency @tanstack/react-query to v5.90.17 (#1365)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 10:57:40 +01:00
renovate[bot]
78bfa68a17 chore(deps): Update dependency react-i18next to v16.5.3 (#1364)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 10:06:03 +01:00
renovate[bot]
ac59615d79 chore(deps): Update dependency jotai to v2.16.2 (#1329)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 09:17:32 +01:00
Fredrik Burmester
4dd80cd8f5 Merge branch 'develop' into fix/external-sub-selection
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-13 22:43:05 +01:00
renovate[bot]
db9f02b225 chore(deps): Update BRAINSia/free-disk-space action to v2.1.3 (#1358)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:42:18 +01:00
renovate[bot]
7a0bbb1084 chore(deps): Update actions/upload-artifact action to v6 (#1362)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:20:42 +01:00
renovate[bot]
05925530c0 chore(deps): Update actions/checkout action to v6 (#1361)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:20:28 +01:00
renovate[bot]
625a292e26 chore(deps): Update actions/cache action to v5 (#1360)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:20:15 +01:00
renovate[bot]
1acd3102ea chore(deps): Update oven-sh/setup-bun action to v2.1.0 (#1359)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:19:58 +01:00
renovate[bot]
543881dc41 chore(deps): Update BRAINSia/free-disk-space digest to 7ef2f7e (#1357)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:19:27 +01:00
Cristea Florian Victor
5d93483dc2 feat: xcode build script (#1296)
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2026-01-13 19:32:58 +01:00
Gauvain
d54a29020a fix(ci): code scanning alert no. 219 (#1353)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-01-13 18:41:56 +01:00
renovate[bot]
1d04e39b85 chore(deps): Update github/codeql-action action to v4.31.10 (#1352)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 15:05:27 +01:00
Alex Kim
ecc62259fc Re-add config version 2026-01-13 22:21:19 +11:00
Alex Kim
ffd96e05fe Merge branch 'fix/external-sub-selection' of github.com:streamyfin/streamyfin into fix/external-sub-selection 2026-01-13 22:19:58 +11:00
Alex Kim
8541ba02d4 Add android version for stopping auto selection of subtitles 2026-01-13 22:19:47 +11:00
Chris
6c955d8a2a Enhance README with Discord link and badge
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Added Discord link and badge to the README.
2026-01-12 22:06:18 +01:00
Fredrik Burmester
b0bb6c6c9a feat: add technical stream info overlay for MPV player 2026-01-12 21:55:32 +01:00
Fredrik Burmester
82abc291d4 Merge branch 'develop' into fix/external-sub-selection 2026-01-12 21:02:16 +01:00
renovate[bot]
3da4b42ca3 chore(deps): Update dependency @tanstack/react-query to v5.90.16 (#1328)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 15:16:38 +01:00
Alex Kim
16940075b2 Stop external subs from being selected when added 2026-01-12 22:50:36 +11:00
renovate[bot]
a3bbb1bc3a chore(deps): Update dependency @types/lodash to v4.17.23 (#1344)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 12:11:05 +01:00
renovate[bot]
1874c116a6 chore(deps): Update dependency react-i18next to v16.5.2 (#1345)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 11:05:17 +01:00
128 changed files with 20117 additions and 1493 deletions

View File

@@ -20,6 +20,18 @@ jobs:
contents: read
steps:
- name: 🗑️ Free Disk Space
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
with:
tool-cache: false
mandb: true
android: false
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
@@ -91,6 +103,18 @@ jobs:
contents: read
steps:
- name: 🗑️ Free Disk Space
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
with:
tool-cache: false
mandb: true
android: false
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
@@ -194,7 +218,7 @@ jobs:
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
with:
xcode-version: "26.0.1"
xcode-version: "26.2"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@main
@@ -203,9 +227,6 @@ jobs:
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: ⚙️ Ensure iOS SDKs installed
run: xcodebuild -downloadPlatform iOS
- name: 🚀 Build iOS app
env:
EXPO_TV: 0
@@ -221,6 +242,63 @@ jobs:
path: build-*.ipa
retention-days: 7
build-ios-phone-unsigned:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: macos-26
name: 🍎 Build iOS IPA (Phone - Unsigned)
permissions:
contents: read
steps:
- name: 📥 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 🛠️ Generate project files
run: bun run prebuild
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
with:
xcode-version: "26.2"
- name: 🚀 Build iOS app
env:
EXPO_TV: 0
run: bun run ios:unsigned-build ${{ github.event_name == 'pull_request' && '-- --verbose' || '' }}
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }}
path: build/*.ipa
retention-days: 7
# Disabled for now - uncomment when ready to build iOS TV
# 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'))
@@ -271,9 +349,6 @@ jobs:
# token: ${{ secrets.EXPO_TOKEN }}
# eas-cache: true
#
# - name: ⚙️ Ensure tvOS SDKs installed
# run: xcodebuild -downloadPlatform tvOS
#
# - name: 🚀 Build iOS app
# env:
# EXPO_TV: 1

View File

@@ -27,13 +27,13 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10

View File

@@ -107,7 +107,7 @@ jobs:
fetch-depth: 0
- name: "🟢 Setup Node.js"
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: '24.x'

View File

@@ -1,4 +1,5 @@
name: 🛎️ Discord Notification
permissions: {}
on:
pull_request:

View File

@@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: "🟢 Setup Node.js"
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: '24.x'
cache: 'npm'

8
.gitignore vendored
View File

@@ -50,8 +50,6 @@ npm-debug.*
.idea/
.ruby-lsp
.cursor/
.claude/
CLAUDE.md
# Environment and Configuration
expo-env.d.ts
@@ -66,7 +64,11 @@ streamyfin-4fec1-firebase-adminsdk.json
# Version and Backup Files
/version-backup-*
modules/background-downloader/android/build/*
/modules/sf-player/android/build
/modules/music-controls/android/build
modules/background-downloader/android/build/*
/modules/mpv-player/android/build
# ios:unsigned-build Artifacts
build/
.claude/settings.local.json

205
CLAUDE.md
View File

@@ -134,3 +134,208 @@ import { apiAtom } from "@/providers/JellyfinProvider";
- 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 Component Rendering Pattern
**IMPORTANT**: The `.tv.tsx` file suffix only works for **pages** in the `app/` directory (resolved by Expo Router). It does NOT work for components - Metro bundler doesn't resolve platform-specific suffixes for component imports.
**Pattern for TV-specific 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 <TVLogin />;
}
return <Login />;
};
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
### TV Option Selector Pattern (Dropdowns/Multi-select)
For dropdown/select components on TV, use a **bottom sheet with horizontal scrolling**. This pattern is ideal for TV because:
- Horizontal scrolling is natural for TV remotes (left/right D-pad)
- Bottom sheet takes minimal screen space
- Focus-based navigation works reliably
**Key implementation details:**
1. **Use absolute positioning instead of Modal** - React Native's `Modal` breaks the TV focus chain. Use an absolutely positioned `View` overlay instead:
```typescript
<View style={{
position: "absolute",
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
}}>
<BlurView intensity={80} tint="dark" style={{ borderTopLeftRadius: 24, borderTopRightRadius: 24 }}>
{/* Content */}
</BlurView>
</View>
```
2. **Horizontal ScrollView with focusable cards**:
```typescript
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{ paddingHorizontal: 48, paddingVertical: 10, gap: 12 }}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
hasTVPreferredFocus={index === selectedIndex}
onPress={() => { onSelect(option.value); onClose(); }}
// ...
/>
))}
</ScrollView>
```
3. **Focus handling on cards** - Use `Pressable` with `onFocus`/`onBlur` and `hasTVPreferredFocus`:
```typescript
<Pressable
onPress={onPress}
onFocus={() => { setFocused(true); animateTo(1.05); }}
onBlur={() => { setFocused(false); animateTo(1); }}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View style={{ transform: [{ scale }], backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.08)" }}>
<Text style={{ color: focused ? "#000" : "#fff" }}>{label}</Text>
</Animated.View>
</Pressable>
```
4. **Add padding for scale animations** - When items scale on focus, add enough padding (`overflow: "visible"` + `paddingVertical`) so scaled items don't clip.
**Reference implementation**: See `TVOptionSelector` and `TVOptionCard` in `components/ItemContent.tv.tsx`
### TV Focus Management for Overlays/Modals
**CRITICAL**: When displaying overlays (bottom sheets, modals, dialogs) 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 that freezes navigation.
**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<ModalType | null>(null);
const isModalOpen = openModal !== null;
// 2. Each focusable component accepts disabled prop
const TVFocusableButton: React.FC<{
onPress: () => void;
disabled?: boolean;
}> = ({ onPress, disabled }) => (
<Pressable
onPress={onPress}
disabled={disabled}
focusable={!disabled}
hasTVPreferredFocus={isFirst && !disabled}
>
{/* content */}
</Pressable>
);
// 3. Pass disabled to all background components when modal is open
<TVFocusableButton onPress={handlePress} disabled={isModalOpen} />
```
**Reference implementation**: See `settings.tv.tsx` for complete example with `TVSettingsOptionButton`, `TVSettingsToggle`, `TVSettingsStepper`, etc.
### 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 ? (
<FlatList
data={items}
renderItem={renderTVItem}
removeClippedSubviews={false}
// ...
/>
) : (
<FlashList data={items} renderItem={renderItem} />
)}
```
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
<TVFilterButton hasTVPreferredFocus={index === 0} />
<TVFocusablePoster /> // No hasTVPreferredFocus
// ❌ Bad - both compete for focus
<TVFilterButton hasTVPreferredFocus />
<TVFocusablePoster hasTVPreferredFocus={index === 0} />
```
4. **Keep headers/filter bars outside the list** - Instead of using `ListHeaderComponent`, render the filter bar as a separate View above the FlatList:
```typescript
<View style={{ flex: 1 }}>
{/* Filter bar - separate from list */}
<View style={{ flexDirection: "row", gap: 12 }}>
<TVFilterButton />
<TVFilterButton />
</View>
{/* Grid */}
<FlatList data={items} renderItem={renderTVItem} />
</View>
```
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<View | null>(null);
// 2. Place invisible focus guide between sections
{firstCardRef && (
<TVFocusGuideView
destinations={[firstCardRef]}
style={{ height: 1, width: "100%" }}
/>
)}
// 3. Target component must use forwardRef
const MyCard = React.forwardRef<View, Props>(({ ... }, ref) => (
<Pressable ref={ref} ...>
...
</Pressable>
));
// 4. Pass state setter as callback ref to first item
{items.map((item, index) => (
<MyCard
ref={index === 0 ? setFirstCardRef : undefined}
...
/>
))}
```
**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.

View File

@@ -5,6 +5,12 @@
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
</p>
<p align="center">
<a href="https://discord.gg/aJvAYeycyY">
<img alt="Streamyfin Discord" src="https://img.shields.io/badge/Discord-Streamyfin-blue?style=flat-square&logo=discord">
</a>
</p>
**Streamyfin is a user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
---

View File

@@ -23,7 +23,8 @@
},
"UISupportsTrueScreenSizeOnMac": true,
"UIFileSharingEnabled": true,
"LSSupportsOpeningDocumentsInPlace": true
"LSSupportsOpeningDocumentsInPlace": true,
"AVInitialRouteSharingPolicy": "LongFormAudio"
},
"config": {
"usesNonExemptEncryption": false
@@ -55,7 +56,23 @@
"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",
@@ -121,6 +138,7 @@
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
["./plugins/withTVOSAppIcon.js"],
[
"./plugins/withGitPod.js",
{

View File

@@ -9,7 +9,7 @@ export default function CustomMenuLayout() {
<Stack.Screen
name='index'
options={{
headerShown: Platform.OS !== "ios",
headerShown: !Platform.isTV,
headerLargeTitle: true,
headerTitle: t("tabs.custom_links"),
headerBlurEffect: "none",

View File

@@ -2,6 +2,7 @@ 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() {
@@ -15,6 +16,10 @@ export default function favorites() {
}, []);
const insets = useSafeAreaInsets();
if (Platform.isTV) {
return <TVFavorites />;
}
return (
<ScrollView
nestedScrollEnabled

View File

@@ -43,7 +43,7 @@ export default function IndexLayout() {
<Stack.Screen
name='downloads/index'
options={{
headerShown: true,
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
title: t("home.downloads.downloads_title"),
@@ -62,7 +62,7 @@ export default function IndexLayout() {
name='sessions/index'
options={{
title: t("home.sessions.title"),
headerShown: true,
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
@@ -81,6 +81,7 @@ export default function IndexLayout() {
name='settings'
options={{
title: t("home.settings.settings_title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
@@ -99,6 +100,7 @@ export default function IndexLayout() {
name='settings/playback-controls/page'
options={{
title: t("home.settings.playback_controls.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
@@ -117,6 +119,7 @@ export default function IndexLayout() {
name='settings/audio-subtitles/page'
options={{
title: t("home.settings.audio_subtitles.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
@@ -135,6 +138,7 @@ export default function IndexLayout() {
name='settings/appearance/page'
options={{
title: t("home.settings.appearance.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
@@ -153,6 +157,7 @@ export default function IndexLayout() {
name='settings/music/page'
options={{
title: t("home.settings.music.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
@@ -171,6 +176,7 @@ export default function IndexLayout() {
name='settings/appearance/hide-libraries/page'
options={{
title: t("home.settings.other.hide_libraries"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
@@ -189,6 +195,7 @@ export default function IndexLayout() {
name='settings/plugins/page'
options={{
title: t("home.settings.plugins.plugins_title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
@@ -207,6 +214,7 @@ export default function IndexLayout() {
name='settings/plugins/marlin-search/page'
options={{
title: "Marlin Search",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
@@ -225,6 +233,7 @@ export default function IndexLayout() {
name='settings/plugins/jellyseerr/page'
options={{
title: "Jellyseerr",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
@@ -243,6 +252,7 @@ export default function IndexLayout() {
name='settings/plugins/streamystats/page'
options={{
title: "Streamystats",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
@@ -261,6 +271,7 @@ export default function IndexLayout() {
name='settings/plugins/kefinTweaks/page'
options={{
title: "KefinTweaks",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
@@ -279,6 +290,7 @@ export default function IndexLayout() {
name='settings/intro/page'
options={{
title: t("home.settings.intro.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
@@ -297,6 +309,7 @@ export default function IndexLayout() {
name='settings/logs/page'
options={{
title: t("home.settings.logs.logs_title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
@@ -315,6 +328,7 @@ export default function IndexLayout() {
name='settings/network/page'
options={{
title: t("home.settings.network.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
@@ -341,7 +355,7 @@ export default function IndexLayout() {
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
headerShown: true,
headerShown: !Platform.isTV,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,

View File

@@ -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);
@@ -104,8 +108,17 @@ export default function settings() {
</ListGroup>
</View>
{!Platform.isTV && <StorageSettings />}
<StorageSettings />
</View>
</ScrollView>
);
}
export default function settings() {
// Use TV settings component on TV platforms
if (Platform.isTV && SettingsTV) {
return <SettingsTV />;
}
return <SettingsMobile />;
}

View File

@@ -0,0 +1,821 @@
import { Ionicons } from "@expo/vector-icons";
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useAtom } from "jotai";
import React, { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Animated, Pressable, ScrollView, TextInput, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import type { TVOptionItem } from "@/components/tv";
import { useTVFocusAnimation } from "@/components/tv";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
// TV-optimized focusable row component
const TVSettingsRow: React.FC<{
label: string;
value: string;
onPress?: () => void;
isFirst?: boolean;
showChevron?: boolean;
disabled?: boolean;
}> = ({ label, value, onPress, isFirst, showChevron = true, disabled }) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02 });
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: focused
? "rgba(255, 255, 255, 0.15)"
: "rgba(255, 255, 255, 0.05)",
borderRadius: 12,
paddingVertical: 16,
paddingHorizontal: 24,
marginBottom: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
]}
>
<Text style={{ fontSize: 20, color: "#FFFFFF" }}>{label}</Text>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Text
style={{
fontSize: 18,
color: "#9CA3AF",
marginRight: showChevron ? 12 : 0,
}}
>
{value}
</Text>
{showChevron && (
<Ionicons name='chevron-forward' size={20} color='#6B7280' />
)}
</View>
</Animated.View>
</Pressable>
);
};
// TV-optimized toggle row component
const TVSettingsToggle: React.FC<{
label: string;
value: boolean;
onToggle: (value: boolean) => void;
isFirst?: boolean;
disabled?: boolean;
}> = ({ label, value, onToggle, isFirst, disabled }) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02 });
return (
<Pressable
onPress={() => onToggle(!value)}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: focused
? "rgba(255, 255, 255, 0.15)"
: "rgba(255, 255, 255, 0.05)",
borderRadius: 12,
paddingVertical: 16,
paddingHorizontal: 24,
marginBottom: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
]}
>
<Text style={{ fontSize: 20, color: "#FFFFFF" }}>{label}</Text>
<View
style={{
width: 56,
height: 32,
borderRadius: 16,
backgroundColor: value ? "#34C759" : "#4B5563",
justifyContent: "center",
paddingHorizontal: 2,
}}
>
<View
style={{
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: "#FFFFFF",
alignSelf: value ? "flex-end" : "flex-start",
}}
/>
</View>
</Animated.View>
</Pressable>
);
};
// TV-optimized stepper row component
const TVSettingsStepper: React.FC<{
label: string;
value: number;
onDecrease: () => void;
onIncrease: () => void;
formatValue?: (value: number) => string;
isFirst?: boolean;
disabled?: boolean;
}> = ({
label,
value,
onDecrease,
onIncrease,
formatValue,
isFirst,
disabled,
}) => {
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 (
<View
style={{
backgroundColor:
labelAnim.focused || minusAnim.focused || plusAnim.focused
? "rgba(255, 255, 255, 0.15)"
: "rgba(255, 255, 255, 0.05)",
borderRadius: 12,
paddingVertical: 16,
paddingHorizontal: 24,
marginBottom: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Pressable
onFocus={labelAnim.handleFocus}
onBlur={labelAnim.handleBlur}
hasTVPreferredFocus={isFirst && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View style={labelAnim.animatedStyle}>
<Text style={{ fontSize: 20, color: "#FFFFFF" }}>{label}</Text>
</Animated.View>
</Pressable>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Pressable
onPress={onDecrease}
onFocus={minusAnim.handleFocus}
onBlur={minusAnim.handleBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
minusAnim.animatedStyle,
{
width: 48,
height: 36,
borderRadius: 18,
backgroundColor: minusAnim.focused ? "#FFFFFF" : "#4B5563",
justifyContent: "center",
alignItems: "center",
},
]}
>
<Ionicons
name='remove'
size={24}
color={minusAnim.focused ? "#000000" : "#FFFFFF"}
/>
</Animated.View>
</Pressable>
<Text
style={{
fontSize: 18,
color: "#FFFFFF",
minWidth: 60,
textAlign: "center",
marginHorizontal: 16,
}}
>
{displayValue}
</Text>
<Pressable
onPress={onIncrease}
onFocus={plusAnim.handleFocus}
onBlur={plusAnim.handleBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
plusAnim.animatedStyle,
{
width: 48,
height: 36,
borderRadius: 18,
backgroundColor: plusAnim.focused ? "#FFFFFF" : "#4B5563",
justifyContent: "center",
alignItems: "center",
},
]}
>
<Ionicons
name='add'
size={24}
color={plusAnim.focused ? "#000000" : "#FFFFFF"}
/>
</Animated.View>
</Pressable>
</View>
</View>
);
};
// TV Settings Option Button - displays current value and opens bottom sheet
const TVSettingsOptionButton: React.FC<{
label: string;
value: string;
onPress: () => void;
isFirst?: boolean;
disabled?: boolean;
}> = ({ label, value, onPress, isFirst, disabled }) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02 });
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: focused
? "rgba(255, 255, 255, 0.15)"
: "rgba(255, 255, 255, 0.05)",
borderRadius: 12,
paddingVertical: 16,
paddingHorizontal: 24,
marginBottom: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
]}
>
<Text style={{ fontSize: 20, color: "#FFFFFF" }}>{label}</Text>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Text
style={{
fontSize: 18,
color: "#9CA3AF",
marginRight: 12,
}}
>
{value}
</Text>
<Ionicons name='chevron-forward' size={20} color='#6B7280' />
</View>
</Animated.View>
</Pressable>
);
};
// TV-optimized text input component
const TVSettingsTextInput: React.FC<{
label: string;
value: string;
placeholder?: string;
onChangeText: (text: string) => void;
onBlur?: () => void;
secureTextEntry?: boolean;
disabled?: boolean;
}> = ({
label,
value,
placeholder,
onChangeText,
onBlur,
secureTextEntry,
disabled,
}) => {
const inputRef = useRef<TextInput>(null);
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02 });
const handleInputBlur = () => {
handleBlur();
onBlur?.();
};
return (
<Pressable
onPress={() => inputRef.current?.focus()}
onFocus={handleFocus}
onBlur={handleInputBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: focused
? "rgba(255, 255, 255, 0.15)"
: "rgba(255, 255, 255, 0.05)",
borderRadius: 12,
paddingVertical: 16,
paddingHorizontal: 24,
marginBottom: 8,
},
]}
>
<Text style={{ fontSize: 16, color: "#9CA3AF", marginBottom: 8 }}>
{label}
</Text>
<TextInput
ref={inputRef}
value={value}
placeholder={placeholder}
placeholderTextColor='#6B7280'
onChangeText={onChangeText}
onBlur={handleInputBlur}
secureTextEntry={secureTextEntry}
autoCapitalize='none'
autoCorrect={false}
style={{
fontSize: 18,
color: "#FFFFFF",
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 16,
borderWidth: focused ? 2 : 1,
borderColor: focused ? "#FFFFFF" : "#4B5563",
}}
/>
</Animated.View>
</Pressable>
);
};
// Section header component
const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
<Text
style={{
fontSize: 16,
fontWeight: "600",
color: "#9CA3AF",
textTransform: "uppercase",
letterSpacing: 1,
marginTop: 32,
marginBottom: 16,
marginLeft: 8,
}}
>
{title}
</Text>
);
// Logout button component
const TVLogoutButton: React.FC<{ onPress: () => void; disabled?: boolean }> = ({
onPress,
disabled,
}) => {
const { t } = useTranslation();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
shadowColor: "#ef4444",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
},
]}
>
<View
style={{
backgroundColor: focused ? "#ef4444" : "rgba(239, 68, 68, 0.8)",
borderRadius: 12,
paddingVertical: 18,
paddingHorizontal: 48,
alignItems: "center",
justifyContent: "center",
}}
>
<Text
style={{
fontSize: 20,
fontWeight: "bold",
color: "#FFFFFF",
}}
>
{t("home.settings.log_out_button")}
</Text>
</View>
</Animated.View>
</Pressable>
);
};
export default function SettingsTV() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { settings, updateSettings } = useSettings();
const { logout } = useJellyfin();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const { showOptions } = useTVOptionModal();
// Local state for OpenSubtitles API key (only commit on blur)
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
settings.openSubtitlesApiKey || "",
);
const currentAudioTranscode =
settings.audioTranscodeMode || AudioTranscodeMode.Auto;
const currentSubtitleMode =
settings.subtitleMode || SubtitlePlaybackMode.Default;
const currentAlignX = settings.mpvSubtitleAlignX ?? "center";
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
// Audio transcoding options
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = 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<SubtitlePlaybackMode>[] = 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<string>[] = 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<string>[] = useMemo(
() => [
{ label: "Top", value: "top", selected: currentAlignY === "top" },
{
label: "Center",
value: "center",
selected: currentAlignY === "center",
},
{
label: "Bottom",
value: "bottom",
selected: currentAlignY === "bottom",
},
],
[currentAlignY],
);
// 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]);
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
<View style={{ flex: 1 }}>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 120,
paddingBottom: insets.bottom + 60,
paddingHorizontal: insets.left + 80,
}}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<Text
style={{
fontSize: 42,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{t("home.settings.settings_title")}
</Text>
{/* Audio Section */}
<SectionHeader title={t("home.settings.audio.audio_title")} />
<TVSettingsOptionButton
label={t("home.settings.audio.transcode_mode.title")}
value={audioTranscodeLabel}
onPress={() =>
showOptions({
title: t("home.settings.audio.transcode_mode.title"),
options: audioTranscodeModeOptions,
onSelect: (value) =>
updateSettings({ audioTranscodeMode: value }),
})
}
isFirst
/>
{/* Subtitles Section */}
<SectionHeader title={t("home.settings.subtitles.subtitle_title")} />
<TVSettingsOptionButton
label={t("home.settings.subtitles.subtitle_mode")}
value={subtitleModeLabel}
onPress={() =>
showOptions({
title: t("home.settings.subtitles.subtitle_mode"),
options: subtitleModeOptions,
onSelect: (value) => updateSettings({ subtitleMode: value }),
})
}
/>
<TVSettingsToggle
label={t("home.settings.subtitles.set_subtitle_track")}
value={settings.rememberSubtitleSelections}
onToggle={(value) =>
updateSettings({ rememberSubtitleSelections: value })
}
/>
<TVSettingsStepper
label={t("home.settings.subtitles.subtitle_size")}
value={settings.subtitleSize / 100}
onDecrease={() => {
const newValue = Math.max(0.3, settings.subtitleSize / 100 - 0.1);
updateSettings({ subtitleSize: Math.round(newValue * 100) });
}}
onIncrease={() => {
const newValue = Math.min(1.5, settings.subtitleSize / 100 + 0.1);
updateSettings({ subtitleSize: Math.round(newValue * 100) });
}}
formatValue={(v) => `${v.toFixed(1)}x`}
/>
{/* MPV Subtitles Section */}
<SectionHeader title='MPV Subtitle Settings' />
<TVSettingsStepper
label='Subtitle Scale'
value={settings.mpvSubtitleScale ?? 1.0}
onDecrease={() => {
const newValue = Math.max(
0.5,
(settings.mpvSubtitleScale ?? 1.0) - 0.1,
);
updateSettings({
mpvSubtitleScale: Math.round(newValue * 10) / 10,
});
}}
onIncrease={() => {
const newValue = Math.min(
2.0,
(settings.mpvSubtitleScale ?? 1.0) + 0.1,
);
updateSettings({
mpvSubtitleScale: Math.round(newValue * 10) / 10,
});
}}
formatValue={(v) => `${v.toFixed(1)}x`}
/>
<TVSettingsStepper
label='Vertical Margin'
value={settings.mpvSubtitleMarginY ?? 0}
onDecrease={() => {
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 });
}}
/>
<TVSettingsOptionButton
label='Horizontal Alignment'
value={alignXLabel}
onPress={() =>
showOptions({
title: "Horizontal Alignment",
options: alignXOptions,
onSelect: (value) =>
updateSettings({
mpvSubtitleAlignX: value as "left" | "center" | "right",
}),
})
}
/>
<TVSettingsOptionButton
label='Vertical Alignment'
value={alignYLabel}
onPress={() =>
showOptions({
title: "Vertical Alignment",
options: alignYOptions,
onSelect: (value) =>
updateSettings({
mpvSubtitleAlignY: value as "top" | "center" | "bottom",
}),
})
}
/>
{/* OpenSubtitles Section */}
<SectionHeader
title={
t("home.settings.subtitles.opensubtitles_title") ||
"OpenSubtitles"
}
/>
<Text
style={{
color: "#9CA3AF",
fontSize: 14,
marginBottom: 16,
marginLeft: 8,
}}
>
{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."}
</Text>
<TVSettingsTextInput
label={
t("home.settings.subtitles.opensubtitles_api_key") || "API Key"
}
value={openSubtitlesApiKey}
placeholder={
t("home.settings.subtitles.opensubtitles_api_key_placeholder") ||
"Enter API key..."
}
onChangeText={setOpenSubtitlesApiKey}
onBlur={() => updateSettings({ openSubtitlesApiKey })}
secureTextEntry
/>
<Text
style={{
color: "#6B7280",
fontSize: 12,
marginTop: 8,
marginLeft: 8,
}}
>
{t("home.settings.subtitles.opensubtitles_get_key") ||
"Get your free API key at opensubtitles.com/en/consumers"}
</Text>
{/* Appearance Section */}
<SectionHeader title={t("home.settings.appearance.title")} />
<TVSettingsToggle
label={t(
"home.settings.appearance.merge_next_up_continue_watching",
)}
value={settings.mergeNextUpAndContinueWatching}
onToggle={(value) =>
updateSettings({ mergeNextUpAndContinueWatching: value })
}
/>
<TVSettingsToggle
label={t("home.settings.appearance.show_home_backdrop")}
value={settings.showHomeBackdrop}
onToggle={(value) => updateSettings({ showHomeBackdrop: value })}
/>
{/* User Section */}
<SectionHeader title={t("home.settings.user_info.user_info_title")} />
<TVSettingsRow
label={t("home.settings.user_info.user")}
value={user?.Name || "-"}
showChevron={false}
/>
<TVSettingsRow
label={t("home.settings.user_info.server")}
value={api?.basePath || "-"}
showChevron={false}
/>
{/* Logout Button */}
<View style={{ marginTop: 48, alignItems: "center" }}>
<TVLogoutButton onPress={logout} />
</View>
</ScrollView>
</View>
</View>
);
}

View File

@@ -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,7 +27,11 @@ 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,
@@ -40,33 +47,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 +66,46 @@ const Page: React.FC = () => {
return (
<OfflineModeProvider isOffline={isOffline}>
<View className='flex flex-1 relative'>
<Animated.View
pointerEvents={"none"}
style={[animatedStyle]}
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black'
>
<View
style={{
height: item?.Type === "Episode" ? 300 : 450,
}}
className='bg-transparent rounded-lg mb-4 w-full'
/>
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
<View className='flex flex-row space-x-1 mb-8'>
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
</View>
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
</Animated.View>
{item && <ItemContent item={item} itemWithSources={itemWithSources} />}
{/* Always render ItemContent - it handles loading state internally on TV */}
<ItemContent
item={item}
itemWithSources={itemWithSources}
isLoading={isLoading}
/>
{/* Skeleton overlay - fades out when content loads */}
{!item && (
<Animated.View
pointerEvents={"none"}
style={[animatedStyle]}
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen z-50 bg-black'
>
{Platform.isTV && ItemContentSkeletonTV ? (
<ItemContentSkeletonTV />
) : (
<View style={{ paddingHorizontal: 16, width: "100%" }}>
<View
style={{
height: 450,
}}
className='bg-transparent rounded-lg mb-4 w-full'
/>
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
<View className='flex flex-row space-x-1 mb-8'>
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
</View>
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
</View>
)}
</Animated.View>
)}
</View>
</OfflineModeProvider>
);

View File

@@ -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 <TVActorPage personId={personId} />;
}
return <MobileActorPage personId={personId} />;
};
const MobileActorPage: React.FC<{ personId: string }> = ({ personId }) => {
const { t } = useTranslation();
const [api] = useAtom(apiAtom);

View File

@@ -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";
@@ -159,6 +160,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 (
<OfflineModeProvider isOffline={isOffline}>
<TVSeriesPage
item={item}
allEpisodes={allEpisodes}
isLoading={isLoading}
/>
</OfflineModeProvider>
);
}
return (
<OfflineModeProvider isOffline={isOffline}>
<ParallaxScrollView

View File

@@ -1,3 +1,4 @@
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
BaseItemDtoQueryResult,
@@ -11,19 +12,44 @@ import {
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { FlatList, useWindowDimensions, View } from "react-native";
import {
Animated,
Easing,
FlatList,
Platform,
Pressable,
ScrollView,
useWindowDimensions,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import {
getItemNavigation,
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -49,6 +75,280 @@ import {
} from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings";
const TV_ITEM_GAP = 16;
const TV_SCALE_PADDING = 20;
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
<View style={{ marginTop: 12 }}>
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
{item.Name}
</Text>
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
{item.ProductionYear}
</Text>
</View>
);
// TV Filter Types and Components
type TVFilterModalType =
| "genre"
| "year"
| "tags"
| "sortBy"
| "sortOrder"
| "filterBy"
| null;
interface TVFilterOption<T> {
label: string;
value: T;
selected: boolean;
}
const TVFilterOptionCard: React.FC<{
label: string;
selected: boolean;
hasTVPreferredFocus?: boolean;
onPress: () => void;
}> = ({ label, selected, hasTVPreferredFocus, onPress }) => {
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 (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
width: 160,
height: 75,
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
}}
>
<Text
style={{
fontSize: 16,
color: focused ? "#000" : "#fff",
fontWeight: focused || selected ? "600" : "400",
textAlign: "center",
}}
numberOfLines={2}
>
{label}
</Text>
{selected && !focused && (
<View style={{ position: "absolute", top: 8, right: 8 }}>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
};
const TVFilterButton: React.FC<{
label: string;
value: string;
onPress: () => void;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
hasActiveFilter?: boolean;
}> = ({
label,
value,
onPress,
hasTVPreferredFocus,
disabled,
hasActiveFilter,
}) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 120,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.04);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View style={{ transform: [{ scale }] }}>
<View
style={{
backgroundColor: focused
? "#fff"
: hasActiveFilter
? "rgba(255, 255, 255, 0.25)"
: "rgba(255,255,255,0.1)",
borderRadius: 10,
paddingVertical: 10,
paddingHorizontal: 16,
flexDirection: "row",
alignItems: "center",
gap: 8,
borderWidth: hasActiveFilter && !focused ? 1 : 0,
borderColor: "rgba(255, 255, 255, 0.4)",
}}
>
{label ? (
<Text style={{ fontSize: 14, color: focused ? "#444" : "#bbb" }}>
{label}
</Text>
) : null}
<Text
style={{
fontSize: 14,
color: focused ? "#000" : "#FFFFFF",
fontWeight: "500",
}}
numberOfLines={1}
>
{value}
</Text>
</View>
</Animated.View>
</Pressable>
);
};
const TVFilterSelector = <T,>({
visible,
title,
options,
onSelect,
onClose,
}: {
visible: boolean;
title: string;
options: TVFilterOption<T>[];
onSelect: (value: T) => void;
onClose: () => void;
}) => {
// Track initial focus index - only set once when modal opens
const initialFocusIndexRef = useRef<number | null>(null);
// Calculate initial focus index only once when visible becomes true
if (visible && initialFocusIndexRef.current === null) {
const idx = options.findIndex((o) => o.selected);
initialFocusIndexRef.current = idx >= 0 ? idx : 0;
}
// Reset when modal closes
if (!visible) {
initialFocusIndexRef.current = null;
return null;
}
const initialFocusIndex = initialFocusIndexRef.current ?? 0;
return (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<View style={{ paddingVertical: 24 }}>
<Text
style={{
fontSize: 20,
fontWeight: "600",
color: "#fff",
paddingHorizontal: 48,
marginBottom: 16,
}}
>
{title}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
{options.map((option, index) => (
<TVFilterOptionCard
key={String(option.value)}
label={option.label}
selected={option.selected}
hasTVPreferredFocus={index === initialFocusIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
</View>
</BlurView>
</View>
);
};
const Page = () => {
const searchParams = useLocalSearchParams() as {
libraryId: string;
@@ -79,6 +379,54 @@ const Page = () => {
const { orientation } = useOrientation();
const { t } = useTranslation();
const router = useRouter();
// TV Filter modal state
const [openFilterModal, setOpenFilterModal] =
useState<TVFilterModalType>(null);
const isFilterModalOpen = openFilterModal !== null;
const isFiltersDisabled = isFilterModalOpen;
// 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 +510,14 @@ const Page = () => {
);
const nrOfCols = useMemo(() => {
if (Platform.isTV) {
// Calculate columns based on TV poster width + gap
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
return Math.max(
1,
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
);
}
if (screenWidth < 300) return 2;
if (screenWidth < 500) return 3;
if (screenWidth < 800) return 5;
@@ -322,7 +678,38 @@ const Page = () => {
</View>
</TouchableItemRouter>
),
[orientation],
[orientation, nrOfCols],
);
const renderTVItem = useCallback(
({ item }: { item: BaseItemDto }) => {
const handlePress = () => {
const navTarget = getItemNavigation(item, "(libraries)");
router.push(navTarget as any);
};
return (
<View
style={{
marginRight: TV_ITEM_GAP,
marginBottom: TV_ITEM_GAP,
width: TV_POSTER_WIDTH,
}}
>
<TVFocusablePoster onPress={handlePress} disabled={isFilterModalOpen}>
{item.Type === "Movie" && <MoviePoster item={item} />}
{(item.Type === "Series" || item.Type === "Episode") && (
<SeriesPoster item={item} />
)}
{item.Type !== "Movie" &&
item.Type !== "Series" &&
item.Type !== "Episode" && <MoviePoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} />
</View>
);
},
[router, isFilterModalOpen],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
@@ -509,6 +896,156 @@ 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(
(): TVFilterOption<string>[] => [
{
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(
(): TVFilterOption<string>[] => [
{
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(
(): TVFilterOption<string>[] => [
{
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(
(): TVFilterOption<SortByOption>[] =>
sortOptions.map((option) => ({
label: option.value,
value: option.key,
selected: sortBy[0] === option.key,
})),
[sortBy],
);
const tvSortOrderOptions = useMemo(
(): TVFilterOption<SortOrderOption>[] =>
sortOrderOptions.map((option) => ({
label: option.value,
value: option.key,
selected: sortOrder[0] === option.key,
})),
[sortOrder],
);
const tvFilterByOptions = useMemo(
(): TVFilterOption<string>[] => [
{
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
const handleGenreSelect = useCallback(
(value: string) => {
if (value === "__all__") {
setSelectedGenres([]);
} else if (selectedGenres.includes(value)) {
setSelectedGenres(selectedGenres.filter((g) => g !== value));
} else {
setSelectedGenres([...selectedGenres, value]);
}
},
[selectedGenres, setSelectedGenres],
);
const handleYearSelect = useCallback(
(value: string) => {
if (value === "__all__") {
setSelectedYears([]);
} else if (selectedYears.includes(value)) {
setSelectedYears(selectedYears.filter((y) => y !== value));
} else {
setSelectedYears([...selectedYears, value]);
}
},
[selectedYears, setSelectedYears],
);
const handleTagSelect = useCallback(
(value: string) => {
if (value === "__all__") {
setSelectedTags([]);
} else if (selectedTags.includes(value)) {
setSelectedTags(selectedTags.filter((t) => t !== value));
} else {
setSelectedTags([...selectedTags, value]);
}
},
[selectedTags, setSelectedTags],
);
const handleFilterBySelect = useCallback(
(value: string) => {
if (value === "__all__") {
_setFilterBy([]);
} else {
setFilter([value as FilterByOption]);
}
},
[setFilter, _setFilterBy],
);
const insets = useSafeAreaInsets();
if (isLoading || isLibraryLoading)
@@ -518,43 +1055,227 @@ const Page = () => {
</View>
);
return (
<FlashList
key={orientation}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("library.no_results")}
</Text>
</View>
}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
// Mobile return
if (!Platform.isTV) {
return (
<FlashList
key={orientation}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("library.no_results")}
</Text>
</View>
}
}}
onEndReachedThreshold={1}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
ItemSeparatorComponent={() => (
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={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
);
}
// TV return with filter overlays - filter bar outside FlatList to fix focus boundary issues
return (
<View style={{ flex: 1 }}>
{/* Background content - disabled when modal is open */}
<View
style={{ flex: 1, opacity: isFilterModalOpen ? 0.3 : 1 }}
focusable={!isFilterModalOpen}
isTVSelectable={!isFilterModalOpen}
pointerEvents={isFilterModalOpen ? "none" : "auto"}
accessibilityElementsHidden={isFilterModalOpen}
importantForAccessibility={
isFilterModalOpen ? "no-hide-descendants" : "auto"
}
>
{/* Filter bar - using View instead of ScrollView to avoid focus conflicts */}
<View
style={{
width: 10,
height: 10,
flexDirection: "row",
flexWrap: "nowrap",
marginTop: insets.top + 100,
paddingBottom: 8,
paddingHorizontal: TV_SCALE_PADDING,
gap: 12,
}}
>
{hasActiveFilters && (
<TVFilterButton
label=''
value={t("library.filters.reset")}
onPress={resetAllFilters}
disabled={isFiltersDisabled}
hasActiveFilter
/>
)}
<TVFilterButton
label={t("library.filters.genres")}
value={
selectedGenres.length > 0
? `${selectedGenres.length} selected`
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("genre")}
hasTVPreferredFocus={!hasActiveFilters}
disabled={isFiltersDisabled}
hasActiveFilter={selectedGenres.length > 0}
/>
<TVFilterButton
label={t("library.filters.years")}
value={
selectedYears.length > 0
? `${selectedYears.length} selected`
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("year")}
disabled={isFiltersDisabled}
hasActiveFilter={selectedYears.length > 0}
/>
<TVFilterButton
label={t("library.filters.tags")}
value={
selectedTags.length > 0
? `${selectedTags.length} selected`
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("tags")}
disabled={isFiltersDisabled}
hasActiveFilter={selectedTags.length > 0}
/>
<TVFilterButton
label={t("library.filters.sort_by")}
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
onPress={() => setOpenFilterModal("sortBy")}
disabled={isFiltersDisabled}
/>
<TVFilterButton
label={t("library.filters.sort_order")}
value={
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
}
onPress={() => setOpenFilterModal("sortOrder")}
disabled={isFiltersDisabled}
/>
<TVFilterButton
label={t("library.filters.filter_by")}
value={
filterBy.length > 0
? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("filterBy")}
disabled={isFiltersDisabled}
hasActiveFilter={filterBy.length > 0}
/>
</View>
{/* Grid - using FlatList instead of FlashList to fix focus issues */}
<FlatList
key={`${orientation}-${nrOfCols}`}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("library.no_results")}
</Text>
</View>
}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderTVItem}
extraData={[orientation, nrOfCols, isFilterModalOpen]}
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={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
)}
/>
</View>
{/* TV Filter Overlays */}
<TVFilterSelector
visible={openFilterModal === "genre"}
title={t("library.filters.genres")}
options={tvGenreFilterOptions}
onSelect={handleGenreSelect}
onClose={() => setOpenFilterModal(null)}
/>
<TVFilterSelector
visible={openFilterModal === "year"}
title={t("library.filters.years")}
options={tvYearFilterOptions}
onSelect={handleYearSelect}
onClose={() => setOpenFilterModal(null)}
/>
<TVFilterSelector
visible={openFilterModal === "tags"}
title={t("library.filters.tags")}
options={tvTagFilterOptions}
onSelect={handleTagSelect}
onClose={() => setOpenFilterModal(null)}
/>
<TVFilterSelector
visible={openFilterModal === "sortBy"}
title={t("library.filters.sort_by")}
options={tvSortByOptions}
onSelect={(value) => setSortBy([value])}
onClose={() => setOpenFilterModal(null)}
/>
<TVFilterSelector
visible={openFilterModal === "sortOrder"}
title={t("library.filters.sort_order")}
options={tvSortOrderOptions}
onSelect={(value) => setSortOrder([value])}
onClose={() => setOpenFilterModal(null)}
/>
<TVFilterSelector
visible={openFilterModal === "filterBy"}
title={t("library.filters.filter_by")}
options={tvFilterByOptions}
onSelect={handleFilterBySelect}
onClose={() => setOpenFilterModal(null)}
/>
</View>
);
};

View File

@@ -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 <TVLibraries />;
}
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 (
<View className='justify-center items-center h-full'>
<Loader />
</View>
);
if (!libraries)
return (
<View className='h-full w-full flex justify-center items-center'>
<Text className='text-lg text-neutral-500'>
{t("library.no_libraries_found")}
</Text>
</View>
);
return (
<FlashList
extraData={settings}
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingTop: Platform.OS === "android" ? 17 : 0,
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
paddingBottom: 150,
paddingLeft: insets.left + 17,
paddingRight: insets.right + 17,
}}
data={libraries}
renderItem={({ item }) => <LibraryItemCard library={item} />}
keyExtractor={(item) => item.Id || ""}
ItemSeparatorComponent={() =>
settings?.libraryOptions?.display === "row" ? (
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='bg-neutral-800 mx-2 my-4'
/>
) : (
<View className='h-4' />
)
}
/>
);
return <Libraries />;
}

View File

@@ -7,7 +7,7 @@ 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 {
useCallback,
@@ -22,9 +22,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,6 +38,7 @@ 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 { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -59,6 +62,8 @@ export default function search() {
const params = useLocalSearchParams();
const insets = useSafeAreaInsets();
const router = useRouter();
const segments = useSegments();
const from = (segments as string[])[2] || "(search)";
const [user] = useAtom(userAtom);
@@ -199,9 +204,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=")}`;
@@ -440,6 +443,38 @@ 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],
);
// Render TV search page
if (Platform.isTV) {
return (
<TVSearchPage
search={search}
setSearch={setSearch}
debouncedSearch={debouncedSearch}
movies={movies}
series={series}
episodes={episodes}
collections={collections}
actors={actors}
artists={artists}
albums={albums}
songs={songs}
playlists={playlists}
loading={loading}
noResults={noResults}
onItemPress={handleItemPress}
/>
);
}
return (
<ScrollView
keyboardDismissMode='on-drag'
@@ -450,26 +485,6 @@ export default function search() {
paddingBottom: 60,
}}
>
{/* <View
className='flex flex-col'
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}}
> */}
{Platform.isTV && (
<Input
placeholder={t("search.search")}
onChangeText={(text) => {
router.setParams({ q: "" });
setSearch(text);
}}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
clearButtonMode='while-editing'
maxLength={500}
/>
)}
<View
className='flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}

View File

@@ -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 (
<Stack>
<Stack.Screen
name='index'
options={{
headerShown: !Platform.isTV,
headerTitle: t("tabs.settings"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
</Stack>
);
}

View File

@@ -0,0 +1,5 @@
import SettingsTV from "@/app/(auth)/(tabs)/(home)/settings.tv";
export default function SettingsTabScreen() {
return <SettingsTV />;
}

View File

@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Alert,
Platform,
RefreshControl,
TouchableOpacity,
useWindowDimensions,
@@ -16,9 +17,17 @@ 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 MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation";
import {
@@ -32,6 +41,20 @@ import {
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { userAtom } from "@/providers/JellyfinProvider";
const TV_ITEM_GAP = 16;
const TV_SCALE_PADDING = 20;
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
<View style={{ marginTop: 12 }}>
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
{item.Name}
</Text>
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
{item.ProductionYear}
</Text>
</View>
);
export default function WatchlistDetailScreen() {
const { t } = useTranslation();
const router = useRouter();
@@ -47,6 +70,14 @@ export default function WatchlistDetailScreen() {
: undefined;
const nrOfCols = useMemo(() => {
if (Platform.isTV) {
// Calculate columns based on TV poster width + gap
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
return Math.max(
1,
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
);
}
if (screenWidth < 300) return 2;
if (screenWidth < 500) return 3;
if (screenWidth < 800) return 5;
@@ -153,6 +184,37 @@ export default function WatchlistDetailScreen() {
[removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
);
const renderTVItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => {
const handlePress = () => {
const navigation = getItemNavigation(item, "(watchlists)");
router.push(navigation as any);
};
return (
<View
style={{
marginRight: TV_ITEM_GAP,
marginBottom: TV_ITEM_GAP,
width: TV_POSTER_WIDTH,
}}
>
<TVFocusablePoster
onPress={handlePress}
hasTVPreferredFocus={index === 0}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
{(item.Type === "Series" || item.Type === "Episode") && (
<SeriesPoster item={item} />
)}
</TVFocusablePoster>
<TVItemCardText item={item} />
</View>
);
},
[router],
);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<TouchableItemRouter
@@ -278,13 +340,14 @@ export default function WatchlistDetailScreen() {
keyExtractor={keyExtractor}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
paddingLeft: Platform.isTV ? TV_SCALE_PADDING : insets.left,
paddingRight: Platform.isTV ? TV_SCALE_PADDING : insets.right,
paddingTop: Platform.isTV ? TV_SCALE_PADDING : 0,
}}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
renderItem={renderItem}
renderItem={Platform.isTV ? renderTVItem : renderItem}
ItemSeparatorComponent={() => (
<View
style={{

View File

@@ -40,7 +40,7 @@ export default function WatchlistsLayout() {
name='[watchlistId]'
options={{
title: "",
headerShown: true,
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
@@ -51,7 +51,7 @@ export default function WatchlistsLayout() {
options={{
title: t("watchlists.create_title"),
presentation: "modal",
headerShown: true,
headerShown: !Platform.isTV,
headerStyle: { backgroundColor: "#171717" },
headerTintColor: "white",
contentStyle: { backgroundColor: "#171717" },
@@ -62,7 +62,7 @@ export default function WatchlistsLayout() {
options={{
title: t("watchlists.edit_title"),
presentation: "modal",
headerShown: true,
headerShown: !Platform.isTV,
headerStyle: { backgroundColor: "#171717" },
headerTintColor: "white",
contentStyle: { backgroundColor: "#171717" },

View File

@@ -11,12 +11,18 @@ import { withLayoutContext } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
import { Colors } from "@/constants/Colors";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
// Music components are not available on tvOS (TrackPlayer not supported)
const MiniPlayerBar = Platform.isTV
? () => null
: require("@/components/music/MiniPlayerBar").MiniPlayerBar;
const MusicPlaybackEngine = Platform.isTV
? () => null
: require("@/components/music/MusicPlaybackEngine").MusicPlaybackEngine;
const { Navigator } = createNativeBottomTabNavigator();
export const NativeTabs = withLayoutContext<
@@ -117,6 +123,17 @@ export default function TabLayout() {
: (_e) => ({ sfSymbol: "list.dash.fill" }),
}}
/>
<NativeTabs.Screen
name='(settings)'
options={{
title: t("tabs.settings"),
tabBarItemHidden: !Platform.isTV,
tabBarIcon:
Platform.OS === "android"
? (_e) => require("@/assets/icons/list.png")
: (_e) => ({ sfSymbol: "gearshape.fill" }),
}}
/>
</NativeTabs>
<MiniPlayerBar />
<MusicPlaybackEngine />

View File

@@ -20,6 +20,7 @@ import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls";
import { Controls as TVControls } from "@/components/video-player/controls/Controls.tv";
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
import {
@@ -48,6 +49,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import {
getMpvAudioId,
@@ -82,6 +84,13 @@ export default function page() {
const [tracksReady, setTracksReady] = useState(false);
const [hasPlaybackStarted, setHasPlaybackStarted] = useState(false);
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0);
const [showTechnicalInfo, setShowTechnicalInfo] = useState(false);
// TV audio/subtitle selection state (tracks current selection for dynamic changes)
const [currentAudioIndex, setCurrentAudioIndex] = useState<
number | undefined
>(undefined);
const [currentSubtitleIndex, setCurrentSubtitleIndex] = useState<number>(-1);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
@@ -125,7 +134,6 @@ export default function page() {
const { lockOrientation, unlockOrientation } = useOrientation();
const offline = offlineStr === "true";
const playbackManager = usePlaybackManager({ isOffline: offline });
// Audio index: use URL param if provided, otherwise use stored index for offline playback
// This is computed after downloadedItem is available, see audioIndexResolved below
@@ -148,6 +156,10 @@ export default function page() {
isError: false,
});
// Playback manager for progress reporting and adjacent items
const playbackManager = usePlaybackManager({ item, isOffline: offline });
const { nextItem, previousItem } = playbackManager;
// Resolve audio index: use URL param if provided, otherwise use stored index for offline playback
const audioIndex = useMemo(() => {
if (audioIndexFromUrl !== undefined) {
@@ -159,6 +171,17 @@ export default function page() {
return undefined;
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
// Initialize TV audio/subtitle indices from URL params
useEffect(() => {
if (audioIndex !== undefined) {
setCurrentAudioIndex(audioIndex);
}
}, [audioIndex]);
useEffect(() => {
setCurrentSubtitleIndex(subtitleIndex);
}, [subtitleIndex]);
// Get the playback speed for this item based on settings
const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed(
item,
@@ -530,7 +553,11 @@ export default function page() {
subtitleIndex,
isTranscoding,
);
const initialAudioId = getMpvAudioId(mediaSource, audioIndex);
const initialAudioId = getMpvAudioId(
mediaSource,
audioIndex,
isTranscoding,
);
// Calculate start position directly here to avoid timing issues
const startTicks = playbackPositionFromUrl
@@ -726,6 +753,108 @@ export default function page() {
videoRef.current?.seekTo?.(position / 1000);
}, []);
// TV audio track change handler
const handleAudioIndexChange = useCallback(
async (index: number) => {
setCurrentAudioIndex(index);
// Check if we're transcoding
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
// Convert Jellyfin index to MPV track ID
const mpvTrackId = getMpvAudioId(
stream?.mediaSource,
index,
isTranscoding,
);
if (mpvTrackId !== undefined) {
await videoRef.current?.setAudioTrack?.(mpvTrackId);
}
},
[stream?.mediaSource],
);
// TV subtitle track change handler
const handleSubtitleIndexChange = useCallback(
async (index: number) => {
setCurrentSubtitleIndex(index);
// Check if we're transcoding
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
if (index === -1) {
// Disable subtitles
await videoRef.current?.disableSubtitles?.();
} else {
// Convert Jellyfin index to MPV track ID
const mpvTrackId = getMpvSubtitleId(
stream?.mediaSource,
index,
isTranscoding,
);
if (mpvTrackId !== undefined && mpvTrackId !== -1) {
await videoRef.current?.setSubtitleTrack?.(mpvTrackId);
}
}
},
[stream?.mediaSource],
);
// Technical info toggle handler
const handleToggleTechnicalInfo = useCallback(() => {
setShowTechnicalInfo((prev) => !prev);
}, []);
// Get technical info from the player
const getTechnicalInfo = useCallback(async () => {
return (await videoRef.current?.getTechnicalInfo?.()) ?? {};
}, []);
// Determine play method based on stream URL and media source
const playMethod = useMemo<
"DirectPlay" | "DirectStream" | "Transcode" | undefined
>(() => {
if (!stream?.url) return undefined;
// Check if transcoding (m3u8 playlist or TranscodingUrl present)
if (stream.url.includes("m3u8") || stream.mediaSource?.TranscodingUrl) {
return "Transcode";
}
// Check if direct play (no container remuxing needed)
// Direct play means the file is being served as-is
if (stream.url.includes("/Videos/") && stream.url.includes("/stream")) {
return "DirectStream";
}
// Default to direct play if we're not transcoding
return "DirectPlay";
}, [stream?.url, stream?.mediaSource?.TranscodingUrl]);
// Extract transcode reasons from the TranscodingUrl
const transcodeReasons = useMemo<string[]>(() => {
const transcodingUrl = stream?.mediaSource?.TranscodingUrl;
if (!transcodingUrl) return [];
try {
// Parse the TranscodeReasons parameter from the URL
const url = new URL(transcodingUrl, "http://localhost");
const reasons = url.searchParams.get("TranscodeReasons");
if (reasons) {
return reasons.split(",").filter(Boolean);
}
} catch {
// If URL parsing fails, try regex fallback
const match = transcodingUrl.match(/TranscodeReasons=([^&]+)/);
if (match) {
return match[1].split(",").filter(Boolean);
}
}
return [];
}, [stream?.mediaSource?.TranscodingUrl]);
const handleZoomToggle = useCallback(async () => {
const newZoomState = !isZoomedToFill;
await videoRef.current?.setZoomedToFill?.(newZoomState);
@@ -762,6 +891,97 @@ export default function page() {
}
}, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]);
// TV: Navigate to previous item
const goToPreviousItem = useCallback(() => {
if (!previousItem || !settings) return;
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(previousItem, settings, {
indexes: {
subtitleIndex: subtitleIndex,
audioIndex: audioIndex,
},
source: stream?.mediaSource ?? undefined,
});
const queryParams = new URLSearchParams({
itemId: previousItem.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString() ?? "",
playbackPosition:
previousItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
router.replace(`player/direct-player?${queryParams}` as any);
}, [
previousItem,
settings,
subtitleIndex,
audioIndex,
stream?.mediaSource,
bitrateValue,
router,
]);
// TV: Add subtitle file to player (for client-side downloaded subtitles)
const addSubtitleFile = useCallback(async (path: string) => {
await videoRef.current?.addSubtitleFile?.(path, true);
}, []);
// TV: Handle server-side subtitle download (needs media source refresh)
// Note: After downloading via Jellyfin API, the subtitle appears in the track list
// but we need to re-fetch the media source to see it. For now, we just log a message.
// A full implementation would refetch getStreamUrl and update the stream state.
const handleServerSubtitleDownloaded = useCallback(() => {
console.log(
"Server-side subtitle downloaded - track list should be refreshed",
);
// TODO: Implement media source refresh to pick up new subtitle
// This would involve re-calling getStreamUrl and updating the stream state
}, []);
// TV: Navigate to next item
const goToNextItem = useCallback(() => {
if (!nextItem || !settings) return;
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(nextItem, settings, {
indexes: {
subtitleIndex: subtitleIndex,
audioIndex: audioIndex,
},
source: stream?.mediaSource ?? undefined,
});
const queryParams = new URLSearchParams({
itemId: nextItem.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString() ?? "",
playbackPosition:
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
router.replace(`player/direct-player?${queryParams}` as any);
}, [
nextItem,
settings,
subtitleIndex,
audioIndex,
stream?.mediaSource,
bitrateValue,
router,
]);
// Apply subtitle settings when video loads
useEffect(() => {
if (!isVideoLoaded || !videoRef.current) return;
@@ -900,32 +1120,66 @@ export default function page() {
</View>
)}
</View>
{isMounted === true && item && !isPipMode && (
<Controls
mediaSource={stream?.mediaSource}
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
startPictureInPicture={startPictureInPicture}
play={play}
pause={pause}
seek={seek}
enableTrickplay={true}
aspectRatio={aspectRatio}
isZoomedToFill={isZoomedToFill}
onZoomToggle={handleZoomToggle}
api={api}
downloadedFiles={downloadedFiles}
playbackSpeed={currentPlaybackSpeed}
setPlaybackSpeed={handleSetPlaybackSpeed}
/>
)}
{isMounted === true &&
item &&
!isPipMode &&
(Platform.isTV ? (
<TVControls
mediaSource={stream?.mediaSource}
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
play={play}
pause={pause}
seek={seek}
audioIndex={currentAudioIndex}
subtitleIndex={currentSubtitleIndex}
onAudioIndexChange={handleAudioIndexChange}
onSubtitleIndexChange={handleSubtitleIndexChange}
previousItem={previousItem}
nextItem={nextItem}
goToPreviousItem={goToPreviousItem}
goToNextItem={goToNextItem}
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
addSubtitleFile={addSubtitleFile}
/>
) : (
<Controls
mediaSource={stream?.mediaSource}
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
startPictureInPicture={startPictureInPicture}
play={play}
pause={pause}
seek={seek}
enableTrickplay={true}
aspectRatio={aspectRatio}
isZoomedToFill={isZoomedToFill}
onZoomToggle={handleZoomToggle}
api={api}
downloadedFiles={downloadedFiles}
playbackSpeed={currentPlaybackSpeed}
setPlaybackSpeed={handleSetPlaybackSpeed}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={handleToggleTechnicalInfo}
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
/>
))}
</View>
</VideoProvider>
</PlayerProvider>

View File

@@ -0,0 +1,167 @@
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { 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 useRouter from "@/hooks/useAppRouter";
import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal";
import { store } from "@/utils/store";
export default function TVOptionModal() {
const router = useRouter();
const modalState = useAtomValue(tvOptionModalAtom);
const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(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
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);
}, [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();
};
// If no modal state, just go back (shouldn't happen in normal usage)
if (!modalState) {
return null;
}
const { title, options, cardWidth = 160, cardHeight = 75 } = modalState;
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
<Text style={styles.title}>{title}</Text>
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
ref={
index === initialSelectedIndex ? firstCardRef : undefined
}
label={option.label}
sublabel={option.sublabel}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => handleSelect(option.value)}
width={cardWidth}
height={cardHeight}
/>
))}
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}
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: 16,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
},
scrollView: {
overflow: "visible",
},
scrollContent: {
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
},
});

View File

@@ -0,0 +1,918 @@
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 useRouter from "@/hooks/useAppRouter";
import {
type SubtitleSearchResult,
useRemoteSubtitles,
} from "@/hooks/useRemoteSubtitles";
import { tvSubtitleModalAtom } from "@/utils/atoms/tvSubtitleModal";
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
import { store } from "@/utils/store";
type TabType = "tracks" | "download";
// 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 (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
styles.trackCard,
animatedStyle,
{
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
},
]}
>
<Text
style={[
styles.trackCardText,
{ color: focused ? "#000" : "#fff" },
(focused || selected) && { fontWeight: "600" },
]}
numberOfLines={2}
>
{label}
</Text>
{sublabel && (
<Text
style={[
styles.trackCardSublabel,
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
]}
numberOfLines={1}
>
{sublabel}
</Text>
)}
{selected && !focused && (
<View style={styles.checkmark}>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
});
// 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 (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
styles.languageCard,
animatedStyle,
{
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
},
]}
>
<Text
style={[
styles.languageCardText,
{ color: focused ? "#000" : "#fff" },
(focused || selected) && { fontWeight: "600" },
]}
numberOfLines={1}
>
{name}
</Text>
<Text
style={[
styles.languageCardCode,
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
]}
>
{code.toUpperCase()}
</Text>
{selected && !focused && (
<View style={styles.checkmark}>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
});
// 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 (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
disabled={isDownloading}
>
<Animated.View
style={[
styles.resultCard,
animatedStyle,
{
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.08)",
borderColor: focused
? "rgba(255,255,255,0.8)"
: "rgba(255,255,255,0.1)",
},
]}
>
{/* Provider/Source badge */}
<View
style={[
styles.providerBadge,
{
backgroundColor: focused
? "rgba(0,0,0,0.1)"
: "rgba(255,255,255,0.1)",
},
]}
>
<Text
style={[
styles.providerText,
{ color: focused ? "rgba(0,0,0,0.7)" : "rgba(255,255,255,0.7)" },
]}
>
{result.providerName}
</Text>
</View>
{/* Name */}
<Text
style={[styles.resultName, { color: focused ? "#000" : "#fff" }]}
numberOfLines={2}
>
{result.name}
</Text>
{/* Meta info row */}
<View style={styles.resultMeta}>
{/* Format */}
<Text
style={[
styles.resultMetaText,
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
]}
>
{result.format?.toUpperCase()}
</Text>
{/* Rating if available */}
{result.communityRating !== undefined &&
result.communityRating > 0 && (
<View style={styles.ratingContainer}>
<Ionicons
name='star'
size={12}
color={focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)"}
/>
<Text
style={[
styles.resultMetaText,
{
color: focused
? "rgba(0,0,0,0.6)"
: "rgba(255,255,255,0.5)",
},
]}
>
{result.communityRating.toFixed(1)}
</Text>
</View>
)}
{/* Download count if available */}
{result.downloadCount !== undefined && result.downloadCount > 0 && (
<View style={styles.downloadCountContainer}>
<Ionicons
name='download-outline'
size={12}
color={focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)"}
/>
<Text
style={[
styles.resultMetaText,
{
color: focused
? "rgba(0,0,0,0.6)"
: "rgba(255,255,255,0.5)",
},
]}
>
{result.downloadCount.toLocaleString()}
</Text>
</View>
)}
</View>
{/* Flags */}
<View style={styles.flagsContainer}>
{result.isHashMatch && (
<View
style={[
styles.flag,
{
backgroundColor: focused
? "rgba(0,150,0,0.2)"
: "rgba(0,200,0,0.2)",
},
]}
>
<Text style={styles.flagText}>Hash Match</Text>
</View>
)}
{result.hearingImpaired && (
<View
style={[
styles.flag,
{
backgroundColor: focused
? "rgba(0,0,0,0.1)"
: "rgba(255,255,255,0.1)",
},
]}
>
<Ionicons
name='ear-outline'
size={12}
color={focused ? "#000" : "#fff"}
/>
</View>
)}
{result.aiTranslated && (
<View
style={[
styles.flag,
{
backgroundColor: focused
? "rgba(0,0,150,0.2)"
: "rgba(100,100,255,0.2)",
},
]}
>
<Text style={styles.flagText}>AI</Text>
</View>
)}
</View>
{/* Loading indicator when downloading */}
{isDownloading && (
<View style={styles.downloadingOverlay}>
<ActivityIndicator size='small' color='#fff' />
</View>
)}
</Animated.View>
</Pressable>
);
});
export default function TVSubtitleModal() {
const router = useRouter();
const { t } = useTranslation();
const modalState = useAtomValue(tvSubtitleModalAtom);
const [activeTab, setActiveTab] = useState<TabType>("tracks");
const [selectedLanguage, setSelectedLanguage] = useState("eng");
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const [hasSearchedThisSession, setHasSearchedThisSession] = useState(false);
const [isReady, setIsReady] = useState(false);
const [isTabContentReady, setIsTabContentReady] = useState(false);
const firstTrackRef = useRef<View>(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]);
// 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);
}, [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]);
const handleLanguageSelect = useCallback(
(code: string) => {
setSelectedLanguage(code);
search({ language: code });
},
[search],
);
const handleTrackSelect = useCallback(
(index: number) => {
modalState?.onSubtitleIndexChange(index);
handleClose();
},
[modalState, handleClose],
);
const handleDownload = useCallback(
async (result: SubtitleSearchResult) => {
setDownloadingId(result.id);
try {
const downloadResult = await downloadAsync(result);
if (downloadResult.type === "server") {
modalState?.onServerSubtitleDownloaded?.();
} else if (downloadResult.type === "local" && downloadResult.path) {
modalState?.onLocalSubtitleDownloaded?.(downloadResult.path);
}
handleClose();
} catch (error) {
console.error("Failed to download subtitle:", error);
} finally {
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,
};
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 (!modalState) {
return null;
}
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={90} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
{/* Header with tabs */}
<View style={styles.header}>
<Text style={styles.title}>
{t("item_card.subtitles.label") || "Subtitles"}
</Text>
{/* Tab bar */}
<View style={styles.tabRow}>
<TVTabButton
label={t("item_card.subtitles.tracks") || "Tracks"}
active={activeTab === "tracks"}
onSelect={() => setActiveTab("tracks")}
/>
<TVTabButton
label={t("player.download") || "Download"}
active={activeTab === "download"}
onSelect={() => setActiveTab("download")}
/>
</View>
</View>
{/* Tracks Tab Content */}
{activeTab === "tracks" && isTabContentReady && (
<View style={styles.section}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.tracksScroll}
contentContainerStyle={styles.tracksScrollContent}
>
{trackOptions.map((option, index) => (
<TVTrackCard
key={option.value}
ref={
index === initialSelectedTrackIndex
? firstTrackRef
: undefined
}
label={option.label}
sublabel={option.sublabel}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedTrackIndex}
onPress={() => handleTrackSelect(option.value)}
/>
))}
</ScrollView>
</View>
)}
{/* Download Tab Content */}
{activeTab === "download" && isTabContentReady && (
<>
{/* Language Selector */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{t("player.language") || "Language"}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.languageScroll}
contentContainerStyle={styles.languageScrollContent}
>
{displayLanguages.map((lang, index) => (
<LanguageCard
key={lang.code}
code={lang.code}
name={lang.name}
selected={selectedLanguage === lang.code}
hasTVPreferredFocus={
index === 0 &&
(!searchResults || searchResults.length === 0)
}
onPress={() => handleLanguageSelect(lang.code)}
/>
))}
</ScrollView>
</View>
{/* Results Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{t("player.results") || "Results"}
{searchResults && ` (${searchResults.length})`}
</Text>
{/* Loading state */}
{isSearching && (
<View style={styles.loadingContainer}>
<ActivityIndicator size='small' color='#fff' />
</View>
)}
{/* Error state */}
{searchError && !isSearching && (
<View style={styles.errorContainer}>
<Ionicons
name='alert-circle-outline'
size={32}
color='rgba(255,100,100,0.8)'
/>
<Text style={styles.errorText}>
{t("player.search_failed") || "Search failed"}
</Text>
<Text style={styles.errorHint}>
{!hasOpenSubtitlesApiKey
? t("player.no_subtitle_provider") ||
"No subtitle provider configured on server"
: String(searchError)}
</Text>
</View>
)}
{/* No results */}
{searchResults &&
searchResults.length === 0 &&
!isSearching &&
!searchError && (
<View style={styles.emptyContainer}>
<Ionicons
name='document-text-outline'
size={32}
color='rgba(255,255,255,0.4)'
/>
<Text style={styles.emptyText}>
{t("player.no_subtitles_found") ||
"No subtitles found"}
</Text>
</View>
)}
{/* Results list */}
{searchResults &&
searchResults.length > 0 &&
!isSearching && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.resultsScroll}
contentContainerStyle={styles.resultsScrollContent}
>
{searchResults.map((result, index) => (
<SubtitleResultCard
key={result.id}
result={result}
hasTVPreferredFocus={index === 0}
isDownloading={downloadingId === result.id}
onPress={() => handleDownload(result)}
/>
))}
</ScrollView>
)}
</View>
{/* API Key hint if no fallback available */}
{!hasOpenSubtitlesApiKey && (
<View style={styles.apiKeyHint}>
<Ionicons
name='information-circle-outline'
size={16}
color='rgba(255,255,255,0.4)'
/>
<Text style={styles.apiKeyHintText}>
{t("player.add_opensubtitles_key_hint") ||
"Add OpenSubtitles API key in settings for client-side fallback"}
</Text>
</View>
)}
</>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.6)",
justifyContent: "flex-end",
},
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,
},
trackCard: {
width: 180,
height: 80,
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
},
trackCardText: {
fontSize: 16,
textAlign: "center",
},
trackCardSublabel: {
fontSize: 12,
marginTop: 2,
},
checkmark: {
position: "absolute",
top: 8,
right: 8,
},
languageScroll: {
overflow: "visible",
},
languageScrollContent: {
paddingHorizontal: 48,
paddingVertical: 8,
gap: 10,
},
languageCard: {
width: 120,
height: 60,
borderRadius: 12,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
},
languageCardText: {
fontSize: 15,
fontWeight: "500",
},
languageCardCode: {
fontSize: 11,
marginTop: 2,
},
resultsScroll: {
overflow: "visible",
},
resultsScrollContent: {
paddingHorizontal: 48,
paddingVertical: 8,
gap: 12,
},
resultCard: {
width: 220,
height: 130,
borderRadius: 14,
padding: 14,
borderWidth: 1,
overflow: "hidden",
},
providerBadge: {
alignSelf: "flex-start",
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 6,
marginBottom: 8,
},
providerText: {
fontSize: 11,
fontWeight: "600",
textTransform: "uppercase",
letterSpacing: 0.5,
},
resultName: {
fontSize: 14,
fontWeight: "500",
marginBottom: 8,
lineHeight: 18,
},
resultMeta: {
flexDirection: "row",
alignItems: "center",
gap: 12,
marginBottom: 8,
},
resultMetaText: {
fontSize: 12,
},
ratingContainer: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
downloadCountContainer: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
flagsContainer: {
flexDirection: "row",
gap: 6,
flexWrap: "wrap",
},
flag: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
flagText: {
fontSize: 10,
fontWeight: "600",
color: "#fff",
},
downloadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.5)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
},
loadingContainer: {
paddingVertical: 20,
alignItems: "center",
},
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,
},
});

View File

@@ -10,6 +10,7 @@ import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device";
import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
@@ -59,7 +60,7 @@ import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import useRouter from "@/hooks/useAppRouter";
import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store";
import { store as jotaiStore, store } from "@/utils/store";
import "react-native-reanimated";
import { Toaster } from "sonner-native";
@@ -178,7 +179,7 @@ export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<JotaiProvider>
<JotaiProvider store={jotaiStore}>
<ActionSheetProvider>
<I18nextProvider i18n={i18n}>
<Layout />
@@ -428,6 +429,22 @@ function Layout() {
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
@@ -443,7 +460,7 @@ function Layout() {
}}
closeButton
/>
<GlobalModal />
{!Platform.isTV && <GlobalModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>

View File

@@ -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<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [serverURL, setServerURL] = useState<string>(_apiUrl || "");
const [serverName, setServerName] = useState<string>("");
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 ? (
<TouchableOpacity
onPress={() => {
removeServer();
}}
className='flex flex-row items-center pr-2 pl-1'
>
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
<Text className=' ml-1 text-purple-600'>
{t("login.change_server")}
</Text>
</TouchableOpacity>
) : 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<string | undefined>} 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 <TVLogin />;
}
/**
* 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<void>}
*
* 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
<SafeAreaView className='flex-1 bg-black'>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
{api?.basePath ? (
// ------------ Username/Password view ------------
<View className='flex-1 items-center justify-center'>
{/* Safe centered column with max width so TV doesnt stretch too far */}
<View className='w-[92%] max-w-[900px] px-2 -mt-12'>
<Text className='text-3xl font-bold text-white mb-1'>
{serverName ? (
<>
{`${t("login.login_to_title")} `}
<Text className='text-purple-500'>{serverName}</Text>
</>
) : (
t("login.login_title")
)}
</Text>
<Text className='text-xs text-neutral-400 mb-6'>
{api.basePath}
</Text>
{/* Username */}
<Input
placeholder={t("login.username_placeholder")}
onChangeText={(text: string) =>
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 */}
<Input
placeholder={t("login.password_placeholder")}
onChangeText={(text: string) =>
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}
/>
<View className='mt-4'>
<Button
onPress={handleLogin}
disabled={!credentials.username.trim()}
>
{t("login.login_button")}
</Button>
</View>
<View className='mt-3'>
<Button
onPress={handleQuickConnect}
className='bg-neutral-800 border border-neutral-700'
>
{t("login.quick_connect")}
</Button>
</View>
</View>
</View>
) : (
// ------------ Server connect view ------------
<View className='flex-1 items-center justify-center'>
<View className='w-[92%] max-w-[900px] -mt-2'>
<View className='items-center mb-1'>
<Image
source={require("@/assets/images/icon-ios-plain.png")}
style={{ width: 110, height: 110 }}
contentFit='contain'
/>
</View>
<Text className='text-white text-4xl font-bold text-center'>
Streamyfin
</Text>
<Text className='text-neutral-400 text-base text-left mt-2 mb-1'>
{t("server.enter_url_to_jellyfin_server")}
</Text>
{/* Full-width Input with clear focus ring */}
<Input
aria-label='Server URL'
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
maxLength={500}
autoFocus={false}
blurOnSubmit={true}
/>
{/* Full-width primary button */}
<View className='mt-4'>
<Button
onPress={async () => {
await handleConnect(serverURL);
}}
>
{t("server.connect_button")}
</Button>
</View>
{/* Lists stay full width but inside max width container */}
<View className='mt-2'>
<JellyfinServerDiscovery
onServerSelect={async (server: any) => {
setServerURL(server.address);
if (server.serverName) setServerName(server.serverName);
await handleConnect(server.address);
}}
/>
<PreviousServersList
onServerSelect={async (s) => {
await handleConnect(s.address);
}}
onQuickLogin={handleQuickLoginWithSavedCredential}
onPasswordLogin={handlePasswordLogin}
onAddAccount={handleAddAccount}
/>
</View>
</View>
</View>
)}
</KeyboardAvoidingView>
</SafeAreaView>
) : (
// Mobile layout
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={{ flex: 1 }}
>
{api?.basePath ? (
<View className='flex flex-col flex-1 justify-center'>
<View className='px-4 w-full'>
<View className='flex flex-col space-y-2'>
<Text className='text-2xl font-bold -mb-2'>
{serverName ? (
<>
{`${t("login.login_to_title")} `}
<Text className='text-purple-600'>{serverName}</Text>
</>
) : (
t("login.login_title")
)}
</Text>
<Text className='text-xs text-neutral-400'>{api.basePath}</Text>
<Input
placeholder={t("login.username_placeholder")}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, username: text }))
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.username) {
setCredentials((prev) => ({
...prev,
username: newValue,
}));
}
}}
value={credentials.username}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
autoCorrect={false}
textContentType='username'
clearButtonMode='while-editing'
maxLength={500}
/>
<Input
placeholder={t("login.password_placeholder")}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, password: text }))
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.password) {
setCredentials((prev) => ({
...prev,
password: newValue,
}));
}
}}
value={credentials.password}
secureTextEntry
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
clearButtonMode='while-editing'
maxLength={500}
/>
<TouchableOpacity
onPress={() => setSaveAccount(!saveAccount)}
className='flex flex-row items-center py-2'
activeOpacity={0.7}
>
<Switch
value={saveAccount}
onValueChange={setSaveAccount}
trackColor={{ false: "#3f3f46", true: Colors.primary }}
thumbColor='white'
/>
<Text className='ml-3 text-neutral-300'>
{t("save_account.save_for_later")}
</Text>
</TouchableOpacity>
<View className='flex flex-row items-center justify-between'>
<Button
onPress={handleLogin}
loading={loading}
disabled={!credentials.username.trim()}
className='flex-1 mr-2'
>
{t("login.login_button")}
</Button>
<TouchableOpacity
onPress={handleQuickConnect}
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
>
<MaterialCommunityIcons
name='cellphone-lock'
size={24}
color='white'
/>
</TouchableOpacity>
</View>
</View>
</View>
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
</View>
) : (
<View className='flex flex-col flex-1 items-center justify-center w-full'>
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
<Image
style={{
width: 100,
height: 100,
marginLeft: -23,
marginBottom: -20,
}}
source={require("@/assets/images/icon-ios-plain.png")}
/>
<Text className='text-3xl font-bold'>Streamyfin</Text>
<Text className='text-neutral-500'>
{t("server.enter_url_to_jellyfin_server")}
</Text>
<Input
aria-label='Server URL'
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
maxLength={500}
/>
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => {
await handleConnect(serverURL);
}}
className='w-full grow'
>
{t("server.connect_button")}
</Button>
<JellyfinServerDiscovery
onServerSelect={async (server) => {
setServerURL(server.address);
if (server.serverName) {
setServerName(server.serverName);
}
await handleConnect(server.address);
}}
/>
<PreviousServersList
onServerSelect={async (s) => {
await handleConnect(s.address);
}}
onQuickLogin={handleQuickLoginWithSavedCredential}
onPasswordLogin={handlePasswordLogin}
onAddAccount={handleAddAccount}
/>
</View>
</View>
)}
</KeyboardAvoidingView>
{/* Save Account Modal */}
<SaveAccountModal
visible={showSaveModal}
onClose={() => {
setShowSaveModal(false);
setPendingLogin(null);
}}
onSave={handleSaveAccountConfirm}
username={pendingLogin?.username || credentials.username}
/>
</SafeAreaView>
);
return <Login />;
};
export default Login;
export default LoginPage;

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

BIN
assets/images/icon-tvos.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

629
bun.lock
View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "streamyfin",
@@ -18,7 +19,7 @@
"@shopify/flash-list": "2.0.2",
"@tanstack/query-sync-storage-persister": "^5.90.18",
"@tanstack/react-pacer": "^0.19.1",
"@tanstack/react-query": "5.90.12",
"@tanstack/react-query": "5.90.17",
"@tanstack/react-query-persist-client": "^5.90.18",
"axios": "^1.7.9",
"expo": "~54.0.31",
@@ -50,14 +51,14 @@
"expo-task-manager": "14.0.9",
"expo-web-browser": "~15.0.10",
"i18next": "^25.0.0",
"jotai": "2.16.1",
"jotai": "2.16.2",
"lodash": "4.17.21",
"nativewind": "^2.0.11",
"patch-package": "^8.0.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "16.5.1",
"react-native": "0.81.5",
"react-i18next": "16.5.3",
"react-native": "npm:react-native-tvos@0.81.5-2",
"react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "1.1.0",
"react-native-circular-progress": "^1.4.1",
@@ -73,7 +74,7 @@
"react-native-ios-context-menu": "^3.2.1",
"react-native-ios-utilities": "5.2.0",
"react-native-mmkv": "4.1.1",
"react-native-nitro-modules": "0.32.1",
"react-native-nitro-modules": "0.33.1",
"react-native-pager-view": "^6.9.1",
"react-native-reanimated": "~4.1.1",
"react-native-reanimated-carousel": "4.0.3",
@@ -94,12 +95,12 @@
"zod": "4.1.13",
},
"devDependencies": {
"@babel/core": "7.28.5",
"@babel/core": "7.28.6",
"@biomejs/biome": "2.3.11",
"@react-native-community/cli": "20.1.0",
"@react-native-tvos/config-tv": "0.1.4",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.21",
"@types/lodash": "4.17.23",
"@types/react": "19.1.17",
"@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0",
@@ -119,17 +120,17 @@
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/code-frame": ["@babel/code-frame@7.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/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
"@babel/compat-data": ["@babel/compat-data@7.28.6", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="],
"@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
"@babel/core": ["@babel/core@7.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/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
"@babel/generator": ["@babel/generator@7.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/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-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.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-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=="],
@@ -143,7 +144,7 @@
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.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-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="],
@@ -163,11 +164,11 @@
"@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/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="],
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
"@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.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
"@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.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=="],
@@ -299,13 +300,13 @@
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
"@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/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.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/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/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=="],
"@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/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/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=="],
@@ -539,6 +540,8 @@
"@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.4", "", { "dependencies": { "getenv": "^1.0.0" }, "peerDependencies": { "expo": ">=52.0.0" } }, "sha512-xfVDqSFjEUsb+xcMk0hE2Z/M6QZH0QzAJOSQZwo7W/ZRaLrd+xFQnx0LaXqt3kxlR3P7wskKHByDP/FSoUZnbA=="],
"@react-native-tvos/virtualized-lists": ["@react-native-tvos/virtualized-lists@0.81.5-2", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-i5L6sJ8Dae5JUWhfb5w/RgZUm3CYRFhV5/PB/xu3ASxFyHjfO0kQAqcU3ySNAOR0HfmaXK8R4OC0h07zoUWKrQ=="],
"@react-native/assets-registry": ["@react-native/assets-registry@0.81.5", "", {}, "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w=="],
"@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=="],
@@ -559,8 +562,6 @@
"@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="],
"@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-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-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=="],
@@ -601,7 +602,7 @@
"@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": ["@tanstack/react-query@5.90.12", "", { "dependencies": { "@tanstack/query-core": "5.90.12" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.17", "", { "dependencies": { "@tanstack/query-core": "5.90.17" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-PGc2u9KLwohDUSchjW9MZqeDQJfJDON7y4W7REdNBgiFKxQy+Pf7eGjiFWEj5xPqKzAeHYdAb62IWI1a9UJyGQ=="],
"@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=="],
@@ -633,7 +634,7 @@
"@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.21", "", {}, "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ=="],
"@types/lodash": ["@types/lodash@4.17.23", "", {}, "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA=="],
"@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
@@ -1305,7 +1306,7 @@
"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.1", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-vrHcAbo3P7Br37C8Bv6JshMtlKMPqqmx0DDREtTjT4nf3QChDrYdbH+4ik/9V0cXA57dK28RkJ5dctYvavcIlg=="],
"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=="],
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
@@ -1639,11 +1640,11 @@
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
"react-i18next": ["react-i18next@16.5.1", "", { "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-Hks6UIRZWW4c+qDAnx1csVsCGYeIR4MoBGQgJ+NUoNnO6qLxXuf8zu0xdcinyXUORgGzCdRsexxO1Xzv3sTdnw=="],
"react-i18next": ["react-i18next@16.5.3", "", { "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-fo+/NNch37zqxOzlBYrWMx0uy/yInPkRfjSuy4lqKdaecR17nvCHnEUt3QyzA8XjQ2B/0iW/5BhaHR3ZmukpGw=="],
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
"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.81.5-2", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native-tvos/virtualized-lists": "0.81.5-2", "@react-native/assets-registry": "0.81.5", "@react-native/codegen": "0.81.5", "@react-native/community-cli-plugin": "0.81.5", "@react-native/gradle-plugin": "0.81.5", "@react-native/js-polyfills": "0.81.5", "@react-native/normalize-colors": "0.81.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.29.1", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.1", "metro-source-map": "^0.83.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.26.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "^19.1.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-y/V8iFZGNXQq6b+X9VBQG19PaBpAXQHhv2vhcCMe2gEePqI2Uu8n3ClqglBn8u+Fl/GXCMcFdnJ0v0nRyxJ5TA=="],
"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=="],
@@ -1677,7 +1678,7 @@
"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.32.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-V+Vy76e4fxRxgVGu5Uh3cBPvuFQW8fM1OUKk1mqEA/JawjhX+hxHtBhpfuvNjV0BnV/uXCIg8/eK+rTpB6tqFg=="],
"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=="],
@@ -2041,16 +2042,76 @@
"zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="],
"@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@babel/helper-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=="],
"@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-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/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/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=="],
"@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=="],
@@ -2099,6 +2160,12 @@
"@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/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=="],
@@ -2123,6 +2190,8 @@
"@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=="],
"@jimp/png/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
@@ -2141,6 +2210,16 @@
"@react-native-community/cli-tools/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"@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/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/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.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/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/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=="],
"@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=="],
@@ -2163,7 +2242,19 @@
"@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.12", "", {}, "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg=="],
"@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.17", "", {}, "sha512-hDww+RyyYhjhUfoYQ4es6pbgxY7LNiPWxt4l1nJqhByjndxJ7HIjDxTBtfvMr5HwjYavMrd+ids5g4Rfev3lVQ=="],
"@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=="],
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
@@ -2175,6 +2266,14 @@
"babel-jest/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"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-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=="],
@@ -2231,6 +2330,12 @@
"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-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
@@ -2245,14 +2350,52 @@
"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.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/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/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
"metro-babel-transformer/@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/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="],
"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-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.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-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=="],
"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=="],
@@ -2261,6 +2404,8 @@
"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=="],
"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=="],
@@ -2341,12 +2486,146 @@
"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=="],
"@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=="],
@@ -2367,6 +2646,24 @@
"@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=="],
@@ -2387,8 +2684,90 @@
"@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=="],
"@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=="],
@@ -2443,6 +2822,14 @@
"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=="],
@@ -2473,6 +2860,24 @@
"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=="],
"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=="],
"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=="],
@@ -2487,10 +2892,86 @@
"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/@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-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-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-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-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=="],
@@ -2523,6 +3004,42 @@
"@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=="],
@@ -2535,6 +3052,10 @@
"@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=="],
@@ -2547,6 +3068,26 @@
"@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=="],
@@ -2571,6 +3112,14 @@
"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=="],
@@ -2583,6 +3132,10 @@
"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=="],
"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=="],
@@ -2593,6 +3146,22 @@
"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=="],
"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=="],
"metro-transform-plugins/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
"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=="],
"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=="],
@@ -2617,6 +3186,10 @@
"@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=="],

View File

@@ -28,7 +28,7 @@ export const Badge: React.FC<Props> = ({
</View>
);
if (Platform.OS === "ios") {
if (Platform.OS === "ios" && !Platform.isTV) {
return (
<View {...props} style={[styles.container, props.style]}>
<GlassEffectView style={{ borderRadius: 100 }}>
@@ -38,21 +38,37 @@ export const Badge: React.FC<Props> = ({
);
}
// On TV, use transparent backgrounds for a cleaner look
const isTV = Platform.isTV;
return (
<View
{...props}
className={`
rounded p-1 shrink grow-0 self-start flex flex-row items-center px-1.5
${variant === "purple" && "bg-purple-600"}
${variant === "gray" && "bg-neutral-800"}
`}
style={[
{
borderRadius: 4,
padding: 4,
paddingHorizontal: 6,
flexShrink: 1,
flexGrow: 0,
alignSelf: "flex-start",
flexDirection: "row",
alignItems: "center",
backgroundColor: isTV
? "rgba(255,255,255,0.1)"
: variant === "purple"
? "#9333ea"
: "#262626",
},
props.style,
]}
>
{iconLeft && <View className='mr-1'>{iconLeft}</View>}
{iconLeft && <View style={{ marginRight: 4 }}>{iconLeft}</View>}
<Text
className={`
text-xs
${variant === "purple" && "text-white"}
`}
style={{
fontSize: 12,
color: "#fff",
}}
>
{text}
</Text>

View File

@@ -122,7 +122,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.08);
animateTo(1.03);
}}
onBlur={() => {
setFocused(false);
@@ -132,10 +132,10 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
<Animated.View
style={{
transform: [{ scale }],
shadowColor: "#a855f7",
shadowColor: color === "black" ? "#ffffff" : "#a855f7",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.9 : 0,
shadowRadius: focused ? 18 : 0,
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? 10 : 0,
elevation: focused ? 12 : 0, // Android glow
}}
>

View File

@@ -0,0 +1,128 @@
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 type React from "react";
import { useMemo } from "react";
import { View } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider";
import { ProgressBar } from "./common/ProgressBar";
import { WatchedIndicator } from "./WatchedIndicator";
export const TV_LANDSCAPE_WIDTH = 340;
type ContinueWatchingPosterProps = {
item: BaseItemDto;
useEpisodePoster?: boolean;
size?: "small" | "normal";
showPlayButton?: boolean;
};
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
item,
useEpisodePoster = false,
// TV version uses fixed width, size prop kept for API compatibility
size: _size = "normal",
showPlayButton = false,
}) => {
const api = useAtomValue(apiAtom);
const url = useMemo(() => {
if (!api) {
return;
}
if (item.Type === "Episode" && useEpisodePoster) {
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
}
if (item.Type === "Episode") {
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ParentThumbImageTag}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
}
if (item.Type === "Movie") {
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
}
if (item.Type === "Program") {
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
}
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
}, [api, item, useEpisodePoster]);
if (!url) {
return (
<View
style={{
width: TV_LANDSCAPE_WIDTH,
aspectRatio: 16 / 9,
borderRadius: 24,
}}
/>
);
}
return (
<View
style={{
position: "relative",
width: TV_LANDSCAPE_WIDTH,
aspectRatio: 16 / 9,
borderRadius: 24,
overflow: "hidden",
}}
>
<View
style={{
width: "100%",
height: "100%",
alignItems: "center",
justifyContent: "center",
}}
>
<Image
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
width: "100%",
height: "100%",
}}
/>
{showPlayButton && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name='play-circle' size={56} color='white' />
</View>
)}
</View>
{!item.UserData?.Played && <WatchedIndicator item={item} />}
<ProgressBar item={item} />
</View>
);
};
export default ContinueWatchingPoster;

View File

@@ -23,7 +23,7 @@ export const Tag: React.FC<
textStyle?: StyleProp<TextStyle>;
} & ViewProps
> = ({ text, textClass, textStyle, ...props }) => {
if (Platform.OS === "ios") {
if (Platform.OS === "ios" && !Platform.isTV) {
return (
<View>
<GlassEffectView style={styles.glass}>

View File

@@ -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";
@@ -36,6 +35,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;
@@ -45,229 +47,243 @@ export type SelectedOptions = {
};
interface ItemContentProps {
item: BaseItemDto;
item?: BaseItemDto | null;
itemWithSources?: BaseItemDto | null;
isLoading?: boolean;
}
export const ItemContent: React.FC<ItemContentProps> = React.memo(
({ item, itemWithSources }) => {
const [api] = useAtom(apiAtom);
const isOffline = useOfflineMode();
const { settings } = useSettings();
const { orientation } = useOrientation();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const [user] = useAtom(userAtom);
// Mobile-specific implementation
const ItemContentMobile: React.FC<ItemContentProps> = ({
item,
itemWithSources,
}) => {
const [api] = useAtom(apiAtom);
const isOffline = useOfflineMode();
const { settings } = useSettings();
const { orientation } = useOrientation();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const [user] = useAtom(userAtom);
const itemColors = useImageColorsReturn({ item });
const itemColors = useImageColorsReturn({ item });
const [loadingLogo, setLoadingLogo] = useState(true);
const [headerHeight, setHeaderHeight] = useState(350);
const [loadingLogo, setLoadingLogo] = useState(true);
const [headerHeight, setHeaderHeight] = useState(350);
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined
>(undefined);
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined
>(undefined);
// 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 {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null),
[api, item],
);
const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null),
[api, item],
);
const onLogoLoad = React.useCallback(() => {
setLoadingLogo(false);
}, []);
const onLogoLoad = React.useCallback(() => {
setLoadingLogo(false);
}, []);
const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
// Needs to automatically change the selected to the default values for default indexes.
useEffect(() => {
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource ?? undefined,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
}, [
defaultAudioIndex,
defaultBitrate,
defaultSubtitleIndex,
defaultMediaSource,
]);
// Needs to automatically change the selected to the default values for default indexes.
useEffect(() => {
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource ?? undefined,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
}, [
defaultAudioIndex,
defaultBitrate,
defaultSubtitleIndex,
defaultMediaSource,
]);
useEffect(() => {
if (!Platform.isTV && itemWithSources) {
navigation.setOptions({
headerRight: () =>
item &&
(Platform.OS === "ios" ? (
<View className='flex flex-row items-center pl-2'>
<Chromecast.Chromecast width={22} height={22} />
{item.Type !== "Program" && (
<View className='flex flex-row items-center'>
{!Platform.isTV && (
<DownloadSingleItem item={itemWithSources} size='large' />
useEffect(() => {
if (!Platform.isTV && itemWithSources) {
navigation.setOptions({
headerRight: () =>
item &&
(Platform.OS === "ios" ? (
<View className='flex flex-row items-center pl-2'>
<Chromecast.Chromecast width={22} height={22} />
{item.Type !== "Program" && (
<View className='flex flex-row items-center'>
{!Platform.isTV && (
<DownloadSingleItem item={itemWithSources} size='large' />
)}
{user?.Policy?.IsAdministrator &&
!settings.hideRemoteSessionButton && (
<PlayInRemoteSessionButton item={item} size='large' />
)}
{user?.Policy?.IsAdministrator &&
!settings.hideRemoteSessionButton && (
<PlayInRemoteSessionButton item={item} size='large' />
)}
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
{settings.streamyStatsServerUrl &&
!settings.hideWatchlistsTab && (
<AddToWatchlist item={item} />
)}
</View>
)}
</View>
) : (
<View className='flex flex-row items-center space-x-2'>
<Chromecast.Chromecast width={22} height={22} />
{item.Type !== "Program" && (
<View className='flex flex-row items-center space-x-2'>
{!Platform.isTV && (
<DownloadSingleItem item={itemWithSources} size='large' />
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
{settings.streamyStatsServerUrl &&
!settings.hideWatchlistsTab && (
<AddToWatchlist item={item} />
)}
{user?.Policy?.IsAdministrator &&
!settings.hideRemoteSessionButton && (
<PlayInRemoteSessionButton item={item} size='large' />
)}
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
{settings.streamyStatsServerUrl &&
!settings.hideWatchlistsTab && (
<AddToWatchlist item={item} />
)}
</View>
)}
</View>
)),
});
}
}, [
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 (
<View
className='flex-1 relative'
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<ParallaxScrollView
className='flex-1'
headerHeight={headerHeight}
headerImage={
<View style={[{ flex: 1 }]}>
<ItemImage
variant={
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
}
item={item}
style={{
width: "100%",
height: "100%",
}}
/>
</View>
)}
</View>
}
logo={
logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
}}
contentFit='contain'
onLoad={onLogoLoad}
onError={onLogoLoad}
/>
) : (
<View />
)
}
>
<View className='flex flex-col bg-transparent shrink'>
<View className='flex flex-col px-4 w-full pt-2 mb-2 shrink'>
<ItemHeader item={item} className='mb-2' />
) : (
<View className='flex flex-row items-center space-x-2'>
<Chromecast.Chromecast width={22} height={22} />
{item.Type !== "Program" && (
<View className='flex flex-row items-center space-x-2'>
{!Platform.isTV && (
<DownloadSingleItem item={itemWithSources} size='large' />
)}
{user?.Policy?.IsAdministrator &&
!settings.hideRemoteSessionButton && (
<PlayInRemoteSessionButton item={item} size='large' />
)}
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
<PlayButton
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
{settings.streamyStatsServerUrl &&
!settings.hideWatchlistsTab && (
<AddToWatchlist item={item} />
)}
</View>
)}
</View>
)),
});
}
}, [
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 (
<View
className='flex-1 relative'
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<ParallaxScrollView
className='flex-1'
headerHeight={headerHeight}
headerImage={
<View style={[{ flex: 1 }]}>
<ItemImage
variant={
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
}
item={item}
style={{
width: "100%",
height: "100%",
}}
/>
</View>
}
logo={
logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
}}
contentFit='contain'
onLoad={onLogoLoad}
onError={onLogoLoad}
/>
) : (
<View />
)
}
>
<View className='flex flex-col bg-transparent shrink'>
<View className='flex flex-col px-4 w-full pt-2 mb-2 shrink'>
<ItemHeader item={item} className='mb-2' />
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
<PlayButton
selectedOptions={selectedOptions}
item={item}
colors={itemColors}
/>
<View className='w-1' />
{!isOffline && (
<MediaSourceButton
selectedOptions={selectedOptions}
item={item}
setSelectedOptions={setSelectedOptions}
item={itemWithSources}
colors={itemColors}
/>
<View className='w-1' />
{!isOffline && (
<MediaSourceButton
selectedOptions={selectedOptions}
setSelectedOptions={setSelectedOptions}
item={itemWithSources}
colors={itemColors}
/>
)}
</View>
)}
</View>
{item.Type === "Episode" && (
<SeasonEpisodesCarousel item={item} loading={loading} />
</View>
{item.Type === "Episode" && (
<SeasonEpisodesCarousel item={item} loading={loading} />
)}
{!isOffline &&
selectedOptions.mediaSource?.MediaStreams &&
selectedOptions.mediaSource.MediaStreams.length > 0 && (
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
)}
{!isOffline &&
selectedOptions.mediaSource?.MediaStreams &&
selectedOptions.mediaSource.MediaStreams.length > 0 && (
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
<OverviewText text={item.Overview} className='px-4 mb-4' />
{item.Type !== "Program" && (
<>
{item.Type === "Episode" && !isOffline && (
<CurrentSeries item={item} className='mb-2' />
)}
<OverviewText text={item.Overview} className='px-4 mb-4' />
<ItemPeopleSections item={item} />
{item.Type !== "Program" && (
<>
{item.Type === "Episode" && !isOffline && (
<CurrentSeries item={item} className='mb-2' />
)}
{!isOffline && <SimilarItems itemId={item.Id} />}
</>
)}
</View>
</ParallaxScrollView>
</View>
);
};
<ItemPeopleSections item={item} />
// Memoize the mobile component
const MemoizedItemContentMobile = React.memo(ItemContentMobile);
{!isOffline && <SimilarItems itemId={item.Id} />}
</>
)}
</View>
</ParallaxScrollView>
</View>
);
},
);
// Exported component that renders TV or mobile version based on platform
export const ItemContent: React.FC<ItemContentProps> = (props) => {
if (Platform.isTV && ItemContentTV) {
return <ItemContentTV {...props} />;
}
return <MemoizedItemContentMobile {...props} />;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,160 @@
import React from "react";
import { Dimensions, View } from "react-native";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
export const ItemContentSkeletonTV: React.FC = () => {
return (
<View
style={{
flex: 1,
flexDirection: "row",
paddingTop: 180,
paddingHorizontal: 160,
}}
>
{/* Left side - Poster placeholder */}
<View
style={{
width: SCREEN_WIDTH * 0.22,
marginRight: 50,
}}
>
<View
style={{
aspectRatio: 2 / 3,
borderRadius: 16,
backgroundColor: "#1a1a1a",
}}
/>
</View>
{/* Right side - Content placeholders */}
<View style={{ flex: 1, justifyContent: "center" }}>
{/* Logo/Title placeholder */}
<View
style={{
height: 80,
width: "60%",
backgroundColor: "#1a1a1a",
borderRadius: 8,
marginBottom: 24,
}}
/>
{/* Metadata badges row */}
<View
style={{
flexDirection: "row",
gap: 12,
marginBottom: 20,
}}
>
<View
style={{
height: 24,
width: 60,
backgroundColor: "#1a1a1a",
borderRadius: 4,
}}
/>
<View
style={{
height: 24,
width: 80,
backgroundColor: "#1a1a1a",
borderRadius: 4,
}}
/>
<View
style={{
height: 24,
width: 50,
backgroundColor: "#1a1a1a",
borderRadius: 4,
}}
/>
</View>
{/* Genres placeholder */}
<View
style={{
flexDirection: "row",
gap: 8,
marginBottom: 24,
}}
>
<View
style={{
height: 28,
width: 80,
backgroundColor: "#1a1a1a",
borderRadius: 14,
}}
/>
<View
style={{
height: 28,
width: 100,
backgroundColor: "#1a1a1a",
borderRadius: 14,
}}
/>
<View
style={{
height: 28,
width: 70,
backgroundColor: "#1a1a1a",
borderRadius: 14,
}}
/>
</View>
{/* Overview placeholder */}
<View
style={{
maxWidth: SCREEN_WIDTH * 0.45,
marginBottom: 32,
}}
>
<View
style={{
height: 18,
width: "100%",
backgroundColor: "#1a1a1a",
borderRadius: 4,
marginBottom: 8,
}}
/>
<View
style={{
height: 18,
width: "90%",
backgroundColor: "#1a1a1a",
borderRadius: 4,
marginBottom: 8,
}}
/>
<View
style={{
height: 18,
width: "75%",
backgroundColor: "#1a1a1a",
borderRadius: 4,
}}
/>
</View>
{/* Play button placeholder */}
<View
style={{
height: 56,
width: 180,
backgroundColor: "#1a1a1a",
borderRadius: 12,
}}
/>
</View>
</View>
);
};

View File

@@ -77,7 +77,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
<View>
<Text className='text-lg font-bold mb-2'>
{t("item_card.subtitles")}
{t("item_card.subtitles.label")}
</Text>
<SubtitleStreamInfo
subtitleStreams={

View File

@@ -142,7 +142,7 @@ export const MediaSourceButton: React.FC<Props> = ({
}));
groups.push({
title: t("item_card.subtitles"),
title: t("item_card.subtitles.label"),
options: [noneOption, ...subtitleOptions],
});
}

View File

@@ -25,7 +25,14 @@ export type ToggleOption = {
disabled?: boolean;
};
export type Option = RadioOption | ToggleOption;
export type ActionOption = {
type: "action";
label: string;
onPress: () => void;
disabled?: boolean;
};
export type Option = RadioOption | ToggleOption | ActionOption;
// Option group structure
export type OptionGroup = {
@@ -64,7 +71,10 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
isLast,
}) => {
const isToggle = option.type === "toggle";
const handlePress = isToggle ? option.onToggle : option.onPress;
const isAction = option.type === "action";
const handlePress = isToggle
? option.onToggle
: (option as RadioOption | ActionOption).onPress;
return (
<>
@@ -76,7 +86,7 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
<Text className='flex-1 text-white'>{option.label}</Text>
{isToggle ? (
<ToggleSwitch value={option.value} />
) : option.selected ? (
) : isAction ? null : (option as RadioOption).selected ? (
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
) : (
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
@@ -150,6 +160,15 @@ const BottomSheetContent: React.FC<{
},
};
}
if (option.type === "action") {
return {
...option,
onPress: () => {
option.onPress();
onClose?.();
},
};
}
return option;
}),
}));
@@ -225,6 +244,9 @@ const PlatformDropdownComponent = ({
const toggleOptions = group.options.filter(
(opt) => opt.type === "toggle",
) as ToggleOption[];
const actionOptions = group.options.filter(
(opt) => opt.type === "action",
) as ActionOption[];
const items = [];
@@ -291,6 +313,21 @@ const PlatformDropdownComponent = ({
);
});
// Add Buttons for action options (no icon)
actionOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`action-${groupIndex}-${optionIndex}`}
onPress={() => {
option.onPress();
}}
disabled={option.disabled}
>
{option.label}
</Button>,
);
});
return items;
})}
</ContextMenu.Items>

View File

@@ -76,7 +76,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
const trigger = (
<View className='flex flex-col' {...props}>
<Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
{t("item_card.subtitles")}
{t("item_card.subtitles.label")}
</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
@@ -97,7 +97,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
<PlatformDropdown
groups={optionGroups}
trigger={trigger}
title={t("item_card.subtitles")}
title={t("item_card.subtitles.label")}
open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}

View File

@@ -1,50 +1,132 @@
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";
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<TextInput>(null);
const inputRef = useRef<TextInput>(null);
const [isFocused, setIsFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
return Platform.isTV ? (
<TouchableOpacity
onPress={() => inputRef?.current?.focus?.()}
activeOpacity={1}
>
<TextInput
ref={inputRef}
className={`
w-full text-lg px-5 py-4 rounded-2xl
${isFocused ? "bg-neutral-700 border-2 border-white" : "bg-neutral-900 border-2 border-transparent"}
text-white ${extraClassName}
`}
allowFontScaling={false}
style={[
style,
{
backgroundColor: isFocused ? "#ffffff88" : "#8f8d8d88",
},
]}
placeholderTextColor={"#ffffffff"}
clearButtonMode='while-editing'
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
{...otherProps}
/>
</TouchableOpacity>
) : (
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) {
const containerStyle = {
height: 48,
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: 16,
};
const inputElement = (
<>
<Ionicons
name='search'
size={20}
color={isFocused ? "#999" : "#666"}
style={{ marginRight: 12 }}
/>
<TextInput
ref={inputRef}
allowFontScaling={false}
placeholderTextColor='#666'
style={[
{
flex: 1,
height: 48,
fontSize: 18,
fontWeight: "400",
color: "#FFFFFF",
backgroundColor: "transparent",
},
style,
]}
onFocus={handleFocus}
onBlur={handleBlur}
{...otherProps}
/>
</>
);
return (
<Pressable
onPress={() => inputRef.current?.focus()}
onFocus={handleFocus}
onBlur={handleBlur}
>
<Animated.View
style={{
transform: [{ scale }],
}}
>
{Platform.OS === "ios" ? (
<BlurView
intensity={isFocused ? 90 : 80}
tint='dark'
style={containerStyle}
>
{inputElement}
</BlurView>
) : (
<View
style={[
containerStyle,
{
backgroundColor: isFocused
? "rgba(255, 255, 255, 0.12)"
: "rgba(255, 255, 255, 0.08)",
},
]}
>
{inputElement}
</View>
)}
</Animated.View>
</Pressable>
);
}
// Mobile version unchanged
return (
<TextInput
ref={inputRef}
className='p-4 rounded-xl bg-neutral-900'
className={`p-4 rounded-xl bg-neutral-900 ${extraClassName}`}
allowFontScaling={false}
style={[{ color: "white" }, style]}
placeholderTextColor={"#9CA3AF"}

View File

@@ -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 { 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<FavoriteTypes, boolean>;
export const Favorites = () => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const pageSize = 20;
const [emptyState, setEmptyState] = useState<EmptyState>({
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 (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: HORIZONTAL_PADDING,
}}
>
<Image
style={{
width: 64,
height: 64,
marginBottom: 16,
tintColor: Colors.primary,
}}
contentFit='contain'
source={heart}
/>
<Text
style={{
fontSize: 32,
fontWeight: "bold",
marginBottom: 8,
color: "#FFFFFF",
}}
>
{t("favorites.noDataTitle")}
</Text>
<Text
style={{
textAlign: "center",
opacity: 0.7,
fontSize: 18,
color: "#FFFFFF",
}}
>
{t("favorites.noData")}
</Text>
</View>
);
}
return (
<ScrollView
nestedScrollEnabled
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingBottom: insets.bottom + 60,
paddingLeft: insets.left + HORIZONTAL_PADDING,
paddingRight: insets.right + HORIZONTAL_PADDING,
}}
>
<View style={{ gap: SECTION_GAP }}>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteSeries}
queryKey={["home", "favorites", "series"]}
title={t("favorites.series")}
hideIfEmpty
pageSize={pageSize}
isFirstSection
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteMovies}
queryKey={["home", "favorites", "movies"]}
title={t("favorites.movies")}
hideIfEmpty
orientation='vertical'
pageSize={pageSize}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteEpisodes}
queryKey={["home", "favorites", "episodes"]}
title={t("favorites.episodes")}
hideIfEmpty
pageSize={pageSize}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteVideos}
queryKey={["home", "favorites", "videos"]}
title={t("favorites.videos")}
hideIfEmpty
pageSize={pageSize}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteBoxsets}
queryKey={["home", "favorites", "boxsets"]}
title={t("favorites.boxsets")}
hideIfEmpty
pageSize={pageSize}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoritePlaylists}
queryKey={["home", "favorites", "playlists"]}
title={t("favorites.playlists")}
hideIfEmpty
pageSize={pageSize}
/>
</View>
</ScrollView>
);
};

View File

@@ -44,6 +44,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 +67,7 @@ type MediaListSectionType = {
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
export const Home = () => {
const HomeMobile = () => {
const router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
@@ -687,3 +690,11 @@ export const Home = () => {
</ScrollView>
);
};
// Exported component that renders TV or mobile version based on platform
export const Home = () => {
if (Platform.isTV && HomeTV) {
return <HomeTV />;
}
return <HomeMobile />;
};

753
components/home/Home.tv.tsx Normal file
View File

@@ -0,0 +1,753 @@
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import { type QueryFunction, useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Animated,
Easing,
ScrollView,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists.tv";
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv";
import { Loader } from "@/components/Loader";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
// Reduced gap since sections have internal padding for scale animations
const SECTION_GAP = 10;
type InfiniteScrollingCollectionListSection = {
type: "InfiniteScrollingCollectionList";
title?: string;
queryKey: (string | undefined | null)[];
queryFn: QueryFunction<BaseItemDto[], any, number>;
orientation?: "horizontal" | "vertical";
pageSize?: number;
priority?: 1 | 2;
parentId?: string;
};
type Section = InfiniteScrollingCollectionListSection;
// Debounce delay in ms - prevents rapid backdrop changes when scrolling fast
const BACKDROP_DEBOUNCE_MS = 300;
export const Home = () => {
const _router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const insets = useSafeAreaInsets();
const { settings } = useSettings();
const scrollRef = useRef<ScrollView>(null);
const {
isConnected,
serverConnected,
loading: retryLoading,
retryCheck,
} = useNetworkStatus();
const _invalidateCache = useInvalidatePlaybackProgressCache();
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
// Dynamic backdrop state with debounce
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Handle item focus with debounce
const handleItemFocus = useCallback((item: BaseItemDto) => {
// Clear any pending debounce timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Set new timer to update focused item after debounce delay
debounceTimerRef.current = setTimeout(() => {
setFocusedItem(item);
}, BACKDROP_DEBOUNCE_MS);
}, []);
// Cleanup debounce timer on unmount
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
// Get backdrop URL from focused item (only if setting is enabled)
const backdropUrl = useMemo(() => {
if (!settings.showHomeBackdrop || !focusedItem) return null;
return getBackdropUrl({
api,
item: focusedItem,
quality: 90,
width: 1920,
});
}, [api, focusedItem, settings.showHomeBackdrop]);
// Crossfade animation for backdrop transitions
const [activeLayer, setActiveLayer] = useState<0 | 1>(0);
const [layer0Url, setLayer0Url] = useState<string | null>(null);
const [layer1Url, setLayer1Url] = useState<string | null>(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 () => {
isCancelled = true;
};
}, [backdropUrl]);
const {
data,
isError: e1,
isLoading: l1,
} = useQuery({
queryKey: ["home", "userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
const userViews = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
[data, settings?.hiddenLibraries],
);
const collections = useMemo(() => {
const allow = ["movies", "tvshows"];
return (
userViews?.filter(
(c) => c.CollectionType && allow.includes(c.CollectionType),
) || []
);
}, [userViews]);
const createCollectionConfig = useCallback(
(
title: string,
queryKey: string[],
includeItemTypes: BaseItemKind[],
parentId: string | undefined,
pageSize = 10,
): InfiniteScrollingCollectionListSection => ({
title,
queryKey,
queryFn: async ({ pageParam = 0 }) => {
if (!api) return [];
const allData =
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 10,
fields: ["PrimaryImageAspectRatio"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes,
parentId,
})
).data || [];
return allData.slice(pageParam, pageParam + pageSize);
},
type: "InfiniteScrollingCollectionList",
pageSize,
parentId,
}),
[api, user?.Id],
);
const defaultSections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" || c.CollectionType === "movies"
? []
: ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey: string[] = [
"home",
`recentlyAddedIn${c.CollectionType}`,
user.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id,
10,
);
});
const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
return items.sort((a, b) => {
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
return new Date(dateB).getTime() - new Date(dateA).getTime();
});
};
const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
const seen = new Set<string>();
return items.filter((item) => {
if (!item.Id || seen.has(item.Id)) return false;
seen.add(item.Id);
return true;
});
};
const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
? [
{
title: t("home.continue_and_next_up"),
queryKey: ["home", "continueAndNextUp"],
queryFn: async ({ pageParam = 0 }) => {
const [resumeResponse, nextUpResponse] = await Promise.all([
getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
startIndex: 0,
limit: 20,
}),
getTvShowsApi(api).getNextUp({
userId: user?.Id,
startIndex: 0,
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
}),
]);
const resumeItems = resumeResponse.data.Items || [];
const nextUpItems = nextUpResponse.data.Items || [];
const combined = [...resumeItems, ...nextUpItems];
const sorted = sortByRecentActivity(combined);
const deduplicated = deduplicateById(sorted);
return deduplicated.slice(pageParam, pageParam + 10);
},
type: "InfiniteScrollingCollectionList",
orientation: "horizontal",
pageSize: 10,
priority: 1,
},
]
: [
{
title: t("home.continue_watching"),
queryKey: ["home", "resumeItems"],
queryFn: async ({ pageParam = 0 }) =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
startIndex: pageParam,
limit: 10,
})
).data.Items || [],
type: "InfiniteScrollingCollectionList",
orientation: "horizontal",
pageSize: 10,
priority: 1,
},
{
title: t("home.next_up"),
queryKey: ["home", "nextUp-all"],
queryFn: async ({ pageParam = 0 }) =>
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
startIndex: pageParam,
limit: 10,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
})
).data.Items || [],
type: "InfiniteScrollingCollectionList",
orientation: "horizontal",
pageSize: 10,
priority: 1,
},
];
const ss: Section[] = [
...firstSections,
...latestMediaViews.map((s) => ({ ...s, priority: 2 as const })),
...(!settings?.streamyStatsMovieRecommendations
? [
{
title: t("home.suggested_movies"),
queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) =>
(
await getSuggestionsApi(api).getSuggestions({
userId: user?.Id,
startIndex: pageParam,
limit: 10,
mediaType: ["Video"],
type: ["Movie"],
})
).data.Items || [],
type: "InfiniteScrollingCollectionList" as const,
orientation: "vertical" as const,
pageSize: 10,
priority: 2 as const,
},
]
: []),
];
return ss;
}, [
api,
user?.Id,
collections,
t,
createCollectionConfig,
settings?.streamyStatsMovieRecommendations,
settings.mergeNextUpAndContinueWatching,
]);
const customSections = useMemo(() => {
if (!api || !user?.Id || !settings?.home?.sections) return [];
const ss: Section[] = [];
settings.home.sections.forEach((section, index) => {
const id = section.title || `section-${index}`;
const pageSize = 10;
ss.push({
title: t(`${id}`),
queryKey: ["home", "custom", String(index), section.title ?? null],
queryFn: async ({ pageParam = 0 }) => {
if (section.items) {
const response = await getItemsApi(api).getItems({
userId: user?.Id,
startIndex: pageParam,
limit: section.items?.limit || pageSize,
recursive: true,
includeItemTypes: section.items?.includeItemTypes,
sortBy: section.items?.sortBy,
sortOrder: section.items?.sortOrder,
filters: section.items?.filters,
parentId: section.items?.parentId,
});
return response.data.Items || [];
}
if (section.nextUp) {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
startIndex: pageParam,
limit: section.nextUp?.limit || pageSize,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: section.nextUp?.enableResumable,
enableRewatching: section.nextUp?.enableRewatching,
});
return response.data.Items || [];
}
if (section.latest) {
const allData =
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
includeItemTypes: section.latest?.includeItemTypes,
limit: section.latest?.limit || 10,
isPlayed: section.latest?.isPlayed,
groupItems: section.latest?.groupItems,
})
).data || [];
return allData.slice(pageParam, pageParam + pageSize);
}
if (section.custom) {
const response = await api.get<BaseItemDtoQueryResult>(
section.custom.endpoint,
{
params: {
...(section.custom.query || {}),
userId: user?.Id,
startIndex: pageParam,
limit: pageSize,
},
headers: section.custom.headers || {},
},
);
return response.data.Items || [];
}
return [];
},
type: "InfiniteScrollingCollectionList",
orientation: section?.orientation || "vertical",
pageSize,
priority: index < 2 ? 1 : 2,
});
});
return ss;
}, [api, user?.Id, settings?.home?.sections, t]);
const sections = settings?.home?.sections ? customSections : defaultSections;
const highPrioritySectionKeys = useMemo(() => {
return sections
.filter((s) => s.priority === 1)
.map((s) => s.queryKey.join("-"));
}, [sections]);
const allHighPriorityLoaded = useMemo(() => {
return highPrioritySectionKeys.every((key) => loadedSections.has(key));
}, [highPrioritySectionKeys, loadedSections]);
const markSectionLoaded = useCallback(
(queryKey: (string | undefined | null)[]) => {
const key = queryKey.join("-");
setLoadedSections((prev) => new Set(prev).add(key));
},
[],
);
if (!isConnected || serverConnected !== true) {
let title = "";
let subtitle = "";
if (!isConnected) {
title = t("home.no_internet");
subtitle = t("home.no_internet_message");
} else if (serverConnected === null) {
title = t("home.checking_server_connection");
subtitle = t("home.checking_server_connection_message");
} else if (!serverConnected) {
title = t("home.server_unreachable");
subtitle = t("home.server_unreachable_message");
}
return (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: HORIZONTAL_PADDING,
}}
>
<Text
style={{
fontSize: 32,
fontWeight: "bold",
marginBottom: 8,
color: "#FFFFFF",
}}
>
{title}
</Text>
<Text
style={{
textAlign: "center",
opacity: 0.7,
fontSize: 18,
color: "#FFFFFF",
}}
>
{subtitle}
</Text>
<View style={{ marginTop: 24 }}>
<Button
color='black'
onPress={retryCheck}
justify='center'
iconRight={
retryLoading ? null : (
<Ionicons name='refresh' size={24} color='white' />
)
}
>
{retryLoading ? (
<ActivityIndicator size='small' color='white' />
) : (
t("home.retry")
)}
</Button>
</View>
</View>
);
}
if (e1)
return (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<Text
style={{
fontSize: 32,
fontWeight: "bold",
marginBottom: 8,
color: "#FFFFFF",
}}
>
{t("home.oops")}
</Text>
<Text
style={{
textAlign: "center",
opacity: 0.7,
fontSize: 18,
color: "#FFFFFF",
}}
>
{t("home.error_message")}
</Text>
</View>
);
if (l1)
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Loader />
</View>
);
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
{/* Dynamic backdrop with crossfade */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
{/* Layer 0 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer0Opacity,
}}
>
{layer0Url && (
<Image
source={{ uri: layer0Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
)}
</Animated.View>
{/* Layer 1 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer1Opacity,
}}
>
{layer1Url && (
<Image
source={{ uri: layer1Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
)}
</Animated.View>
{/* Gradient overlays for readability */}
<LinearGradient
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.4, 1]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: "100%",
}}
/>
</View>
<ScrollView
ref={scrollRef}
nestedScrollEnabled
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingBottom: insets.bottom + 60,
paddingLeft: insets.left + HORIZONTAL_PADDING,
paddingRight: insets.right + HORIZONTAL_PADDING,
}}
>
<View style={{ gap: SECTION_GAP }}>
{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;
const hasStreamystatsContent =
settings.streamyStatsMovieRecommendations ||
settings.streamyStatsSeriesRecommendations ||
settings.streamyStatsPromotedWatchlists;
const streamystatsSections =
index === streamystatsIndex && hasStreamystatsContent ? (
<View key='streamystats-sections' style={{ gap: SECTION_GAP }}>
{settings.streamyStatsMovieRecommendations && (
<StreamystatsRecommendations
title={t(
"home.settings.plugins.streamystats.recommended_movies",
)}
type='Movie'
enabled={allHighPriorityLoaded}
onItemFocus={handleItemFocus}
/>
)}
{settings.streamyStatsSeriesRecommendations && (
<StreamystatsRecommendations
title={t(
"home.settings.plugins.streamystats.recommended_series",
)}
type='Series'
enabled={allHighPriorityLoaded}
onItemFocus={handleItemFocus}
/>
)}
{settings.streamyStatsPromotedWatchlists && (
<StreamystatsPromotedWatchlists
enabled={allHighPriorityLoaded}
onItemFocus={handleItemFocus}
/>
)}
</View>
) : null;
if (section.type === "InfiniteScrollingCollectionList") {
const isHighPriority = section.priority === 1;
const isFirstSection = index === 0;
return (
<View key={index} style={{ gap: SECTION_GAP }}>
<InfiniteScrollingCollectionList
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
pageSize={section.pageSize}
enabled={isHighPriority || allHighPriorityLoaded}
onLoaded={
isHighPriority
? () => markSectionLoaded(section.queryKey)
: undefined
}
isFirstSection={isFirstSection}
onItemFocus={handleItemFocus}
/>
{streamystatsSections}
</View>
);
}
return null;
})}
</View>
</ScrollView>
</View>
);
};

View File

@@ -0,0 +1,384 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
type QueryFunction,
type QueryKey,
useInfiniteQuery,
} from "@tanstack/react-query";
import { useSegments } from "expo-router";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
FlatList,
View,
type ViewProps,
} from "react-native";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { Colors } from "@/constants/Colors";
import useRouter from "@/hooks/useAppRouter";
import ContinueWatchingPoster, {
TV_LANDSCAPE_WIDTH,
} from "../ContinueWatchingPoster.tv";
import SeriesPoster from "../posters/SeriesPoster.tv";
const ITEM_GAP = 16;
// Extra padding to accommodate scale animation (1.05x) and glow shadow
const SCALE_PADDING = 20;
interface Props extends ViewProps {
title?: string | null;
orientation?: "horizontal" | "vertical";
disabled?: boolean;
queryKey: QueryKey;
queryFn: QueryFunction<BaseItemDto[], QueryKey, number>;
hideIfEmpty?: boolean;
pageSize?: number;
onPressSeeAll?: () => void;
enabled?: boolean;
onLoaded?: () => void;
isFirstSection?: boolean;
onItemFocus?: (item: BaseItemDto) => void;
}
// TV-specific ItemCardText with larger fonts
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return (
<View style={{ marginTop: 12, flexDirection: "column" }}>
{item.Type === "Episode" ? (
<>
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
{item.Name}
</Text>
<Text
numberOfLines={1}
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
>
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
{" - "}
{item.SeriesName}
</Text>
</>
) : (
<>
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
{item.Name}
</Text>
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
{item.ProductionYear}
</Text>
</>
)}
</View>
);
};
export const InfiniteScrollingCollectionList: React.FC<Props> = ({
title,
orientation = "vertical",
disabled = false,
queryFn,
queryKey,
hideIfEmpty = false,
pageSize = 10,
enabled = true,
onLoaded,
isFirstSection = false,
onItemFocus,
...props
}) => {
const effectivePageSize = Math.max(1, pageSize);
const hasCalledOnLoaded = useRef(false);
const router = useRouter();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
// Track focus within section and scroll back to start when leaving
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
const [focusedCount, setFocusedCount] = useState(0);
const prevFocusedCount = useRef(0);
// When section loses all focus, scroll back to start
useEffect(() => {
if (prevFocusedCount.current > 0 && focusedCount === 0) {
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
}
prevFocusedCount.current = focusedCount;
}, [focusedCount]);
const handleItemFocus = useCallback(
(item: BaseItemDto) => {
setFocusedCount((c) => c + 1);
onItemFocus?.(item);
},
[onItemFocus],
);
const handleItemBlur = useCallback(() => {
setFocusedCount((c) => Math.max(0, c - 1));
}, []);
const {
data,
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
isSuccess,
} = useInfiniteQuery({
queryKey: queryKey,
queryFn: ({ pageParam = 0, ...context }) =>
queryFn({ ...context, queryKey, pageParam }),
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length < effectivePageSize) {
return undefined;
}
return allPages.reduce((acc, page) => acc + page.length, 0);
},
initialPageParam: 0,
staleTime: 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled,
});
useEffect(() => {
if (isSuccess && !hasCalledOnLoaded.current && onLoaded) {
hasCalledOnLoaded.current = true;
onLoaded();
}
}, [isSuccess, onLoaded]);
const { t } = useTranslation();
const allItems = useMemo(() => {
const items = data?.pages.flat() ?? [];
const seen = new Set<string>();
const deduped: BaseItemDto[] = [];
for (const item of items) {
const id = item.Id;
if (!id) continue;
if (seen.has(id)) continue;
seen.add(id);
deduped.push(item);
}
return deduped;
}, [data]);
const itemWidth =
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
const handleItemPress = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, from);
router.push(navigation as any);
},
[from, router],
);
const handleEndReached = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: itemWidth + ITEM_GAP,
offset: (itemWidth + ITEM_GAP) * index,
index,
}),
[itemWidth],
);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => {
const isFirstItem = isFirstSection && index === 0;
const isHorizontal = orientation === "horizontal";
const renderPoster = () => {
if (item.Type === "Episode" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Episode" && !isHorizontal) {
return <SeriesPoster item={item} />;
}
if (item.Type === "Movie" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Movie" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "Series" && !isHorizontal) {
return <SeriesPoster item={item} />;
}
if (item.Type === "Series" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Program") {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "BoxSet" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "BoxSet" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Playlist" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "Playlist" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Video" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "Video" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
// Default fallback
return isHorizontal ? (
<ContinueWatchingPoster item={item} />
) : (
<MoviePoster item={item} />
);
};
return (
<View style={{ marginRight: ITEM_GAP, width: itemWidth }}>
<TVFocusablePoster
onPress={() => handleItemPress(item)}
hasTVPreferredFocus={isFirstItem}
onFocus={() => handleItemFocus(item)}
onBlur={handleItemBlur}
>
{renderPoster()}
</TVFocusablePoster>
<TVItemCardText item={item} />
</View>
);
},
[
orientation,
isFirstSection,
itemWidth,
handleItemPress,
handleItemFocus,
handleItemBlur,
],
);
if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
if (disabled || !title) return null;
return (
<View style={{ overflow: "visible" }} {...props}>
{/* Section Header */}
<Text
style={{
fontSize: 22,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
>
{title}
</Text>
{isLoading === false && allItems.length === 0 && (
<Text
style={{ color: "#737373", fontSize: 16, marginLeft: SCALE_PADDING }}
>
{t("home.no_items")}
</Text>
)}
{isLoading ? (
<View
style={{
flexDirection: "row",
gap: ITEM_GAP,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: itemWidth }}>
<View
style={{
backgroundColor: "#262626",
width: itemWidth,
aspectRatio: orientation === "horizontal" ? 16 / 9 : 10 / 15,
borderRadius: 12,
marginBottom: 8,
}}
/>
<View
style={{
borderRadius: 6,
overflow: "hidden",
marginBottom: 4,
alignSelf: "flex-start",
}}
>
<Text
style={{
color: "#262626",
backgroundColor: "#262626",
borderRadius: 6,
fontSize: 16,
}}
numberOfLines={1}
>
Placeholder text here
</Text>
</View>
</View>
))}
</View>
) : (
<FlatList
ref={flatListRef}
horizontal
data={allItems}
keyExtractor={(item) => item.Id!}
renderItem={renderItem}
showsHorizontalScrollIndicator={false}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
initialNumToRender={5}
maxToRenderPerBatch={3}
windowSize={5}
removeClippedSubviews={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
}}
ListFooterComponent={
isFetchingNextPage ? (
<View
style={{
marginLeft: 8,
justifyContent: "center",
height: orientation === "horizontal" ? 191 : 315,
}}
>
<ActivityIndicator size='small' color={Colors.primary} />
</View>
) : null
}
/>
)}
</View>
);
};

View File

@@ -0,0 +1,332 @@
import type {
BaseItemDto,
PublicSystemInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useSegments } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useMemo } from "react";
import { FlatList, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { createStreamystatsApi } from "@/utils/streamystats/api";
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
const ITEM_GAP = 16;
const SCALE_PADDING = 20;
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return (
<View style={{ marginTop: 12, flexDirection: "column" }}>
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
{item.Name}
</Text>
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
{item.ProductionYear}
</Text>
</View>
);
};
interface WatchlistSectionProps extends ViewProps {
watchlist: StreamystatsWatchlist;
jellyfinServerId: string;
onItemFocus?: (item: BaseItemDto) => void;
}
const WatchlistSection: React.FC<WatchlistSectionProps> = ({
watchlist,
jellyfinServerId,
onItemFocus,
...props
}) => {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
const router = useRouter();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
const { data: items, isLoading } = useQuery({
queryKey: [
"streamystats",
"watchlist",
watchlist.id,
jellyfinServerId,
settings?.streamyStatsServerUrl,
],
queryFn: async (): Promise<BaseItemDto[]> => {
if (!settings?.streamyStatsServerUrl || !api?.accessToken || !user?.Id) {
return [];
}
const streamystatsApi = createStreamystatsApi({
serverUrl: settings.streamyStatsServerUrl,
jellyfinToken: api.accessToken,
});
const watchlistDetail = await streamystatsApi.getWatchlistItemIds({
watchlistId: watchlist.id,
jellyfinServerId,
});
const itemIds = watchlistDetail.data?.items;
if (!itemIds?.length) {
return [];
}
const response = await getItemsApi(api).getItems({
userId: user.Id,
ids: itemIds,
fields: ["PrimaryImageAspectRatio", "Genres"],
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
});
return response.data.Items || [];
},
enabled:
Boolean(settings?.streamyStatsServerUrl) &&
Boolean(api?.accessToken) &&
Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
const handleItemPress = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, from);
router.push(navigation as any);
},
[from, router],
);
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: TV_POSTER_WIDTH + ITEM_GAP,
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
index,
}),
[],
);
const renderItem = useCallback(
({ item }: { item: BaseItemDto }) => {
return (
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
<TVFocusablePoster
onPress={() => handleItemPress(item)}
onFocus={() => onItemFocus?.(item)}
hasTVPreferredFocus={false}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
{item.Type === "Series" && <SeriesPoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} />
</View>
);
},
[handleItemPress, onItemFocus],
);
if (!isLoading && (!items || items.length === 0)) return null;
return (
<View style={{ overflow: "visible" }} {...props}>
<Text
style={{
fontSize: 22,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
>
{watchlist.name}
</Text>
{isLoading ? (
<View
style={{
flexDirection: "row",
gap: ITEM_GAP,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
<View
style={{
backgroundColor: "#262626",
width: TV_POSTER_WIDTH,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,
}}
/>
</View>
))}
</View>
) : (
<FlatList
horizontal
data={items}
keyExtractor={(item) => item.Id!}
renderItem={renderItem}
showsHorizontalScrollIndicator={false}
initialNumToRender={5}
maxToRenderPerBatch={3}
windowSize={5}
removeClippedSubviews={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
}}
/>
)}
</View>
);
};
interface StreamystatsPromotedWatchlistsProps extends ViewProps {
enabled?: boolean;
onItemFocus?: (item: BaseItemDto) => void;
}
export const StreamystatsPromotedWatchlists: React.FC<
StreamystatsPromotedWatchlistsProps
> = ({ enabled = true, onItemFocus, ...props }) => {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
const streamyStatsEnabled = useMemo(() => {
return Boolean(settings?.streamyStatsServerUrl);
}, [settings?.streamyStatsServerUrl]);
const { data: serverInfo } = useQuery({
queryKey: ["jellyfin", "serverInfo"],
queryFn: async (): Promise<PublicSystemInfo | null> => {
if (!api) return null;
const response = await getSystemApi(api).getPublicSystemInfo();
return response.data;
},
enabled: enabled && Boolean(api) && streamyStatsEnabled,
staleTime: 60 * 60 * 1000,
});
const jellyfinServerId = serverInfo?.Id;
const {
data: watchlists,
isLoading,
isError,
} = useQuery({
queryKey: [
"streamystats",
"promotedWatchlists",
jellyfinServerId,
settings?.streamyStatsServerUrl,
],
queryFn: async (): Promise<StreamystatsWatchlist[]> => {
if (
!settings?.streamyStatsServerUrl ||
!api?.accessToken ||
!jellyfinServerId
) {
return [];
}
const streamystatsApi = createStreamystatsApi({
serverUrl: settings.streamyStatsServerUrl,
jellyfinToken: api.accessToken,
});
const response = await streamystatsApi.getPromotedWatchlists({
jellyfinServerId,
includePreview: false,
});
return response.data || [];
},
enabled:
enabled &&
streamyStatsEnabled &&
Boolean(api?.accessToken) &&
Boolean(jellyfinServerId) &&
Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
if (!streamyStatsEnabled) return null;
if (isError) return null;
if (!isLoading && (!watchlists || watchlists.length === 0)) return null;
if (isLoading) {
return (
<View style={{ overflow: "visible" }} {...props}>
<View
style={{
height: 16,
width: 128,
backgroundColor: "#262626",
borderRadius: 4,
marginLeft: SCALE_PADDING,
marginBottom: 16,
}}
/>
<View
style={{
flexDirection: "row",
gap: ITEM_GAP,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
<View
style={{
backgroundColor: "#262626",
width: TV_POSTER_WIDTH,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,
}}
/>
</View>
))}
</View>
</View>
);
}
return (
<>
{watchlists?.map((watchlist) => (
<WatchlistSection
key={watchlist.id}
watchlist={watchlist}
jellyfinServerId={jellyfinServerId!}
onItemFocus={onItemFocus}
{...props}
/>
))}
</>
);
};

View File

@@ -0,0 +1,265 @@
import type {
BaseItemDto,
PublicSystemInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useSegments } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useMemo } from "react";
import { FlatList, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { createStreamystatsApi } from "@/utils/streamystats/api";
import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types";
const ITEM_GAP = 16;
const SCALE_PADDING = 20;
interface Props extends ViewProps {
title: string;
type: "Movie" | "Series";
limit?: number;
enabled?: boolean;
onItemFocus?: (item: BaseItemDto) => void;
}
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return (
<View style={{ marginTop: 12, flexDirection: "column" }}>
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
{item.Name}
</Text>
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
{item.ProductionYear}
</Text>
</View>
);
};
export const StreamystatsRecommendations: React.FC<Props> = ({
title,
type,
limit = 20,
enabled = true,
onItemFocus,
...props
}) => {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
const router = useRouter();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
const streamyStatsEnabled = useMemo(() => {
return Boolean(settings?.streamyStatsServerUrl);
}, [settings?.streamyStatsServerUrl]);
const { data: serverInfo } = useQuery({
queryKey: ["jellyfin", "serverInfo"],
queryFn: async (): Promise<PublicSystemInfo | null> => {
if (!api) return null;
const response = await getSystemApi(api).getPublicSystemInfo();
return response.data;
},
enabled: enabled && Boolean(api) && streamyStatsEnabled,
staleTime: 60 * 60 * 1000,
});
const jellyfinServerId = serverInfo?.Id;
const {
data: recommendationIds,
isLoading: isLoadingRecommendations,
isError: isRecommendationsError,
} = useQuery({
queryKey: [
"streamystats",
"recommendations",
type,
jellyfinServerId,
settings?.streamyStatsServerUrl,
],
queryFn: async (): Promise<string[]> => {
if (
!settings?.streamyStatsServerUrl ||
!api?.accessToken ||
!jellyfinServerId
) {
return [];
}
const streamyStatsApi = createStreamystatsApi({
serverUrl: settings.streamyStatsServerUrl,
jellyfinToken: api.accessToken,
});
const response = await streamyStatsApi.getRecommendationIds(
jellyfinServerId,
type,
limit,
);
const data = response as StreamystatsRecommendationsIdsResponse;
if (type === "Movie") {
return data.data.movies || [];
}
return data.data.series || [];
},
enabled:
enabled &&
streamyStatsEnabled &&
Boolean(api?.accessToken) &&
Boolean(jellyfinServerId) &&
Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
const {
data: items,
isLoading: isLoadingItems,
isError: isItemsError,
} = useQuery({
queryKey: [
"streamystats",
"recommendations",
"items",
type,
recommendationIds,
],
queryFn: async (): Promise<BaseItemDto[]> => {
if (!api || !user?.Id || !recommendationIds?.length) {
return [];
}
const response = await getItemsApi(api).getItems({
userId: user.Id,
ids: recommendationIds,
fields: ["PrimaryImageAspectRatio", "Genres"],
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
});
return response.data.Items || [];
},
enabled:
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
const isLoading = isLoadingRecommendations || isLoadingItems;
const isError = isRecommendationsError || isItemsError;
const handleItemPress = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, from);
router.push(navigation as any);
},
[from, router],
);
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: TV_POSTER_WIDTH + ITEM_GAP,
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
index,
}),
[],
);
const renderItem = useCallback(
({ item }: { item: BaseItemDto }) => {
return (
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
<TVFocusablePoster
onPress={() => handleItemPress(item)}
onFocus={() => onItemFocus?.(item)}
hasTVPreferredFocus={false}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
{item.Type === "Series" && <SeriesPoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} />
</View>
);
},
[handleItemPress, onItemFocus],
);
if (!streamyStatsEnabled) return null;
if (isError) return null;
if (!isLoading && (!items || items.length === 0)) return null;
return (
<View style={{ overflow: "visible" }} {...props}>
<Text
style={{
fontSize: 22,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
>
{title}
</Text>
{isLoading ? (
<View
style={{
flexDirection: "row",
gap: ITEM_GAP,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
<View
style={{
backgroundColor: "#262626",
width: TV_POSTER_WIDTH,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,
}}
/>
</View>
))}
</View>
) : (
<FlatList
horizontal
data={items}
keyExtractor={(item) => item.Id!}
renderItem={renderItem}
showsHorizontalScrollIndicator={false}
initialNumToRender={5}
maxToRenderPerBatch={3}
windowSize={5}
removeClippedSubviews={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
}}
/>
)}
</View>
);
};

View File

@@ -0,0 +1,109 @@
import {
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
export const Libraries: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const queryClient = useQueryClient();
const { settings } = useSettings();
const { t } = useTranslation();
const { data, isLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({
userId: user?.Id,
});
return response.data.Items || null;
},
staleTime: 60,
});
const libraries = useMemo(
() =>
data
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
.filter((l) => l.CollectionType !== "books") || [],
[data, settings?.hiddenLibraries],
);
useEffect(() => {
for (const item of data || []) {
queryClient.prefetchQuery({
queryKey: ["library", item.Id],
queryFn: async () => {
if (!item.Id || !user?.Id || !api) return null;
const response = await getUserLibraryApi(api).getItem({
itemId: item.Id,
userId: user?.Id,
});
return response.data;
},
staleTime: 60 * 1000,
});
}
}, [data, api, queryClient, user?.Id]);
const insets = useSafeAreaInsets();
if (isLoading)
return (
<View className='justify-center items-center h-full'>
<Loader />
</View>
);
if (!libraries)
return (
<View className='h-full w-full flex justify-center items-center'>
<Text className='text-lg text-neutral-500'>
{t("library.no_libraries_found")}
</Text>
</View>
);
return (
<FlashList
extraData={settings}
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingTop: Platform.OS === "android" ? 17 : 0,
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
paddingBottom: 150,
paddingLeft: insets.left + 17,
paddingRight: insets.right + 17,
}}
data={libraries}
renderItem={({ item }) => <LibraryItemCard library={item} />}
keyExtractor={(item) => item.Id || ""}
ItemSeparatorComponent={() =>
settings?.libraryOptions?.display === "row" ? (
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='bg-neutral-800 mx-2 my-4'
/>
) : (
<View className='h-4' />
)
}
/>
);
};

View File

@@ -0,0 +1,390 @@
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
CollectionType,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Animated, Easing, FlatList, Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
const HORIZONTAL_PADDING = 80;
const CARD_HEIGHT = 220;
const CARD_GAP = 24;
const SCALE_PADDING = 20;
type IconName = React.ComponentProps<typeof Ionicons>["name"];
const icons: Record<CollectionType, IconName> = {
movies: "film",
tvshows: "tv",
music: "musical-notes",
books: "book",
homevideos: "videocam",
boxsets: "albums",
playlists: "list",
folders: "folder",
livetv: "tv",
musicvideos: "musical-notes",
photos: "images",
trailers: "videocam",
unknown: "help-circle",
} as const;
interface LibraryWithPreview extends BaseItemDto {
previewItems?: BaseItemDto[];
itemCount?: number;
}
const TVLibraryRow: React.FC<{
library: LibraryWithPreview;
isFirst: boolean;
onPress: () => void;
}> = ({ library, isFirst, onPress }) => {
const [api] = useAtom(apiAtom);
const { t } = useTranslation();
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const opacity = useRef(new Animated.Value(0.7)).current;
const animateTo = (toScale: number, toOpacity: number) => {
Animated.parallel([
Animated.timing(scale, {
toValue: toScale,
duration: 200,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: toOpacity,
duration: 200,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
]).start();
};
const backdropUrl = useMemo(() => {
// Try to get backdrop from library or first preview item
if (library.previewItems?.[0]) {
return getBackdropUrl({
api,
item: library.previewItems[0],
width: 1920,
});
}
return getBackdropUrl({
api,
item: library,
width: 1920,
});
}, [api, library]);
const iconName = icons[library.CollectionType!] || "folder";
const itemTypeName = useMemo(() => {
if (library.CollectionType === "movies")
return t("library.item_types.movies");
if (library.CollectionType === "tvshows")
return t("library.item_types.series");
if (library.CollectionType === "boxsets")
return t("library.item_types.boxsets");
if (library.CollectionType === "music")
return t("library.item_types.items");
return t("library.item_types.items");
}, [library.CollectionType, t]);
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.02, 1);
}}
onBlur={() => {
setFocused(false);
animateTo(1, 0.7);
}}
hasTVPreferredFocus={isFirst}
>
<Animated.View
style={{
transform: [{ scale }],
opacity,
height: CARD_HEIGHT,
borderRadius: 20,
overflow: "hidden",
backgroundColor: "#1a1a1a",
borderWidth: focused ? 4 : 0,
borderColor: "#FFFFFF",
shadowColor: "#FFFFFF",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.3 : 0,
shadowRadius: focused ? 30 : 0,
}}
>
{/* Background Image */}
{backdropUrl && (
<Image
source={{ uri: backdropUrl }}
style={{
position: "absolute",
width: "100%",
height: "100%",
}}
contentFit='cover'
cachePolicy='memory-disk'
/>
)}
{/* Gradient Overlay */}
<LinearGradient
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.8)"]}
style={{
position: "absolute",
width: "100%",
height: "100%",
}}
/>
{/* Content */}
<View
style={{
flex: 1,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 40,
}}
>
{/* Icon Container */}
<BlurView
intensity={60}
tint='dark'
style={{
width: 80,
height: 80,
borderRadius: 20,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
}}
>
<Ionicons name={iconName} size={40} color='#FFFFFF' />
</BlurView>
{/* Text Content */}
<View style={{ marginLeft: 24, flex: 1 }}>
<Text
numberOfLines={1}
style={{
fontSize: 32,
fontWeight: "700",
color: "#FFFFFF",
textShadowColor: "rgba(0,0,0,0.8)",
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
}}
>
{library.Name}
</Text>
{library.itemCount !== undefined && (
<Text
style={{
fontSize: 18,
color: "rgba(255,255,255,0.7)",
marginTop: 4,
textShadowColor: "rgba(0,0,0,0.8)",
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
}}
>
{library.itemCount} {itemTypeName}
</Text>
)}
</View>
{/* Arrow Indicator */}
<Animated.View
style={{
opacity: focused ? 1 : 0.5,
}}
>
<Ionicons name='chevron-forward' size={32} color='#FFFFFF' />
</Animated.View>
</View>
</Animated.View>
</Pressable>
);
};
export const TVLibraries: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { settings } = useSettings();
const insets = useSafeAreaInsets();
const router = useRouter();
const { t } = useTranslation();
const { data: userViews, isLoading: viewsLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({
userId: user?.Id,
});
return response.data.Items || [];
},
staleTime: 60 * 1000,
enabled: !!api && !!user?.Id,
});
const libraries = useMemo(
() =>
userViews
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
.filter((l) => l.CollectionType !== "books") || [],
[userViews, settings?.hiddenLibraries],
);
// Fetch item counts and preview items for each library
const { data: librariesWithData, isLoading: dataLoading } = useQuery({
queryKey: ["library-data", libraries.map((l) => l.Id).join(",")],
queryFn: async () => {
const results: LibraryWithPreview[] = await Promise.all(
libraries.map(async (library) => {
let itemType: string | undefined;
if (library.CollectionType === "movies") itemType = "Movie";
else if (library.CollectionType === "tvshows") itemType = "Series";
else if (library.CollectionType === "boxsets") itemType = "BoxSet";
// Fetch count
const countResponse = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: library.Id,
recursive: true,
limit: 0,
includeItemTypes: itemType ? [itemType as any] : undefined,
});
// Fetch preview items with backdrops
const previewResponse = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: library.Id,
recursive: true,
limit: 1,
sortBy: ["Random"],
includeItemTypes: itemType ? [itemType as any] : undefined,
imageTypes: ["Backdrop"],
});
return {
...library,
itemCount: countResponse.data.TotalRecordCount,
previewItems: previewResponse.data.Items || [],
};
}),
);
return results;
},
enabled: !!api && !!user?.Id && libraries.length > 0,
staleTime: 60 * 1000,
});
const handleLibraryPress = useCallback(
(library: BaseItemDto) => {
if (library.CollectionType === "music") {
router.push({
pathname: `/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions`,
params: { libraryId: library.Id! },
});
} else {
router.push({
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
params: { libraryId: library.Id! },
});
}
},
[router],
);
const renderItem = useCallback(
({ item, index }: { item: LibraryWithPreview; index: number }) => (
<View style={{ marginBottom: CARD_GAP, paddingHorizontal: 8 }}>
<TVLibraryRow
library={item}
isFirst={index === 0}
onPress={() => handleLibraryPress(item)}
/>
</View>
),
[handleLibraryPress],
);
const isLoading = viewsLoading || dataLoading;
const displayLibraries = librariesWithData || libraries;
if (isLoading && libraries.length === 0) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Loader />
</View>
);
}
if (!displayLibraries || displayLibraries.length === 0) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Text style={{ fontSize: 20, color: "#737373" }}>
{t("library.no_libraries_found")}
</Text>
</View>
);
}
return (
<View
style={{
flex: 1,
paddingTop: insets.top + 80,
paddingBottom: insets.bottom + 40,
}}
>
<FlatList
data={displayLibraries}
keyExtractor={(item) => item.Id || ""}
renderItem={renderItem}
showsVerticalScrollIndicator={false}
removeClippedSubviews={false}
contentContainerStyle={{
paddingBottom: 40,
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
paddingVertical: SCALE_PADDING,
}}
/>
</View>
);
};

View File

@@ -0,0 +1,174 @@
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
BaseItemKind,
CollectionType,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
export const TV_LIBRARY_CARD_WIDTH = 280;
export const TV_LIBRARY_CARD_HEIGHT = 180;
interface Props {
library: BaseItemDto;
}
type IconName = React.ComponentProps<typeof Ionicons>["name"];
const icons: Record<CollectionType, IconName> = {
movies: "film",
tvshows: "tv",
music: "musical-notes",
books: "book",
homevideos: "videocam",
boxsets: "albums",
playlists: "list",
folders: "folder",
livetv: "tv",
musicvideos: "musical-notes",
photos: "images",
trailers: "videocam",
unknown: "help-circle",
} as const;
export const TVLibraryCard: React.FC<Props> = ({ library }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { t } = useTranslation();
const url = useMemo(
() =>
getPrimaryImageUrl({
api,
item: library,
}),
[api, library],
);
const itemType = useMemo(() => {
let _itemType: BaseItemKind | undefined;
if (library.CollectionType === "movies") {
_itemType = "Movie";
} else if (library.CollectionType === "tvshows") {
_itemType = "Series";
} else if (library.CollectionType === "boxsets") {
_itemType = "BoxSet";
} else if (library.CollectionType === "homevideos") {
_itemType = "Video";
} else if (library.CollectionType === "musicvideos") {
_itemType = "MusicVideo";
}
return _itemType;
}, [library.CollectionType]);
const itemTypeName = useMemo(() => {
let nameStr: string;
if (library.CollectionType === "movies") {
nameStr = t("library.item_types.movies");
} else if (library.CollectionType === "tvshows") {
nameStr = t("library.item_types.series");
} else if (library.CollectionType === "boxsets") {
nameStr = t("library.item_types.boxsets");
} else {
nameStr = t("library.item_types.items");
}
return nameStr;
}, [library.CollectionType, t]);
const { data: itemsCount } = useQuery({
queryKey: ["library-count", library.Id],
queryFn: async () => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: library.Id,
recursive: true,
limit: 0,
includeItemTypes: itemType ? [itemType] : undefined,
});
return response.data.TotalRecordCount;
},
enabled: !!api && !!user?.Id && !!library.Id,
});
const iconName = icons[library.CollectionType!] || "folder";
return (
<View
style={{
width: TV_LIBRARY_CARD_WIDTH,
height: TV_LIBRARY_CARD_HEIGHT,
borderRadius: 16,
overflow: "hidden",
backgroundColor: "#1a1a1a",
borderWidth: 1,
borderColor: "#333",
}}
>
{url && (
<Image
source={{ uri: url }}
style={{
position: "absolute",
width: "100%",
height: "100%",
}}
cachePolicy='memory-disk'
/>
)}
<View
style={{
position: "absolute",
width: "100%",
height: "100%",
backgroundColor: url ? "rgba(0, 0, 0, 0.6)" : "transparent",
}}
/>
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 20,
}}
>
<Ionicons name={iconName} size={48} color='#e5e5e5' />
<Text
numberOfLines={1}
style={{
fontSize: 22,
fontWeight: "600",
color: "#FFFFFF",
marginTop: 12,
textAlign: "center",
}}
>
{library.Name}
</Text>
{itemsCount !== undefined && (
<Text
style={{
fontSize: 14,
color: "#9CA3AF",
marginTop: 4,
}}
>
{itemsCount} {itemTypeName}
</Text>
)}
</View>
</View>
);
};

456
components/login/Login.tsx Normal file
View File

@@ -0,0 +1,456 @@
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useState } from "react";
import {
Alert,
Keyboard,
KeyboardAvoidingView,
Platform,
Switch,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { z } from "zod";
import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
import { SaveAccountModal } from "@/components/SaveAccountModal";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import type {
AccountSecurityType,
SavedServer,
} from "@/utils/secureCredentials";
const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")),
});
export const Login: React.FC = () => {
const api = useAtomValue(apiAtom);
const navigation = useNavigation();
const params = useLocalSearchParams();
const {
setServer,
login,
removeServer,
initiateQuickConnect,
loginWithSavedCredential,
loginWithPassword,
} = useJellyfin();
const {
apiUrl: _apiUrl,
username: _username,
password: _password,
} = params as { apiUrl: string; username: string; password: string };
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [serverURL, setServerURL] = useState<string>(_apiUrl || "");
const [serverName, setServerName] = useState<string>("");
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);
useEffect(() => {
(async () => {
if (_apiUrl) {
await setServer({
address: _apiUrl,
});
setTimeout(() => {
if (_username && _password) {
setCredentials({ username: _username, password: _password });
login(_username, _password);
}
}, 0);
}
})();
}, [_apiUrl, _username, _password]);
useEffect(() => {
navigation.setOptions({
headerTitle: serverName,
headerLeft: () =>
api?.basePath ? (
<TouchableOpacity
onPress={() => {
removeServer();
}}
className='flex flex-row items-center pr-2 pl-1'
>
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
<Text className=' ml-1 text-purple-600'>
{t("login.change_server")}
</Text>
</TouchableOpacity>
) : null,
});
}, [serverName, navigation, api?.basePath]);
const handleLogin = async () => {
Keyboard.dismiss();
const result = CredentialsSchema.safeParse(credentials);
if (!result.success) return;
if (saveAccount) {
setPendingLogin({
username: credentials.username,
password: credentials.password,
});
setShowSaveModal(true);
} else {
await performLogin(credentials.username, credentials.password);
}
};
const performLogin = async (
username: string,
password: string,
options?: {
saveAccount?: boolean;
securityType?: AccountSecurityType;
pinCode?: string;
},
) => {
setLoading(true);
try {
await login(username, password, serverName, options);
} catch (error) {
if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message);
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_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) => {
setServer({ address: server.address });
if (server.name) {
setServerName(server.name);
}
};
const checkUrl = useCallback(async (url: string) => {
setLoadingServerCheck(true);
const baseUrl = url.replace(/^https?:\/\//i, "");
const protocols = ["https", "http"];
try {
return checkHttp(baseUrl, protocols);
} catch (e) {
if (e instanceof Error && e.message === "Server too old") {
throw e;
}
return undefined;
} finally {
setLoadingServerCheck(false);
}
}, []);
async function checkHttp(baseUrl: string, protocols: string[]) {
for (const protocol of protocols) {
try {
const response = await fetch(
`${protocol}://${baseUrl}/System/Info/Public`,
{
mode: "cors",
},
);
if (response.ok) {
const data = (await response.json()) as PublicSystemInfo;
const serverVersion = data.Version?.split(".");
if (serverVersion && +serverVersion[0] <= 10) {
if (+serverVersion[1] < 10) {
Alert.alert(
t("login.too_old_server_text"),
t("login.too_old_server_description"),
);
throw new Error("Server too old");
}
}
setServerName(data.ServerName || "");
return `${protocol}://${baseUrl}`;
}
} catch (e) {
if (e instanceof Error && e.message === "Server too old") {
throw e;
}
}
}
return undefined;
}
const handleConnect = useCallback(async (url: string) => {
url = url.trim().replace(/\/$/, "");
try {
const result = await checkUrl(url);
if (result === undefined) {
Alert.alert(
t("login.connection_failed"),
t("login.could_not_connect_to_server"),
);
return;
}
await setServer({ address: result });
} catch {}
}, []);
const handleQuickConnect = async () => {
try {
const code = await initiateQuickConnect();
if (code) {
Alert.alert(
t("login.quick_connect"),
t("login.enter_code_to_login", { code: code }),
[
{
text: t("login.got_it"),
},
],
);
}
} catch (_error) {
Alert.alert(
t("login.error_title"),
t("login.failed_to_initiate_quick_connect"),
);
}
};
return (
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={{ flex: 1 }}
>
{api?.basePath ? (
<View className='flex flex-col flex-1 justify-center'>
<View className='px-4 w-full'>
<View className='flex flex-col space-y-2'>
<Text className='text-2xl font-bold -mb-2'>
{serverName ? (
<>
{`${t("login.login_to_title")} `}
<Text className='text-purple-600'>{serverName}</Text>
</>
) : (
t("login.login_title")
)}
</Text>
<Text className='text-xs text-neutral-400'>{api.basePath}</Text>
<Input
placeholder={t("login.username_placeholder")}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, username: text }))
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.username) {
setCredentials((prev) => ({
...prev,
username: newValue,
}));
}
}}
value={credentials.username}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
autoCorrect={false}
textContentType='username'
clearButtonMode='while-editing'
maxLength={500}
/>
<Input
placeholder={t("login.password_placeholder")}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, password: text }))
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.password) {
setCredentials((prev) => ({
...prev,
password: newValue,
}));
}
}}
value={credentials.password}
secureTextEntry
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
clearButtonMode='while-editing'
maxLength={500}
/>
<TouchableOpacity
onPress={() => setSaveAccount(!saveAccount)}
className='flex flex-row items-center py-2'
activeOpacity={0.7}
>
<Switch
value={saveAccount}
onValueChange={setSaveAccount}
trackColor={{ false: "#3f3f46", true: Colors.primary }}
thumbColor='white'
/>
<Text className='ml-3 text-neutral-300'>
{t("save_account.save_for_later")}
</Text>
</TouchableOpacity>
<View className='flex flex-row items-center justify-between'>
<Button
onPress={handleLogin}
loading={loading}
disabled={!credentials.username.trim()}
className='flex-1 mr-2'
>
{t("login.login_button")}
</Button>
<TouchableOpacity
onPress={handleQuickConnect}
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
>
<MaterialCommunityIcons
name='cellphone-lock'
size={24}
color='white'
/>
</TouchableOpacity>
</View>
</View>
</View>
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
</View>
) : (
<View className='flex flex-col flex-1 items-center justify-center w-full'>
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
<Image
style={{
width: 100,
height: 100,
marginLeft: -23,
marginBottom: -20,
}}
source={require("@/assets/images/icon-ios-plain.png")}
/>
<Text className='text-3xl font-bold'>Streamyfin</Text>
<Text className='text-neutral-500'>
{t("server.enter_url_to_jellyfin_server")}
</Text>
<Input
aria-label='Server URL'
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
maxLength={500}
/>
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => {
await handleConnect(serverURL);
}}
className='w-full grow'
>
{t("server.connect_button")}
</Button>
<JellyfinServerDiscovery
onServerSelect={async (server) => {
setServerURL(server.address);
if (server.serverName) {
setServerName(server.serverName);
}
await handleConnect(server.address);
}}
/>
<PreviousServersList
onServerSelect={async (s) => {
await handleConnect(s.address);
}}
onQuickLogin={handleQuickLoginWithSavedCredential}
onPasswordLogin={handlePasswordLogin}
onAddAccount={handleAddAccount}
/>
</View>
</View>
)}
</KeyboardAvoidingView>
<SaveAccountModal
visible={showSaveModal}
onClose={() => {
setShowSaveModal(false);
setPendingLogin(null);
}}
onSave={handleSaveAccountConfirm}
username={pendingLogin?.username || credentials.username}
/>
</SafeAreaView>
);
};

View File

@@ -0,0 +1,151 @@
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 { Colors } from "@/constants/Colors";
import type { SavedServerAccount } from "@/utils/secureCredentials";
interface TVAccountCardProps {
account: SavedServerAccount;
onPress: () => void;
onLongPress?: () => void;
hasTVPreferredFocus?: boolean;
}
export const TVAccountCard: React.FC<TVAccountCardProps> = ({
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 (
<Pressable
onPress={onPress}
onLongPress={onLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
{
transform: [{ scale }],
shadowColor: "#a855f7",
shadowOffset: { width: 0, height: 0 },
shadowRadius: 16,
elevation: 8,
},
{ shadowOpacity: glowOpacity },
]}
>
<View
style={{
backgroundColor: isFocused ? "#2a2a2a" : "#262626",
borderWidth: 2,
borderColor: isFocused ? "#FFFFFF" : "transparent",
borderRadius: 16,
paddingHorizontal: 24,
paddingVertical: 20,
flexDirection: "row",
alignItems: "center",
}}
>
{/* Avatar */}
<View
style={{
width: 56,
height: 56,
backgroundColor: "#404040",
borderRadius: 28,
alignItems: "center",
justifyContent: "center",
marginRight: 20,
}}
>
<Ionicons name='person' size={28} color='white' />
</View>
{/* Account Info */}
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 22,
fontWeight: "600",
color: "#FFFFFF",
}}
>
{account.username}
</Text>
<Text
style={{
fontSize: 16,
color: "#9CA3AF",
marginTop: 4,
}}
>
{getSecurityText()}
</Text>
</View>
{/* Security Icon */}
<Ionicons name={getSecurityIcon()} size={24} color={Colors.primary} />
</View>
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,81 @@
import React, { useRef, useState } from "react";
import {
Animated,
Easing,
Pressable,
TextInput,
type TextInputProps,
} from "react-native";
interface TVInputProps extends TextInputProps {
label?: string;
hasTVPreferredFocus?: boolean;
}
export const TVInput: React.FC<TVInputProps> = ({
label,
placeholder,
hasTVPreferredFocus,
style,
...props
}) => {
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<TextInput>(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 (
<Pressable
onPress={() => inputRef.current?.focus()}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
borderRadius: 10,
borderWidth: 3,
borderColor: isFocused ? "#FFFFFF" : "#333333",
}}
>
<TextInput
ref={inputRef}
placeholder={displayPlaceholder}
allowFontScaling={false}
style={[
{
height: 68,
fontSize: 24,
color: "#FFFFFF",
},
style,
]}
onFocus={handleFocus}
onBlur={handleBlur}
{...props}
/>
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,760 @@
import { Ionicons } 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, useRef, useState } from "react";
import {
Alert,
Animated,
Easing,
KeyboardAvoidingView,
Pressable,
ScrollView,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { z } from "zod";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { TVInput } from "@/components/login/TVInput";
import {
TVPreviousServersList,
TVServerActionSheet,
} from "@/components/login/TVPreviousServersList";
import { TVSaveAccountToggle } from "@/components/login/TVSaveAccountToggle";
import { TVServerCard } from "@/components/login/TVServerCard";
import { PasswordEntryModal } from "@/components/PasswordEntryModal";
import { PINEntryModal } from "@/components/PINEntryModal";
import { SaveAccountModal } from "@/components/SaveAccountModal";
import { Colors } from "@/constants/Colors";
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import {
type AccountSecurityType,
removeServerFromList,
type SavedServer,
type SavedServerAccount,
} from "@/utils/secureCredentials";
const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")),
});
const TVBackButton: React.FC<{ onPress: () => void; label: string }> = ({
onPress,
label,
}) => {
const [isFocused, setIsFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateFocus = (focused: boolean) => {
Animated.timing(scale, {
toValue: focused ? 1.05 : 1,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
};
return (
<Pressable
onPress={onPress}
onFocus={() => {
setIsFocused(true);
animateFocus(true);
}}
onBlur={() => {
setIsFocused(false);
animateFocus(false);
}}
style={{ alignSelf: "flex-start", marginBottom: 40 }}
>
<Animated.View
style={{
transform: [{ scale }],
flexDirection: "row",
alignItems: "center",
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 8,
backgroundColor: isFocused
? "rgba(168, 85, 247, 0.2)"
: "transparent",
borderWidth: 2,
borderColor: isFocused ? Colors.primary : "transparent",
}}
>
<Ionicons
name='chevron-back'
size={28}
color={isFocused ? "#FFFFFF" : Colors.primary}
/>
<Text
style={{
color: isFocused ? "#FFFFFF" : Colors.primary,
fontSize: 20,
marginLeft: 4,
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export const TVLogin: 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<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [serverURL, setServerURL] = useState<string>(_apiUrl || "");
const [serverName, setServerName] = useState<string>("");
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);
// PIN/Password entry for saved accounts
const [pinModalVisible, setPinModalVisible] = useState(false);
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
null,
);
const [selectedAccount, setSelectedAccount] =
useState<SavedServerAccount | null>(null);
// Server action sheet state
const [showServerActionSheet, setShowServerActionSheet] = useState(false);
const [actionSheetServer, setActionSheetServer] =
useState<SavedServer | null>(null);
const [loginTriggerServer, setLoginTriggerServer] =
useState<SavedServer | null>(null);
const [actionSheetKey, setActionSheetKey] = useState(0);
// Server discovery
const {
servers: discoveredServers,
isSearching,
startDiscovery,
} = useJellyfinDiscovery();
// Auto login from URL params
useEffect(() => {
(async () => {
if (_apiUrl) {
await setServer({ address: _apiUrl });
setTimeout(() => {
if (_username && _password) {
setCredentials({ username: _username, password: _password });
login(_username, _password);
}
}, 0);
}
})();
}, [_apiUrl, _username, _password]);
// Update header
useEffect(() => {
navigation.setOptions({
headerTitle: serverName,
headerShown: false,
});
}, [serverName, navigation]);
const handleLogin = async () => {
const result = CredentialsSchema.safeParse(credentials);
if (!result.success) return;
if (saveAccount) {
setPendingLogin({
username: credentials.username,
password: credentials.password,
});
setShowSaveModal(true);
} else {
await performLogin(credentials.username, credentials.password);
}
};
const performLogin = async (
username: string,
password: string,
options?: {
saveAccount?: boolean;
securityType?: AccountSecurityType;
pinCode?: string;
},
) => {
setLoading(true);
try {
await login(username, password, serverName, options);
} catch (error) {
if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message);
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_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) => {
setServer({ address: server.address });
if (server.name) {
setServerName(server.name);
}
};
const handlePinRequired = (
server: SavedServer,
account: SavedServerAccount,
) => {
setSelectedServer(server);
setSelectedAccount(account);
setPinModalVisible(true);
};
const handlePasswordRequired = (
server: SavedServer,
account: SavedServerAccount,
) => {
setSelectedServer(server);
setSelectedAccount(account);
setPasswordModalVisible(true);
};
const handlePinSuccess = async () => {
setPinModalVisible(false);
if (selectedServer && selectedAccount) {
await handleQuickLoginWithSavedCredential(
selectedServer.address,
selectedAccount.userId,
);
}
setSelectedServer(null);
setSelectedAccount(null);
};
const handlePasswordSubmit = async (password: string) => {
if (selectedServer && selectedAccount) {
await handlePasswordLogin(
selectedServer.address,
selectedAccount.username,
password,
);
}
setPasswordModalVisible(false);
setSelectedServer(null);
setSelectedAccount(null);
};
const handleForgotPIN = async () => {
if (selectedServer) {
setSelectedServer(null);
setSelectedAccount(null);
setPinModalVisible(false);
}
};
// Server action sheet handlers
const handleServerAction = (server: SavedServer) => {
setActionSheetServer(server);
setActionSheetKey((k) => k + 1); // Force remount to reset focus
setShowServerActionSheet(true);
};
const handleServerActionLogin = () => {
setShowServerActionSheet(false);
if (actionSheetServer) {
// Trigger the login flow in TVPreviousServersList
setLoginTriggerServer(actionSheetServer);
// Reset the trigger after a tick to allow re-triggering the same server
setTimeout(() => setLoginTriggerServer(null), 0);
}
};
const handleServerActionDelete = () => {
if (!actionSheetServer) return;
Alert.alert(
t("server.remove_server"),
t("server.remove_server_description", {
server: actionSheetServer.name || actionSheetServer.address,
}),
[
{
text: t("common.cancel"),
style: "cancel",
onPress: () => setShowServerActionSheet(false),
},
{
text: t("common.delete"),
style: "destructive",
onPress: async () => {
await removeServerFromList(actionSheetServer.address);
setShowServerActionSheet(false);
setActionSheetServer(null);
},
},
],
);
};
const checkUrl = useCallback(async (url: string) => {
setLoadingServerCheck(true);
const baseUrl = url.replace(/^https?:\/\//i, "");
const protocols = ["https", "http"];
try {
return checkHttp(baseUrl, protocols);
} catch (e) {
if (e instanceof Error && e.message === "Server too old") {
throw e;
}
return undefined;
} finally {
setLoadingServerCheck(false);
}
}, []);
async function checkHttp(baseUrl: string, protocols: string[]) {
for (const protocol of protocols) {
try {
const response = await fetch(
`${protocol}://${baseUrl}/System/Info/Public`,
{ mode: "cors" },
);
if (response.ok) {
const data = (await response.json()) as PublicSystemInfo;
const serverVersion = data.Version?.split(".");
if (serverVersion && +serverVersion[0] <= 10) {
if (+serverVersion[1] < 10) {
Alert.alert(
t("login.too_old_server_text"),
t("login.too_old_server_description"),
);
throw new Error("Server too old");
}
}
setServerName(data.ServerName || "");
return `${protocol}://${baseUrl}`;
}
} catch (e) {
if (e instanceof Error && e.message === "Server too old") {
throw e;
}
}
}
return undefined;
}
const handleConnect = useCallback(async (url: string) => {
url = url.trim().replace(/\/$/, "");
try {
const result = await checkUrl(url);
if (result === undefined) {
Alert.alert(
t("login.connection_failed"),
t("login.could_not_connect_to_server"),
);
return;
}
await setServer({ address: result });
} catch {}
}, []);
const handleQuickConnect = async () => {
try {
const code = await initiateQuickConnect();
if (code) {
Alert.alert(
t("login.quick_connect"),
t("login.enter_code_to_login", { code: code }),
[{ text: t("login.got_it") }],
);
}
} catch (_error) {
Alert.alert(
t("login.error_title"),
t("login.failed_to_initiate_quick_connect"),
);
}
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: "#000000" }}>
<KeyboardAvoidingView behavior='padding' style={{ flex: 1 }}>
{api?.basePath ? (
// ==================== CREDENTIALS SCREEN ====================
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: 60,
}}
showsVerticalScrollIndicator={false}
>
<View
style={{
width: "100%",
maxWidth: 800,
paddingHorizontal: 60,
}}
>
{/* Back Button */}
<TVBackButton
onPress={() => removeServer()}
label={t("login.change_server")}
/>
{/* Title */}
<Text
style={{
fontSize: 48,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{serverName ? (
<>
{`${t("login.login_to_title")} `}
<Text style={{ color: Colors.primary }}>{serverName}</Text>
</>
) : (
t("login.login_title")
)}
</Text>
<Text
style={{
fontSize: 18,
color: "#9CA3AF",
marginBottom: 40,
}}
>
{api.basePath}
</Text>
{/* Username Input - extra padding for focus scale */}
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
<TVInput
placeholder={t("login.username_placeholder")}
value={credentials.username}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, username: text }))
}
autoCapitalize='none'
autoCorrect={false}
textContentType='username'
returnKeyType='next'
hasTVPreferredFocus
/>
</View>
{/* Password Input */}
<View style={{ marginBottom: 32, paddingHorizontal: 8 }}>
<TVInput
placeholder={t("login.password_placeholder")}
value={credentials.password}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, password: text }))
}
secureTextEntry
autoCapitalize='none'
textContentType='password'
returnKeyType='done'
/>
</View>
{/* Save Account Toggle */}
<View style={{ marginBottom: 40, paddingHorizontal: 8 }}>
<TVSaveAccountToggle
value={saveAccount}
onValueChange={setSaveAccount}
label={t("save_account.save_for_later")}
/>
</View>
{/* Login Button */}
<View style={{ marginBottom: 16 }}>
<Button
onPress={handleLogin}
loading={loading}
disabled={!credentials.username.trim() || loading}
>
{t("login.login_button")}
</Button>
</View>
{/* Quick Connect Button */}
<Button
onPress={handleQuickConnect}
color='black'
className='bg-neutral-800 border border-neutral-700'
>
{t("login.quick_connect")}
</Button>
</View>
</ScrollView>
) : (
// ==================== SERVER SELECTION SCREEN ====================
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: 60,
}}
showsVerticalScrollIndicator={false}
>
<View
style={{
width: "100%",
maxWidth: 800,
paddingHorizontal: 60,
}}
>
{/* Logo */}
<View style={{ alignItems: "center", marginBottom: 16 }}>
<Image
source={require("@/assets/images/icon-tvos.png")}
style={{ width: 150, height: 150 }}
contentFit='contain'
/>
</View>
{/* Title */}
<Text
style={{
fontSize: 48,
fontWeight: "bold",
color: "#FFFFFF",
textAlign: "center",
marginBottom: 8,
}}
>
Streamyfin
</Text>
<Text
style={{
fontSize: 20,
color: "#9CA3AF",
textAlign: "center",
marginBottom: 40,
}}
>
{t("server.enter_url_to_jellyfin_server")}
</Text>
{/* Server URL Input - extra padding for focus scale */}
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
<TVInput
placeholder={t("server.server_url_placeholder")}
value={serverURL}
onChangeText={setServerURL}
keyboardType='url'
autoCapitalize='none'
textContentType='URL'
returnKeyType='done'
hasTVPreferredFocus
/>
</View>
{/* Connect Button */}
<View style={{ marginBottom: 16 }}>
<Button
onPress={() => handleConnect(serverURL)}
loading={loadingServerCheck}
disabled={loadingServerCheck || !serverURL.trim()}
>
{t("server.connect_button")}
</Button>
</View>
{/* Server Discovery */}
<View style={{ marginBottom: 24 }}>
<Button
onPress={startDiscovery}
color='black'
className='bg-neutral-800'
>
{isSearching
? t("server.searching")
: t("server.search_for_local_servers")}
</Button>
</View>
{/* Discovered Servers */}
{discoveredServers.length > 0 && (
<View
style={{
marginTop: 16,
marginBottom: 16,
paddingHorizontal: 8,
}}
>
<Text
style={{
fontSize: 20,
fontWeight: "600",
color: "#9CA3AF",
marginBottom: 16,
}}
>
{t("server.servers")}
</Text>
<View style={{ gap: 16 }}>
{discoveredServers.map((server) => (
<TVServerCard
key={server.address}
title={server.serverName || server.address}
subtitle={
server.serverName ? server.address : undefined
}
onPress={() => {
setServerURL(server.address);
if (server.serverName) {
setServerName(server.serverName);
}
handleConnect(server.address);
}}
/>
))}
</View>
</View>
)}
{/* Previous Servers */}
<View style={{ paddingHorizontal: 8 }}>
<TVPreviousServersList
onServerSelect={(s) => handleConnect(s.address)}
onQuickLogin={handleQuickLoginWithSavedCredential}
onPasswordLogin={handlePasswordLogin}
onAddAccount={handleAddAccount}
onPinRequired={handlePinRequired}
onPasswordRequired={handlePasswordRequired}
onServerAction={handleServerAction}
loginServerOverride={loginTriggerServer}
/>
</View>
</View>
</ScrollView>
)}
</KeyboardAvoidingView>
{/* Save Account Modal */}
<SaveAccountModal
visible={showSaveModal}
onClose={() => {
setShowSaveModal(false);
setPendingLogin(null);
}}
onSave={handleSaveAccountConfirm}
username={pendingLogin?.username || credentials.username}
/>
{/* PIN Entry Modal */}
<PINEntryModal
visible={pinModalVisible}
onClose={() => {
setPinModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
onSuccess={handlePinSuccess}
onForgotPIN={handleForgotPIN}
serverUrl={selectedServer?.address || ""}
userId={selectedAccount?.userId || ""}
username={selectedAccount?.username || ""}
/>
{/* Password Entry Modal */}
<PasswordEntryModal
visible={passwordModalVisible}
onClose={() => {
setPasswordModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
onSubmit={handlePasswordSubmit}
username={selectedAccount?.username || ""}
/>
{/* Server Action Sheet */}
<TVServerActionSheet
key={actionSheetKey}
visible={showServerActionSheet}
server={actionSheetServer}
onLogin={handleServerActionLogin}
onDelete={handleServerActionDelete}
onClose={() => setShowServerActionSheet(false)}
/>
</SafeAreaView>
);
};

View File

@@ -0,0 +1,508 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import type React from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Alert,
Animated,
Easing,
Modal,
Pressable,
ScrollView,
View,
} from "react-native";
import { useMMKVString } from "react-native-mmkv";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import {
deleteAccountCredential,
getPreviousServers,
type SavedServer,
type SavedServerAccount,
} from "@/utils/secureCredentials";
import { TVAccountCard } from "./TVAccountCard";
import { TVServerCard } from "./TVServerCard";
// Action card for server action sheet (Apple TV style)
const TVServerActionCard: 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 animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
const isDestructive = variant === "destructive";
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
width: 180,
height: 90,
backgroundColor: focused
? isDestructive
? "#ef4444"
: "#fff"
: isDestructive
? "rgba(239, 68, 68, 0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
gap: 8,
}}
>
<Ionicons
name={icon}
size={28}
color={
focused
? isDestructive
? "#fff"
: "#000"
: isDestructive
? "#ef4444"
: "#fff"
}
/>
<Text
style={{
fontSize: 16,
color: focused
? isDestructive
? "#fff"
: "#000"
: isDestructive
? "#ef4444"
: "#fff",
fontWeight: "600",
textAlign: "center",
}}
numberOfLines={1}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
// Server action sheet component (bottom sheet with horizontal scrolling)
const TVServerActionSheet: React.FC<{
visible: boolean;
server: SavedServer | null;
onLogin: () => void;
onDelete: () => void;
onClose: () => void;
}> = ({ visible, server, onLogin, onDelete, onClose }) => {
const { t } = useTranslation();
if (!server) return null;
return (
<Modal
visible={visible}
transparent
animationType='fade'
onRequestClose={onClose}
>
<View
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<View
style={{
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
}}
>
{/* Title */}
<Text
style={{
fontSize: 18,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 8,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
{server.name || server.address}
</Text>
{/* Horizontal options */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
<TVServerActionCard
label={t("common.login")}
icon='log-in-outline'
hasTVPreferredFocus
onPress={onLogin}
/>
<TVServerActionCard
label={t("common.delete")}
icon='trash-outline'
variant='destructive'
onPress={onDelete}
/>
<TVServerActionCard
label={t("common.cancel")}
icon='close-outline'
onPress={onClose}
/>
</ScrollView>
</View>
</BlurView>
</View>
</Modal>
);
};
interface TVPreviousServersListProps {
onServerSelect: (server: SavedServer) => void;
onQuickLogin?: (serverUrl: string, userId: string) => Promise<void>;
onPasswordLogin?: (
serverUrl: string,
username: string,
password: string,
) => Promise<void>;
onAddAccount?: (server: SavedServer) => void;
onPinRequired?: (server: SavedServer, account: SavedServerAccount) => void;
onPasswordRequired?: (
server: SavedServer,
account: SavedServerAccount,
) => void;
// Called when server is pressed to show action sheet (handled by parent)
onServerAction?: (server: SavedServer) => void;
// Called by parent when "Login" is selected from action sheet
loginServerOverride?: SavedServer | null;
}
// Export the action sheet for use in parent components
export { TVServerActionSheet };
export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
onServerSelect,
onQuickLogin,
onAddAccount,
onPinRequired,
onPasswordRequired,
onServerAction,
loginServerOverride,
}) => {
const { t } = useTranslation();
const [_previousServers, setPreviousServers] =
useMMKVString("previousServers");
const [loadingServer, setLoadingServer] = useState<string | null>(null);
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
null,
);
const [showAccountsModal, setShowAccountsModal] = useState(false);
const previousServers = useMemo(() => {
return JSON.parse(_previousServers || "[]") as SavedServer[];
}, [_previousServers]);
// When parent triggers login via loginServerOverride, execute the login flow
useEffect(() => {
if (loginServerOverride) {
const accountCount = loginServerOverride.accounts?.length || 0;
if (accountCount === 0) {
onServerSelect(loginServerOverride);
} else if (accountCount === 1) {
handleAccountLogin(
loginServerOverride,
loginServerOverride.accounts[0],
);
} else {
setSelectedServer(loginServerOverride);
setShowAccountsModal(true);
}
}
}, [loginServerOverride]);
const refreshServers = () => {
const servers = getPreviousServers();
setPreviousServers(JSON.stringify(servers));
};
const handleAccountLogin = async (
server: SavedServer,
account: SavedServerAccount,
) => {
setShowAccountsModal(false);
switch (account.securityType) {
case "none":
if (onQuickLogin) {
setLoadingServer(server.address);
try {
await onQuickLogin(server.address, account.userId);
} catch {
Alert.alert(
t("server.session_expired"),
t("server.please_login_again"),
[{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
);
} finally {
setLoadingServer(null);
}
}
break;
case "pin":
if (onPinRequired) {
onPinRequired(server, account);
}
break;
case "password":
if (onPasswordRequired) {
onPasswordRequired(server, account);
}
break;
}
};
const handleServerPress = (server: SavedServer) => {
if (loadingServer) return;
// If onServerAction is provided, delegate to parent for action sheet handling
if (onServerAction) {
onServerAction(server);
return;
}
// Fallback: direct login flow (for backwards compatibility)
const accountCount = server.accounts?.length || 0;
if (accountCount === 0) {
onServerSelect(server);
} else if (accountCount === 1) {
handleAccountLogin(server, server.accounts[0]);
} else {
setSelectedServer(server);
setShowAccountsModal(true);
}
};
const getServerSubtitle = (server: SavedServer): string | undefined => {
const accountCount = server.accounts?.length || 0;
if (accountCount > 1) {
return t("server.accounts_count", { count: accountCount });
}
if (accountCount === 1) {
return `${server.accounts[0].username}${t("server.saved")}`;
}
return server.name ? server.address : undefined;
};
const getSecurityIcon = (
server: SavedServer,
): keyof typeof Ionicons.glyphMap | null => {
const accountCount = server.accounts?.length || 0;
if (accountCount === 0) return null;
if (accountCount > 1) {
return "people";
}
const account = server.accounts[0];
switch (account.securityType) {
case "pin":
return "keypad";
case "password":
return "lock-closed";
default:
return "key";
}
};
const handleDeleteAccount = async (account: SavedServerAccount) => {
if (!selectedServer) return;
Alert.alert(
t("server.remove_saved_login"),
t("server.remove_account_description", { username: account.username }),
[
{ text: t("common.cancel"), style: "cancel" },
{
text: t("common.remove"),
style: "destructive",
onPress: async () => {
await deleteAccountCredential(
selectedServer.address,
account.userId,
);
refreshServers();
if (selectedServer.accounts.length <= 1) {
setShowAccountsModal(false);
}
},
},
],
);
};
if (!previousServers.length) return null;
return (
<View style={{ marginTop: 32 }}>
<Text
style={{
fontSize: 24,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 16,
}}
>
{t("server.previous_servers")}
</Text>
<View style={{ gap: 12 }}>
{previousServers.map((server) => (
<TVServerCard
key={server.address}
title={server.name || server.address}
subtitle={getServerSubtitle(server)}
securityIcon={getSecurityIcon(server)}
isLoading={loadingServer === server.address}
onPress={() => handleServerPress(server)}
/>
))}
</View>
{/* TV Account Selection Modal */}
<Modal
visible={showAccountsModal}
transparent
animationType='fade'
onRequestClose={() => setShowAccountsModal(false)}
>
<View
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.9)",
justifyContent: "center",
alignItems: "center",
padding: 80,
}}
>
<View
style={{
backgroundColor: "#1a1a1a",
borderRadius: 24,
padding: 40,
width: "100%",
maxWidth: 700,
}}
>
<Text
style={{
fontSize: 32,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{t("server.select_account")}
</Text>
<Text
style={{
fontSize: 18,
color: "#9CA3AF",
marginBottom: 32,
}}
>
{selectedServer?.name || selectedServer?.address}
</Text>
<View style={{ gap: 12, marginBottom: 24 }}>
{selectedServer?.accounts.map((account, index) => (
<TVAccountCard
key={account.userId}
account={account}
onPress={() =>
selectedServer &&
handleAccountLogin(selectedServer, account)
}
onLongPress={() => handleDeleteAccount(account)}
hasTVPreferredFocus={index === 0}
/>
))}
</View>
<View style={{ gap: 12 }}>
<Button
onPress={() => {
setShowAccountsModal(false);
if (selectedServer && onAddAccount) {
onAddAccount(selectedServer);
}
}}
color='purple'
>
{t("server.add_account")}
</Button>
<Button
onPress={() => setShowAccountsModal(false)}
color='black'
className='bg-neutral-800'
>
{t("common.cancel")}
</Button>
</View>
</View>
</View>
</Modal>
</View>
);
};

View File

@@ -0,0 +1,115 @@
import React, { useRef, useState } from "react";
import { Animated, Easing, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
interface TVSaveAccountToggleProps {
value: boolean;
onValueChange: (value: boolean) => void;
label: string;
hasTVPreferredFocus?: boolean;
}
export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
value,
onValueChange,
label,
hasTVPreferredFocus,
}) => {
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 (
<Pressable
onPress={() => onValueChange(!value)}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
{
transform: [{ scale }],
shadowColor: "#a855f7",
shadowOffset: { width: 0, height: 0 },
shadowRadius: 16,
elevation: 8,
},
{ shadowOpacity: glowOpacity },
]}
>
<View
style={{
backgroundColor: isFocused ? "#2a2a2a" : "#1a1a1a",
borderWidth: 2,
borderColor: isFocused ? "#FFFFFF" : "transparent",
borderRadius: 16,
paddingHorizontal: 24,
paddingVertical: 20,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Text
style={{
fontSize: 20,
color: "#FFFFFF",
}}
>
{label}
</Text>
<View
pointerEvents='none'
style={{
width: 60,
height: 34,
borderRadius: 17,
backgroundColor: value ? Colors.primary : "#3f3f46",
justifyContent: "center",
paddingHorizontal: 3,
}}
>
<View
style={{
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: "white",
alignSelf: value ? "flex-end" : "flex-start",
}}
/>
</View>
</View>
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,148 @@
import { Ionicons } from "@expo/vector-icons";
import React, { useRef, useState } from "react";
import {
ActivityIndicator,
Animated,
Easing,
Pressable,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
interface TVServerCardProps {
title: string;
subtitle?: string;
securityIcon?: keyof typeof Ionicons.glyphMap | null;
isLoading?: boolean;
onPress: () => void;
hasTVPreferredFocus?: boolean;
}
export const TVServerCard: React.FC<TVServerCardProps> = ({
title,
subtitle,
securityIcon,
isLoading,
onPress,
hasTVPreferredFocus,
}) => {
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.7 : 0,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
]).start();
};
const handleFocus = () => {
setIsFocused(true);
animateFocus(true);
};
const handleBlur = () => {
setIsFocused(false);
animateFocus(false);
};
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={isLoading}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
{
transform: [{ scale }],
shadowColor: "#a855f7",
shadowOffset: { width: 0, height: 0 },
shadowRadius: 16,
elevation: 8,
},
{ shadowOpacity: glowOpacity },
]}
>
<View
style={{
backgroundColor: isFocused ? "#2a2a2a" : "#1a1a1a",
borderWidth: 2,
borderColor: isFocused ? "#FFFFFF" : "transparent",
borderRadius: 16,
paddingHorizontal: 24,
paddingVertical: 20,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 22,
fontWeight: "600",
color: "#FFFFFF",
}}
numberOfLines={1}
>
{title}
</Text>
{subtitle && (
<Text
style={{
fontSize: 16,
color: "#9CA3AF",
marginTop: 4,
}}
numberOfLines={1}
>
{subtitle}
</Text>
)}
</View>
<View style={{ marginLeft: 16 }}>
{isLoading ? (
<ActivityIndicator size='small' color={Colors.primary} />
) : securityIcon ? (
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Ionicons
name={securityIcon}
size={20}
color={Colors.primary}
style={{ marginRight: 8 }}
/>
<Ionicons
name='chevron-forward'
size={24}
color={isFocused ? "#FFFFFF" : "#6B7280"}
/>
</View>
) : (
<Ionicons
name='chevron-forward'
size={24}
color={isFocused ? "#FFFFFF" : "#6B7280"}
/>
)}
</View>
</View>
</Animated.View>
</Pressable>
);
};

View File

@@ -243,7 +243,7 @@ export const MiniPlayerBar: React.FC = () => {
]}
>
<Animated.View style={[styles.touchable, animatedBarStyle]}>
{Platform.OS === "ios" ? (
{Platform.OS === "ios" && !Platform.isTV ? (
<GlassEffectView style={styles.blurContainer}>
<View
style={{

View File

@@ -0,0 +1,530 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useSegments } from "expo-router";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Dimensions,
Easing,
FlatList,
Pressable,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const HORIZONTAL_PADDING = 80;
const TOP_PADDING = 140;
const ACTOR_IMAGE_SIZE = 250;
const ITEM_GAP = 16;
const SCALE_PADDING = 20;
// Focusable poster wrapper component for TV
const TVFocusablePoster: React.FC<{
children: React.ReactNode;
onPress: () => void;
hasTVPreferredFocus?: boolean;
onFocus?: () => void;
onBlur?: () => void;
}> = ({ children, onPress, hasTVPreferredFocus, onFocus, onBlur }) => {
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();
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
onFocus?.();
}}
onBlur={() => {
setFocused(false);
animateTo(1);
onBlur?.();
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
shadowColor: "#ffffff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
}}
>
{children}
</Animated.View>
</Pressable>
);
};
interface TVActorPageProps {
personId: string;
}
export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
// Track which filmography item is currently focused for dynamic backdrop
const [focusedIndex, setFocusedIndex] = useState(0);
// FlatList ref for scrolling back
const filmographyListRef = useRef<FlatList<BaseItemDto>>(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 filmography
const { data: filmography = [], isLoading: isLoadingFilmography } = useQuery({
queryKey: ["actor", "filmography", 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", "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(() => {
if (filmography.length === 0) return null;
const focusedItem = filmography[focusedIndex] ?? filmography[0];
return getBackdropUrl({
api,
item: focusedItem,
quality: 90,
width: 1920,
});
}, [api, filmography, focusedIndex]);
// Crossfade animation for backdrop transitions
// Use two alternating layers for smooth crossfade
const [activeLayer, setActiveLayer] = useState<0 | 1>(0);
const [layer0Url, setLayer0Url] = useState<string | null>(null);
const [layer1Url, setLayer1Url] = useState<string | null>(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<BaseItemDto> | null | undefined, index: number) => ({
length: TV_POSTER_WIDTH + ITEM_GAP,
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
index,
}),
[],
);
// Render filmography item
const renderFilmographyItem = useCallback(
({ item: filmItem, index }: { item: BaseItemDto; index: number }) => (
<View style={{ marginRight: ITEM_GAP }}>
<TVFocusablePoster
onPress={() => handleItemPress(filmItem)}
onFocus={() => setFocusedIndex(index)}
hasTVPreferredFocus={index === 0}
>
<View>
<MoviePoster item={filmItem} />
<View style={{ width: TV_POSTER_WIDTH, marginTop: 8 }}>
<ItemCardText item={filmItem} />
</View>
</View>
</TVFocusablePoster>
</View>
),
[handleItemPress],
);
if (isLoadingActor) {
return (
<View
style={{
flex: 1,
backgroundColor: "#000000",
justifyContent: "center",
alignItems: "center",
}}
>
<Loader />
</View>
);
}
if (!item?.Id) return null;
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
{/* Full-screen backdrop with crossfade - two alternating layers */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
{/* Layer 0 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer0Opacity,
}}
>
{layer0Url ? (
<Image
source={{ uri: layer0Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View style={{ flex: 1, backgroundColor: "#1a1a1a" }} />
)}
</Animated.View>
{/* Layer 1 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer1Opacity,
}}
>
{layer1Url ? (
<Image
source={{ uri: layer1Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View style={{ flex: 1, backgroundColor: "#1a1a1a" }} />
)}
</Animated.View>
{/* Gradient overlays for readability */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.5, 1]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: "70%",
}}
/>
<LinearGradient
colors={["rgba(0,0,0,0.8)", "transparent"]}
start={{ x: 0, y: 0 }}
end={{ x: 0.6, y: 0 }}
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: "60%",
}}
/>
</View>
{/* Main content area */}
<View
style={{
flex: 1,
paddingTop: insets.top + TOP_PADDING,
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
}}
>
{/* Top section - Actor image + Info */}
<View
style={{
flexDirection: "row",
marginBottom: 48,
}}
>
{/* Left side - Circular actor image */}
<View
style={{
width: ACTOR_IMAGE_SIZE,
height: ACTOR_IMAGE_SIZE,
borderRadius: ACTOR_IMAGE_SIZE / 2,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
marginRight: 50,
borderWidth: 3,
borderColor: "rgba(255,255,255,0.2)",
}}
>
{actorImageUrl ? (
<Image
source={{ uri: actorImageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name='person'
size={80}
color='rgba(255,255,255,0.4)'
/>
</View>
)}
</View>
{/* Right side - Info */}
<View style={{ flex: 1, justifyContent: "center" }}>
{/* Actor name */}
<Text
style={{
fontSize: 42,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
numberOfLines={2}
>
{item.Name}
</Text>
{/* Production year / Birth year */}
{item.ProductionYear && (
<Text
style={{
fontSize: 18,
color: "#9CA3AF",
marginBottom: 16,
}}
>
{item.ProductionYear}
</Text>
)}
{/* Biography */}
{item.Overview && (
<Text
style={{
fontSize: 18,
color: "#D1D5DB",
lineHeight: 28,
maxWidth: SCREEN_WIDTH * 0.45,
}}
numberOfLines={4}
>
{item.Overview}
</Text>
)}
</View>
</View>
{/* Filmography section */}
<View style={{ flex: 1, overflow: "visible" }}>
<Text
style={{
fontSize: 22,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
>
{t("item_card.appeared_in")}
</Text>
{isLoadingFilmography ? (
<View
style={{
height: 300,
justifyContent: "center",
alignItems: "center",
}}
>
<Loader />
</View>
) : filmography.length === 0 ? (
<Text
style={{
color: "#737373",
fontSize: 16,
marginLeft: SCALE_PADDING,
}}
>
{t("common.no_results")}
</Text>
) : (
<FlatList
ref={filmographyListRef}
horizontal
data={filmography}
keyExtractor={(filmItem) => filmItem.Id!}
renderItem={renderFilmographyItem}
showsHorizontalScrollIndicator={false}
initialNumToRender={6}
maxToRenderPerBatch={4}
windowSize={5}
removeClippedSubviews={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
}}
/>
)}
</View>
</View>
</View>
);
};

View File

@@ -0,0 +1,82 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
export const TV_POSTER_WIDTH = 210;
type MoviePosterProps = {
item: BaseItemDto;
showProgress?: boolean;
};
const MoviePoster: React.FC<MoviePosterProps> = ({
item,
showProgress = false,
}) => {
const [api] = useAtom(apiAtom);
const url = useMemo(() => {
return getPrimaryImageUrl({
api,
item,
width: 420, // 2x for quality on large screens
});
}, [api, item]);
const progress = item.UserData?.PlayedPercentage || 0;
const blurhash = useMemo(() => {
const key = item.ImageTags?.Primary as string;
return item.ImageBlurHashes?.Primary?.[key];
}, [item]);
return (
<View
style={{
position: "relative",
borderRadius: 24,
overflow: "hidden",
width: TV_POSTER_WIDTH,
aspectRatio: 10 / 15,
}}
>
<Image
placeholder={{
blurhash,
}}
key={item.Id}
id={item.Id}
source={
url
? {
uri: url,
}
: null
}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
aspectRatio: 10 / 15,
width: "100%",
}}
/>
<WatchedIndicator item={item} />
{showProgress && progress > 0 && (
<View
style={{
height: 4,
backgroundColor: "#dc2626",
width: "100%",
}}
/>
)}
</View>
);
};
export default MoviePoster;

View File

@@ -0,0 +1,69 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
export const TV_POSTER_WIDTH = 210;
type SeriesPosterProps = {
item: BaseItemDto;
showProgress?: boolean;
};
const SeriesPoster: React.FC<SeriesPosterProps> = ({ item }) => {
const [api] = useAtom(apiAtom);
const url = useMemo(() => {
if (item.Type === "Episode") {
return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=630&quality=80&tag=${item.SeriesPrimaryImageTag}`;
}
return getPrimaryImageUrl({
api,
item,
width: 420, // 2x for quality on large screens
});
}, [api, item]);
const blurhash = useMemo(() => {
const key = item.ImageTags?.Primary as string;
return item.ImageBlurHashes?.Primary?.[key];
}, [item]);
return (
<View
style={{
width: TV_POSTER_WIDTH,
aspectRatio: 10 / 15,
position: "relative",
borderRadius: 24,
overflow: "hidden",
}}
>
<Image
placeholder={{
blurhash,
}}
key={item.Id}
id={item.Id}
source={
url
? {
uri: url,
}
: null
}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
height: "100%",
width: "100%",
}}
/>
</View>
);
};
export default SeriesPoster;

View File

@@ -0,0 +1,298 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { TVSearchSection } from "./TVSearchSection";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
const SECTION_GAP = 10;
const SCALE_PADDING = 20;
// Loading skeleton for TV
const TVLoadingSkeleton: React.FC = () => {
const itemWidth = 210;
return (
<View style={{ overflow: "visible" }}>
<View
style={{
width: 200,
height: 28,
backgroundColor: "#262626",
borderRadius: 8,
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
/>
<View
style={{
flexDirection: "row",
gap: 16,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: itemWidth }}>
<View
style={{
backgroundColor: "#262626",
width: itemWidth,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,
}}
/>
<View
style={{
borderRadius: 6,
overflow: "hidden",
marginBottom: 4,
alignSelf: "flex-start",
}}
>
<Text
style={{
color: "#262626",
backgroundColor: "#262626",
borderRadius: 6,
fontSize: 16,
}}
numberOfLines={1}
>
Placeholder text here
</Text>
</View>
</View>
))}
</View>
</View>
);
};
// Example search suggestions for TV
const exampleSearches = [
"Lord of the rings",
"Avengers",
"Game of Thrones",
"Breaking Bad",
"Stranger Things",
"The Mandalorian",
];
interface TVSearchPageProps {
search: string;
setSearch: (text: string) => void;
debouncedSearch: string;
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;
}
export const TVSearchPage: React.FC<TVSearchPageProps> = ({
search,
setSearch,
debouncedSearch,
movies,
series,
episodes,
collections,
actors,
artists,
albums,
songs,
playlists,
loading,
noResults,
onItemPress,
}) => {
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,
]);
return (
<ScrollView
nestedScrollEnabled
showsVerticalScrollIndicator={false}
keyboardDismissMode='on-drag'
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingBottom: insets.bottom + 60,
paddingLeft: insets.left + HORIZONTAL_PADDING,
paddingRight: insets.right + HORIZONTAL_PADDING,
}}
>
{/* Search Input */}
<View style={{ marginBottom: 32, marginHorizontal: SCALE_PADDING }}>
<Input
placeholder={t("search.search")}
value={search}
onChangeText={setSearch}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
clearButtonMode='while-editing'
maxLength={500}
hasTVPreferredFocus={
debouncedSearch.length === 0 && sections.length === 0
}
/>
</View>
{/* Loading State */}
{loading && (
<View style={{ gap: SECTION_GAP }}>
<TVLoadingSkeleton />
<TVLoadingSkeleton />
</View>
)}
{/* Search Results */}
{!loading && (
<View style={{ gap: SECTION_GAP }}>
{sections.map((section, index) => (
<TVSearchSection
key={section.key}
title={section.title}
items={section.items!}
orientation={section.orientation || "vertical"}
isFirstSection={index === 0}
onItemPress={onItemPress}
imageUrlGetter={
["artists", "albums", "songs", "playlists"].includes(
section.key,
)
? getImageUrl
: undefined
}
/>
))}
</View>
)}
{/* No Results State */}
{!loading && noResults && debouncedSearch.length > 0 && (
<View style={{ alignItems: "center", paddingTop: 40 }}>
<Text
style={{
fontSize: 24,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{t("search.no_results_found_for")}
</Text>
<Text style={{ fontSize: 18, color: "#9334E9" }}>
"{debouncedSearch}"
</Text>
</View>
)}
{/* Example Searches (when no search query) */}
{!loading && debouncedSearch.length === 0 && (
<View style={{ alignItems: "center", paddingTop: 40, gap: 16 }}>
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
gap: 12,
justifyContent: "center",
}}
>
{exampleSearches.map((example) => (
<Pressable
key={example}
onPress={() => setSearch(example)}
style={({ focused }) => ({
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 24,
backgroundColor: focused
? "#9334E9"
: "rgba(255, 255, 255, 0.1)",
transform: [{ scale: focused ? 1.05 : 1 }],
})}
>
<Text
style={{
fontSize: 16,
color: "#FFFFFF",
}}
>
{example}
</Text>
</Pressable>
))}
</View>
</View>
)}
</ScrollView>
);
};

View File

@@ -0,0 +1,344 @@
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 ContinueWatchingPoster, {
TV_LANDSCAPE_WIDTH,
} from "@/components/ContinueWatchingPoster.tv";
import { Text } from "@/components/common/Text";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
const ITEM_GAP = 16;
const SCALE_PADDING = 20;
// TV-specific ItemCardText with larger fonts
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return (
<View style={{ marginTop: 12, flexDirection: "column" }}>
{item.Type === "Episode" ? (
<>
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
{item.Name}
</Text>
<Text
numberOfLines={1}
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
>
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
{" - "}
{item.SeriesName}
</Text>
</>
) : item.Type === "MusicArtist" ? (
<Text
numberOfLines={2}
style={{ fontSize: 16, color: "#FFFFFF", textAlign: "center" }}
>
{item.Name}
</Text>
) : item.Type === "MusicAlbum" ? (
<>
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
{item.Name}
</Text>
<Text
numberOfLines={1}
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
>
{item.AlbumArtist || item.Artists?.join(", ")}
</Text>
</>
) : item.Type === "Audio" ? (
<>
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
{item.Name}
</Text>
<Text
numberOfLines={1}
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
>
{item.Artists?.join(", ") || item.AlbumArtist}
</Text>
</>
) : item.Type === "Playlist" ? (
<>
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
{item.Name}
</Text>
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
{item.ChildCount} tracks
</Text>
</>
) : item.Type === "Person" ? (
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
{item.Name}
</Text>
) : (
<>
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
{item.Name}
</Text>
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
{item.ProductionYear}
</Text>
</>
)}
</View>
);
};
interface TVSearchSectionProps extends ViewProps {
title: string;
items: BaseItemDto[];
orientation?: "horizontal" | "vertical";
disabled?: boolean;
isFirstSection?: boolean;
onItemPress: (item: BaseItemDto) => void;
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
}
export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
title,
items,
orientation = "vertical",
disabled = false,
isFirstSection = false,
onItemPress,
imageUrlGetter,
...props
}) => {
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
const [focusedCount, setFocusedCount] = useState(0);
const prevFocusedCount = useRef(0);
// When section loses all focus, scroll back to start
useEffect(() => {
if (prevFocusedCount.current > 0 && focusedCount === 0) {
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
}
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" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: itemWidth + ITEM_GAP,
offset: (itemWidth + ITEM_GAP) * index,
index,
}),
[itemWidth],
);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => {
const isFirstItem = isFirstSection && index === 0;
const isHorizontal = orientation === "horizontal";
const renderPoster = () => {
// Music Artist - circular avatar
if (item.Type === "MusicArtist") {
const imageUrl = imageUrlGetter?.(item);
return (
<View
style={{
width: 160,
height: 160,
borderRadius: 80,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#262626",
}}
>
<Text style={{ fontSize: 48 }}>👤</Text>
</View>
)}
</View>
);
}
// Music Album, 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 (
<View
style={{
width: TV_POSTER_WIDTH,
height: TV_POSTER_WIDTH,
borderRadius: 12,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#262626",
}}
>
<Text style={{ fontSize: 64 }}>{icon}</Text>
</View>
)}
</View>
);
}
// Person (Actor)
if (item.Type === "Person") {
return <MoviePoster item={item} />;
}
// Episode rendering
if (item.Type === "Episode" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Episode" && !isHorizontal) {
return <SeriesPoster item={item} />;
}
// Movie rendering
if (item.Type === "Movie" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Movie" && !isHorizontal) {
return <MoviePoster item={item} />;
}
// Series rendering
if (item.Type === "Series" && !isHorizontal) {
return <SeriesPoster item={item} />;
}
if (item.Type === "Series" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
// BoxSet (Collection)
if (item.Type === "BoxSet" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "BoxSet" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
// Default fallback
return isHorizontal ? (
<ContinueWatchingPoster item={item} />
) : (
<MoviePoster item={item} />
);
};
// Special width for music artists (circular)
const actualItemWidth = item.Type === "MusicArtist" ? 160 : itemWidth;
return (
<View style={{ marginRight: ITEM_GAP, width: actualItemWidth }}>
<TVFocusablePoster
onPress={() => onItemPress(item)}
hasTVPreferredFocus={isFirstItem && !disabled}
onFocus={handleItemFocus}
onBlur={handleItemBlur}
disabled={disabled}
>
{renderPoster()}
</TVFocusablePoster>
<TVItemCardText item={item} />
</View>
);
},
[
orientation,
isFirstSection,
itemWidth,
onItemPress,
handleItemFocus,
handleItemBlur,
disabled,
imageUrlGetter,
],
);
if (!items || items.length === 0) return null;
return (
<View style={{ overflow: "visible" }} {...props}>
{/* Section Header */}
<Text
style={{
fontSize: 22,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
>
{title}
</Text>
<FlatList
ref={flatListRef}
horizontal
data={items}
keyExtractor={(item) => item.Id!}
renderItem={renderItem}
showsHorizontalScrollIndicator={false}
initialNumToRender={5}
maxToRenderPerBatch={3}
windowSize={5}
removeClippedSubviews={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
}}
/>
</View>
);
};

View File

@@ -0,0 +1,141 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtomValue } from "jotai";
import React, { useMemo } from "react";
import { View } from "react-native";
import { ProgressBar } from "@/components/common/ProgressBar";
import { Text } from "@/components/common/Text";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { apiAtom } from "@/providers/JellyfinProvider";
import { runtimeTicksToMinutes } from "@/utils/time";
export const TV_EPISODE_WIDTH = 340;
interface TVEpisodeCardProps {
episode: BaseItemDto;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
onPress: () => void;
onFocus?: () => void;
onBlur?: () => void;
}
export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
episode,
hasTVPreferredFocus = false,
disabled = false,
onPress,
onFocus,
onBlur,
}) => {
const api = useAtomValue(apiAtom);
const thumbnailUrl = useMemo(() => {
if (!api) return null;
// Try to get episode primary image first
if (episode.ImageTags?.Primary) {
return `${api.basePath}/Items/${episode.Id}/Images/Primary?fillHeight=600&quality=80&tag=${episode.ImageTags.Primary}`;
}
// Fall back to series thumb or backdrop
if (episode.ParentBackdropItemId && episode.ParentThumbImageTag) {
return `${api.basePath}/Items/${episode.ParentBackdropItemId}/Images/Thumb?fillHeight=600&quality=80&tag=${episode.ParentThumbImageTag}`;
}
// Default episode image
return `${api.basePath}/Items/${episode.Id}/Images/Primary?fillHeight=600&quality=80`;
}, [api, episode]);
const duration = useMemo(() => {
if (!episode.RunTimeTicks) return null;
return runtimeTicksToMinutes(episode.RunTimeTicks);
}, [episode.RunTimeTicks]);
const episodeLabel = useMemo(() => {
const season = episode.ParentIndexNumber;
const ep = episode.IndexNumber;
if (season !== undefined && ep !== undefined) {
return `S${season}:E${ep}`;
}
return null;
}, [episode.ParentIndexNumber, episode.IndexNumber]);
return (
<View style={{ width: TV_EPISODE_WIDTH }}>
<TVFocusablePoster
onPress={onPress}
hasTVPreferredFocus={hasTVPreferredFocus}
disabled={disabled}
onFocus={onFocus}
onBlur={onBlur}
>
<View
style={{
width: TV_EPISODE_WIDTH,
aspectRatio: 16 / 9,
borderRadius: 12,
overflow: "hidden",
backgroundColor: "#1a1a1a",
borderWidth: 1,
borderColor: "#262626",
}}
>
{thumbnailUrl ? (
<Image
source={{ uri: thumbnailUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View
style={{
width: "100%",
height: "100%",
backgroundColor: "#262626",
}}
/>
)}
{!episode.UserData?.Played && <WatchedIndicator item={episode} />}
<ProgressBar item={episode} />
</View>
</TVFocusablePoster>
{/* Episode info below thumbnail */}
<View style={{ marginTop: 12, paddingHorizontal: 4 }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
{episodeLabel && (
<Text
style={{
fontSize: 14,
color: "#9CA3AF",
fontWeight: "500",
}}
>
{episodeLabel}
</Text>
)}
{duration && (
<>
<Text style={{ color: "#6B7280", fontSize: 14 }}></Text>
<Text style={{ fontSize: 14, color: "#9CA3AF" }}>{duration}</Text>
</>
)}
</View>
<Text
numberOfLines={2}
style={{
fontSize: 16,
color: "#FFFFFF",
marginTop: 4,
fontWeight: "500",
}}
>
{episode.Name}
</Text>
</View>
</View>
);
};

View File

@@ -0,0 +1,195 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { BlurView } from "expo-blur";
import React, { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Animated, Easing, Pressable, ScrollView, View } from "react-native";
import { Text } from "@/components/common/Text";
interface TVSeasonSelectorProps {
visible: boolean;
seasons: BaseItemDto[];
selectedSeasonIndex: number | string | null | undefined;
onSelect: (seasonIndex: number) => void;
onClose: () => void;
}
const TVSeasonCard: React.FC<{
season: BaseItemDto;
isSelected: boolean;
hasTVPreferredFocus?: boolean;
onPress: () => void;
}> = ({ season, isSelected, hasTVPreferredFocus, onPress }) => {
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 seasonName = useMemo(() => {
if (season.Name) return season.Name;
if (season.IndexNumber !== undefined) return `Season ${season.IndexNumber}`;
return "Season";
}, [season]);
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
width: 180,
height: 85,
backgroundColor: focused
? "#fff"
: isSelected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 16,
}}
>
<Text
style={{
fontSize: 18,
color: focused ? "#000" : "#fff",
fontWeight: focused || isSelected ? "600" : "400",
textAlign: "center",
}}
numberOfLines={2}
>
{seasonName}
</Text>
{isSelected && !focused && (
<View
style={{
position: "absolute",
top: 10,
right: 10,
}}
>
<Ionicons
name='checkmark'
size={18}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
};
export const TVSeasonSelector: React.FC<TVSeasonSelectorProps> = ({
visible,
seasons,
selectedSeasonIndex,
onSelect,
onClose,
}) => {
const { t } = useTranslation();
const initialFocusIndex = useMemo(() => {
const idx = seasons.findIndex(
(s) =>
s.IndexNumber === selectedSeasonIndex ||
s.Name === String(selectedSeasonIndex),
);
return idx >= 0 ? idx : 0;
}, [seasons, selectedSeasonIndex]);
if (!visible) return null;
return (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<View
style={{
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
}}
>
{/* Title */}
<Text
style={{
fontSize: 18,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
{t("item_card.select_season")}
</Text>
{/* Horizontal season cards */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
{seasons.map((season, index) => (
<TVSeasonCard
key={season.Id || index}
season={season}
isSelected={
season.IndexNumber === selectedSeasonIndex ||
season.Name === String(selectedSeasonIndex)
}
hasTVPreferredFocus={index === initialFocusIndex}
onPress={() => {
onSelect(season.IndexNumber ?? index);
onClose();
}}
/>
))}
</ScrollView>
</View>
</BlurView>
</View>
);
};

View File

@@ -0,0 +1,118 @@
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 } 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 { 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<TVSeriesHeaderProps> = ({ item }) => {
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 (
<View style={{ flex: 1, justifyContent: "center" }}>
{/* Logo or Title */}
{logoUrl ? (
<Image
source={{ uri: logoUrl }}
style={{
height: 100,
width: "80%",
marginBottom: 24,
}}
contentFit='contain'
contentPosition='left'
/>
) : (
<Text
style={{
fontSize: 52,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
}}
numberOfLines={2}
>
{item.Name}
</Text>
)}
{/* Metadata badges row */}
<View
style={{
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: 12,
marginBottom: 20,
}}
>
{yearString && (
<Text style={{ color: "#9CA3AF", fontSize: 18 }}>{yearString}</Text>
)}
{item.OfficialRating && (
<Badge text={item.OfficialRating} variant='gray' />
)}
{item.CommunityRating != null && (
<Badge
text={item.CommunityRating.toFixed(1)}
variant='gray'
iconLeft={<Ionicons name='star' size={16} color='gold' />}
/>
)}
</View>
{/* Genres */}
{item.Genres && item.Genres.length > 0 && (
<View style={{ marginBottom: 24 }}>
<GenreTags genres={item.Genres} />
</View>
)}
{/* Overview */}
{item.Overview && (
<Text
style={{
fontSize: 18,
color: "#D1D5DB",
lineHeight: 28,
maxWidth: SCREEN_WIDTH * 0.45,
}}
numberOfLines={4}
>
{item.Overview}
</Text>
)}
</View>
);
};

View File

@@ -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 } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Dimensions,
Easing,
FlatList,
Pressable,
ScrollView,
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 {
TV_EPISODE_WIDTH,
TVEpisodeCard,
} from "@/components/series/TVEpisodeCard";
import { TVSeasonSelector } from "@/components/series/TVSeasonSelector";
import { TVSeriesHeader } from "@/components/series/TVSeriesHeader";
import useRouter from "@/hooks/useAppRouter";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
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 ITEM_GAP = 16;
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";
}> = ({
onPress,
children,
hasTVPreferredFocus,
disabled = false,
variant = "primary",
}) => {
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 (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
{
transform: [{ scale }],
shadowColor: isPrimary ? "#fff" : "#a855f7",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
},
]}
>
<View
style={{
backgroundColor: focused
? isPrimary
? "#ffffff"
: "#7c3aed"
: isPrimary
? "rgba(255, 255, 255, 0.9)"
: "rgba(124, 58, 237, 0.8)",
borderRadius: 12,
paddingVertical: 18,
paddingHorizontal: 32,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
minWidth: 180,
}}
>
{children}
</View>
</Animated.View>
</Pressable>
);
};
// Season selector button
const TVSeasonButton: React.FC<{
seasonName: string;
onPress: () => void;
disabled?: boolean;
}> = ({ seasonName, onPress, 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: 120,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.02);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={{
transform: [{ scale }],
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.4 : 0,
shadowRadius: focused ? 12 : 0,
}}
>
<View
style={{
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
borderRadius: 10,
paddingVertical: 14,
paddingHorizontal: 20,
flexDirection: "row",
alignItems: "center",
gap: 8,
}}
>
<Text
style={{
fontSize: 16,
color: focused ? "#000" : "#FFFFFF",
fontWeight: "500",
}}
>
{seasonName}
</Text>
<Ionicons
name='chevron-down'
size={18}
color={focused ? "#000" : "#FFFFFF"}
/>
</View>
</Animated.View>
</Pressable>
);
};
export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
item,
allEpisodes = [],
isLoading: _isLoading,
}) => {
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();
// Season state
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
const selectedSeasonIndex = useMemo(
() => seasonIndexState[item.Id ?? ""] ?? 1,
[item.Id, seasonIndexState],
);
// Modal state
const [openModal, setOpenModal] = useState<"season" | null>(null);
const isModalOpen = openModal !== null;
// ScrollView ref for page scrolling
const mainScrollRef = useRef<ScrollView>(null);
// FlatList ref for scrolling back
const episodeListRef = useRef<FlatList<BaseItemDto>>(null);
const [focusedCount, setFocusedCount] = useState(0);
const prevFocusedCount = useRef(0);
// Scroll back to start when episode list loses focus
useEffect(() => {
if (prevFocusedCount.current > 0 && focusedCount === 0) {
episodeListRef.current?.scrollToOffset({ offset: 0, animated: true });
// Scroll page back to top when leaving episode section
mainScrollRef.current?.scrollTo({ y: 0, animated: true });
}
prevFocusedCount.current = focusedCount;
}, [focusedCount]);
const handleEpisodeFocus = useCallback(() => {
setFocusedCount((c) => {
// Scroll page down when first episode receives focus
if (c === 0) {
mainScrollRef.current?.scrollTo({ y: 200, animated: true });
}
return 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,
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 : 0,
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],
);
// Episode list item layout
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: TV_EPISODE_WIDTH + ITEM_GAP,
offset: (TV_EPISODE_WIDTH + ITEM_GAP) * index,
index,
}),
[],
);
// Render episode card
const renderEpisode = useCallback(
({ item: episode }: { item: BaseItemDto; index: number }) => (
<View style={{ marginRight: ITEM_GAP }}>
<TVEpisodeCard
episode={episode}
onPress={() => handleEpisodePress(episode)}
disabled={isModalOpen}
onFocus={handleEpisodeFocus}
onBlur={handleEpisodeBlur}
/>
</View>
),
[handleEpisodePress, isModalOpen, handleEpisodeFocus, handleEpisodeBlur],
);
// 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 (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
{/* Full-screen backdrop */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
<ItemImage
variant='Backdrop'
item={item}
style={{ width: "100%", height: "100%" }}
/>
{/* Gradient overlays for readability */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.5, 1]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: "70%",
}}
/>
<LinearGradient
colors={["rgba(0,0,0,0.8)", "transparent"]}
start={{ x: 0, y: 0 }}
end={{ x: 0.6, y: 0 }}
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: "60%",
}}
/>
</View>
{/* Main content */}
<ScrollView
ref={mainScrollRef}
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
paddingBottom: insets.bottom + 60,
}}
showsVerticalScrollIndicator={false}
>
{/* Top section - Poster + Content */}
<View
style={{
flexDirection: "row",
minHeight: SCREEN_HEIGHT * 0.45,
}}
>
{/* Left side - Poster */}
<View
style={{
width: SCREEN_WIDTH * POSTER_WIDTH_PERCENT,
marginRight: 50,
}}
>
<View
style={{
aspectRatio: 2 / 3,
borderRadius: 16,
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.5,
shadowRadius: 20,
}}
>
<ItemImage
variant='Primary'
item={item}
style={{ width: "100%", height: "100%" }}
/>
</View>
</View>
{/* Right side - Content */}
<View style={{ flex: 1, justifyContent: "center" }}>
<TVSeriesHeader item={item} />
{/* Action buttons */}
<View
style={{
flexDirection: "row",
gap: 16,
marginTop: 32,
}}
>
<TVFocusableButton
onPress={handlePlayNextEpisode}
hasTVPreferredFocus
disabled={isModalOpen}
variant='primary'
>
<Ionicons
name='play'
size={28}
color='#000000'
style={{ marginRight: 10 }}
/>
<Text
style={{
fontSize: 20,
fontWeight: "bold",
color: "#000000",
}}
>
{playButtonText}
</Text>
</TVFocusableButton>
{seasons.length > 1 && (
<TVSeasonButton
seasonName={selectedSeasonName}
onPress={() => setOpenModal("season")}
disabled={isModalOpen}
/>
)}
</View>
</View>
</View>
{/* Episodes section */}
<View style={{ marginTop: 40, overflow: "visible" }}>
<Text
style={{
fontSize: 22,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
>
{selectedSeasonName}
</Text>
<FlatList
ref={episodeListRef}
horizontal
data={episodesForSeason}
keyExtractor={(ep) => ep.Id!}
renderItem={renderEpisode}
showsHorizontalScrollIndicator={false}
initialNumToRender={5}
maxToRenderPerBatch={3}
windowSize={5}
removeClippedSubviews={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
}}
ListEmptyComponent={
<Text
style={{
color: "#737373",
fontSize: 16,
marginLeft: SCALE_PADDING,
}}
>
{t("item_card.no_episodes_for_this_season")}
</Text>
}
/>
</View>
</ScrollView>
{/* Season selector modal */}
<TVSeasonSelector
visible={openModal === "season"}
seasons={seasons}
selectedSeasonIndex={selectedSeasonIndex}
onSelect={handleSeasonSelect}
onClose={() => setOpenModal(null)}
/>
</View>
);
};

View File

@@ -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> = ({ ...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,
@@ -171,6 +177,44 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
/>
</ListItem>
</ListGroup>
{/* OpenSubtitles API Key for client-side subtitle fetching */}
<ListGroup
title={
t("home.settings.subtitles.opensubtitles_title") || "OpenSubtitles"
}
description={
<Text className='text-[#8E8D91] text-xs'>
{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."}
</Text>
}
>
<View className='p-4'>
<Text className='text-xs text-gray-400 mb-2'>
{t("home.settings.subtitles.opensubtitles_api_key") || "API Key"}
</Text>
<Input
className='border border-neutral-800'
placeholder={
t("home.settings.subtitles.opensubtitles_api_key_placeholder") ||
"Enter API key..."
}
value={openSubtitlesApiKey}
onChangeText={setOpenSubtitlesApiKey}
onBlur={() => {
updateSettings({ openSubtitlesApiKey });
}}
autoCapitalize='none'
autoCorrect={false}
secureTextEntry
/>
<Text className='text-xs text-gray-500 mt-2'>
{t("home.settings.subtitles.opensubtitles_get_key") ||
"Get your free API key at opensubtitles.com/en/consumers"}
</Text>
</View>
</ListGroup>
</View>
);
};

View File

@@ -12,7 +12,7 @@ type ICommonScreenOptions =
export const commonScreenOptions: ICommonScreenOptions = {
title: "",
headerShown: true,
headerShown: !Platform.isTV,
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerBlurEffect: "none",

104
components/tv/TVButton.tsx Normal file
View File

@@ -0,0 +1,104 @@
import React from "react";
import { Animated, Pressable, View, type ViewStyle } from "react-native";
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;
}
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 ? "#7c3aed" : "rgba(124, 58, 237, 0.8)",
shadowColor: "#a855f7",
borderWidth: 1,
borderColor: "transparent",
};
default:
return {
backgroundColor: focused ? "#ffffff" : "rgba(255, 255, 255, 0.9)",
shadowColor: "#fff",
borderWidth: 1,
borderColor: "transparent",
};
}
};
export const TVButton: React.FC<TVButtonProps> = ({
onPress,
children,
variant = "primary",
hasTVPreferredFocus = false,
disabled = false,
style,
scaleAmount = 1.05,
square = false,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount });
const buttonStyles = getButtonStyles(variant, focused);
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
shadowColor: buttonStyles.shadowColor,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.4 : 0,
shadowRadius: focused ? 15 : 0,
},
style,
]}
>
<View
style={{
backgroundColor: buttonStyles.backgroundColor,
borderWidth: buttonStyles.borderWidth,
borderColor: buttonStyles.borderColor,
borderRadius: 12,
paddingVertical: 18,
paddingHorizontal: square ? 18 : 32,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
minWidth: square ? undefined : 180,
}}
>
{children}
</View>
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,60 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVCancelButtonProps {
onPress: () => void;
label?: string;
disabled?: boolean;
}
export const TVCancelButton: React.FC<TVCancelButtonProps> = ({
onPress,
label = "Cancel",
disabled = false,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.15)",
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 10,
flexDirection: "row",
alignItems: "center",
gap: 8,
},
]}
>
<Ionicons
name='close'
size={20}
color={focused ? "#000" : "rgba(255,255,255,0.8)"}
/>
<Text
style={{
fontSize: 16,
color: focused ? "#000" : "rgba(255,255,255,0.8)",
fontWeight: "500",
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,73 @@
import React, { useRef, useState } from "react";
import { Animated, Easing, Pressable, type ViewStyle } from "react-native";
export interface TVFocusablePosterProps {
children: React.ReactNode;
onPress: () => void;
hasTVPreferredFocus?: boolean;
glowColor?: "white" | "purple";
scaleAmount?: number;
style?: ViewStyle;
onFocus?: () => void;
onBlur?: () => void;
disabled?: boolean;
}
export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({
children,
onPress,
hasTVPreferredFocus = false,
glowColor = "white",
scaleAmount = 1.05,
style,
onFocus: onFocusProp,
onBlur: onBlurProp,
disabled = false,
}) => {
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 (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(scaleAmount);
onFocusProp?.();
}}
onBlur={() => {
setFocused(false);
animateTo(1);
onBlurProp?.();
}}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
{
transform: [{ scale }],
shadowColor,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
},
style,
]}
>
{children}
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,102 @@
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 "./hooks/useTVFocusAnimation";
export interface TVOptionCardProps {
label: string;
sublabel?: string;
selected: boolean;
hasTVPreferredFocus?: boolean;
onPress: () => void;
width?: number;
height?: number;
}
export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
(
{
label,
sublabel,
selected,
hasTVPreferredFocus = false,
onPress,
width = 160,
height = 75,
},
ref,
) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
animatedStyle,
{
width,
height,
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
},
]}
>
<Text
style={{
fontSize: 16,
color: focused ? "#000" : "#fff",
fontWeight: focused || selected ? "600" : "400",
textAlign: "center",
}}
numberOfLines={2}
>
{label}
</Text>
{sublabel && (
<Text
style={{
fontSize: 12,
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)",
textAlign: "center",
marginTop: 2,
}}
numberOfLines={1}
>
{sublabel}
</Text>
)}
{selected && !focused && (
<View
style={{
position: "absolute",
top: 8,
right: 8,
}}
>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
},
);

View File

@@ -0,0 +1,199 @@
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 { TVCancelButton } from "./TVCancelButton";
import { TVOptionCard } from "./TVOptionCard";
export type TVOptionItem<T> = {
label: string;
sublabel?: string;
value: T;
selected: boolean;
};
export interface TVOptionSelectorProps<T> {
visible: boolean;
title: string;
options: TVOptionItem<T>[];
onSelect: (value: T) => void;
onClose: () => void;
cancelLabel?: string;
cardWidth?: number;
cardHeight?: number;
}
export const TVOptionSelector = <T,>({
visible,
title,
options,
onSelect,
onClose,
cancelLabel = "Cancel",
cardWidth = 160,
cardHeight = 75,
}: TVOptionSelectorProps<T>) => {
const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(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]);
if (!visible) return null;
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
<Text style={styles.title}>{title}</Text>
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
ref={
index === initialSelectedIndex ? firstCardRef : undefined
}
label={option.label}
sublabel={option.sublabel}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
width={cardWidth}
height={cardHeight}
/>
))}
</ScrollView>
)}
{isReady && (
<View style={styles.cancelButtonContainer}>
<TVCancelButton onPress={onClose} label={cancelLabel} />
</View>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
};
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: 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: 16,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
},
scrollView: {
overflow: "visible",
},
scrollContent: {
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
},
cancelButtonContainer: {
marginTop: 16,
paddingHorizontal: 48,
alignItems: "flex-start",
},
});

View File

@@ -0,0 +1,68 @@
import React from "react";
import { Animated, Pressable } from "react-native";
import { Text } from "@/components/common/Text";
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<TVTabButtonProps> = ({
label,
active,
onSelect,
hasTVPreferredFocus = false,
switchOnFocus = false,
disabled = false,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.05,
duration: 120,
onFocus: switchOnFocus ? onSelect : undefined,
});
return (
<Pressable
onPress={onSelect}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: focused
? "#fff"
: active
? "rgba(255,255,255,0.2)"
: "transparent",
borderBottomColor: active ? "#fff" : "transparent",
borderBottomWidth: 2,
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 8,
},
]}
>
<Text
style={{
fontSize: 16,
color: focused ? "#000" : "#fff",
fontWeight: focused || active ? "600" : "400",
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,61 @@
import { useCallback, useRef, useState } from "react";
import { Animated, Easing } from "react-native";
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 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);
onFocus?.();
}, [animateTo, scaleAmount, onFocus]);
const handleBlur = useCallback(() => {
setFocused(false);
animateTo(1);
onBlur?.();
}, [animateTo, onBlur]);
const animatedStyle = { transform: [{ scale }] };
return {
focused,
scale,
handleFocus,
handleBlur,
animatedStyle,
};
};

17
components/tv/index.ts Normal file
View File

@@ -0,0 +1,17 @@
export type {
UseTVFocusAnimationOptions,
UseTVFocusAnimationReturn,
} from "./hooks/useTVFocusAnimation";
export { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export type { TVButtonProps } from "./TVButton";
export { TVButton } from "./TVButton";
export type { TVCancelButtonProps } from "./TVCancelButton";
export { TVCancelButton } from "./TVCancelButton";
export type { TVFocusablePosterProps } from "./TVFocusablePoster";
export { TVFocusablePoster } from "./TVFocusablePoster";
export type { TVOptionCardProps } from "./TVOptionCard";
export { TVOptionCard } from "./TVOptionCard";
export type { TVOptionItem, TVOptionSelectorProps } from "./TVOptionSelector";
export { TVOptionSelector } from "./TVOptionSelector";
export type { TVTabButtonProps } from "./TVTabButton";
export { TVTabButton } from "./TVTabButton";

View File

@@ -21,6 +21,7 @@ import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay";
import type { TechnicalInfo } from "@/modules/mpv-player";
import { DownloadedItem } from "@/providers/Downloads/types";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
@@ -36,6 +37,7 @@ import { useRemoteControl } from "./hooks/useRemoteControl";
import { useVideoNavigation } from "./hooks/useVideoNavigation";
import { useVideoSlider } from "./hooks/useVideoSlider";
import { useVideoTime } from "./hooks/useVideoTime";
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
import { useControlsTimeout } from "./useControlsTimeout";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
import { type AspectRatio } from "./VideoScalingModeSelector";
@@ -64,6 +66,12 @@ interface Props {
// Playback speed props
playbackSpeed?: number;
setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
// Technical info props
showTechnicalInfo?: boolean;
onToggleTechnicalInfo?: () => void;
getTechnicalInfo?: () => Promise<TechnicalInfo>;
playMethod?: "DirectPlay" | "DirectStream" | "Transcode";
transcodeReasons?: string[];
}
export const Controls: FC<Props> = ({
@@ -88,6 +96,11 @@ export const Controls: FC<Props> = ({
downloadedFiles = undefined,
playbackSpeed = 1.0,
setPlaybackSpeed,
showTechnicalInfo = false,
onToggleTechnicalInfo,
getTechnicalInfo,
playMethod,
transcodeReasons,
}) => {
const offline = useOfflineMode();
const { settings, updateSettings } = useSettings();
@@ -460,6 +473,16 @@ export const Controls: FC<Props> = ({
onSkipForward={handleSkipForward}
onSkipBackward={handleSkipBackward}
/>
{/* Technical Info Overlay - rendered outside animated views to stay visible */}
{getTechnicalInfo && (
<TechnicalInfoOverlay
showControls={showControls}
visible={showTechnicalInfo}
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
/>
)}
<Animated.View
style={headerAnimatedStyle}
pointerEvents={showControls ? "auto" : "none"}
@@ -480,6 +503,8 @@ export const Controls: FC<Props> = ({
onZoomToggle={onZoomToggle}
playbackSpeed={playbackSpeed}
setPlaybackSpeed={setPlaybackSpeed}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={onToggleTechnicalInfo}
/>
</Animated.View>
<Animated.View

File diff suppressed because it is too large Load Diff

View File

@@ -34,6 +34,9 @@ interface HeaderControlsProps {
// Playback speed props
playbackSpeed?: number;
setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
// Technical info props
showTechnicalInfo?: boolean;
onToggleTechnicalInfo?: () => void;
}
export const HeaderControls: FC<HeaderControlsProps> = ({
@@ -52,6 +55,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
onZoomToggle,
playbackSpeed = 1.0,
setPlaybackSpeed,
showTechnicalInfo = false,
onToggleTechnicalInfo,
}) => {
const { settings } = useSettings();
const router = useRouter();
@@ -110,6 +115,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
<DropdownView
playbackSpeed={playbackSpeed}
setPlaybackSpeed={setPlaybackSpeed}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={onToggleTechnicalInfo}
/>
</View>
)}

View File

@@ -0,0 +1,984 @@
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,
Pressable,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import {
TVCancelButton,
TVTabButton,
useTVFocusAnimation,
} 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";
// 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 (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
styles.trackCard,
animatedStyle,
{
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
},
]}
>
<Text
style={[
styles.trackCardText,
{ color: focused ? "#000" : "#fff" },
(focused || selected) && { fontWeight: "600" },
]}
numberOfLines={2}
>
{label}
</Text>
{sublabel && (
<Text
style={[
styles.trackCardSublabel,
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
]}
numberOfLines={1}
>
{sublabel}
</Text>
)}
{selected && !focused && (
<View style={styles.checkmark}>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
});
// 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 (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
styles.languageCard,
animatedStyle,
{
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
},
]}
>
<Text
style={[
styles.languageCardText,
{ color: focused ? "#000" : "#fff" },
(focused || selected) && { fontWeight: "600" },
]}
numberOfLines={1}
>
{name}
</Text>
<Text
style={[
styles.languageCardCode,
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
]}
>
{code.toUpperCase()}
</Text>
{selected && !focused && (
<View style={styles.checkmark}>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
});
// 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 (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
disabled={isDownloading}
>
<Animated.View
style={[
styles.resultCard,
animatedStyle,
{
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.08)",
borderColor: focused
? "rgba(255,255,255,0.8)"
: "rgba(255,255,255,0.1)",
},
]}
>
{/* Provider/Source badge */}
<View
style={[
styles.providerBadge,
{
backgroundColor: focused
? "rgba(0,0,0,0.1)"
: "rgba(255,255,255,0.1)",
},
]}
>
<Text
style={[
styles.providerText,
{ color: focused ? "rgba(0,0,0,0.7)" : "rgba(255,255,255,0.7)" },
]}
>
{result.providerName}
</Text>
</View>
{/* Name */}
<Text
style={[styles.resultName, { color: focused ? "#000" : "#fff" }]}
numberOfLines={2}
>
{result.name}
</Text>
{/* Meta info row */}
<View style={styles.resultMeta}>
{/* Format */}
<Text
style={[
styles.resultMetaText,
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
]}
>
{result.format?.toUpperCase()}
</Text>
{/* Rating if available */}
{result.communityRating !== undefined &&
result.communityRating > 0 && (
<View style={styles.ratingContainer}>
<Ionicons
name='star'
size={12}
color={focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)"}
/>
<Text
style={[
styles.resultMetaText,
{
color: focused
? "rgba(0,0,0,0.6)"
: "rgba(255,255,255,0.5)",
},
]}
>
{result.communityRating.toFixed(1)}
</Text>
</View>
)}
{/* Download count if available */}
{result.downloadCount !== undefined && result.downloadCount > 0 && (
<View style={styles.downloadCountContainer}>
<Ionicons
name='download-outline'
size={12}
color={focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)"}
/>
<Text
style={[
styles.resultMetaText,
{
color: focused
? "rgba(0,0,0,0.6)"
: "rgba(255,255,255,0.5)",
},
]}
>
{result.downloadCount.toLocaleString()}
</Text>
</View>
)}
</View>
{/* Flags */}
<View style={styles.flagsContainer}>
{result.isHashMatch && (
<View
style={[
styles.flag,
{
backgroundColor: focused
? "rgba(0,150,0,0.2)"
: "rgba(0,200,0,0.2)",
},
]}
>
<Text style={styles.flagText}>Hash Match</Text>
</View>
)}
{result.hearingImpaired && (
<View
style={[
styles.flag,
{
backgroundColor: focused
? "rgba(0,0,0,0.1)"
: "rgba(255,255,255,0.1)",
},
]}
>
<Ionicons
name='ear-outline'
size={12}
color={focused ? "#000" : "#fff"}
/>
</View>
)}
{result.aiTranslated && (
<View
style={[
styles.flag,
{
backgroundColor: focused
? "rgba(0,0,150,0.2)"
: "rgba(100,100,255,0.2)",
},
]}
>
<Text style={styles.flagText}>AI</Text>
</View>
)}
</View>
{/* Loading indicator when downloading */}
{isDownloading && (
<View style={styles.downloadingOverlay}>
<ActivityIndicator size='small' color='#fff' />
</View>
)}
</Animated.View>
</Pressable>
);
});
export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
visible,
item,
mediaSourceId,
subtitleTracks,
currentSubtitleIndex,
onSubtitleIndexChange,
onClose,
onServerSubtitleDownloaded,
onLocalSubtitleDownloaded,
}) => {
const { t } = useTranslation();
console.log(
"[TVSubtitleSheet] visible:",
visible,
"tracks:",
subtitleTracks.length,
);
const [activeTab, setActiveTab] = useState<TabType>("tracks");
const [selectedLanguage, setSelectedLanguage] = useState("eng");
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const [hasSearchedThisSession, setHasSearchedThisSession] = useState(false);
const [isReady, setIsReady] = useState(false);
const [isTabContentReady, setIsTabContentReady] = useState(false);
const firstTrackRef = useRef<View>(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 (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={90} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
{/* Header with tabs */}
<View style={styles.header}>
<Text style={styles.title}>
{t("item_card.subtitles.label") || "Subtitles"}
</Text>
{/* Tab bar */}
<View style={styles.tabRow}>
<TVTabButton
label={t("item_card.subtitles.tracks") || "Tracks"}
active={activeTab === "tracks"}
onSelect={() => setActiveTab("tracks")}
/>
<TVTabButton
label={t("player.download") || "Download"}
active={activeTab === "download"}
onSelect={() => setActiveTab("download")}
/>
</View>
</View>
{/* Tracks Tab Content */}
{activeTab === "tracks" && isTabContentReady && (
<View style={styles.section}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.tracksScroll}
contentContainerStyle={styles.tracksScrollContent}
>
{trackOptions.map((option, index) => (
<TVTrackCard
key={option.value}
ref={
index === initialSelectedTrackIndex
? firstTrackRef
: undefined
}
label={option.label}
sublabel={option.sublabel}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedTrackIndex}
onPress={() => handleTrackSelect(option.value)}
/>
))}
</ScrollView>
</View>
)}
{/* Download Tab Content */}
{activeTab === "download" && isTabContentReady && (
<>
{/* Language Selector */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{t("player.language") || "Language"}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.languageScroll}
contentContainerStyle={styles.languageScrollContent}
>
{displayLanguages.map((lang, index) => (
<LanguageCard
key={lang.code}
code={lang.code}
name={lang.name}
selected={selectedLanguage === lang.code}
hasTVPreferredFocus={
index === 0 &&
(!searchResults || searchResults.length === 0)
}
onPress={() => handleLanguageSelect(lang.code)}
/>
))}
</ScrollView>
</View>
{/* Results Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{t("player.results") || "Results"}
{searchResults && ` (${searchResults.length})`}
</Text>
{/* Loading state */}
{isSearching && (
<View style={styles.loadingContainer}>
<ActivityIndicator size='large' color='#fff' />
<Text style={styles.loadingText}>
{t("player.searching") || "Searching..."}
</Text>
</View>
)}
{/* Error state */}
{searchError && !isSearching && (
<View style={styles.errorContainer}>
<Ionicons
name='alert-circle-outline'
size={32}
color='rgba(255,100,100,0.8)'
/>
<Text style={styles.errorText}>
{t("player.search_failed") || "Search failed"}
</Text>
<Text style={styles.errorHint}>
{!hasOpenSubtitlesApiKey
? t("player.no_subtitle_provider") ||
"No subtitle provider configured on server"
: String(searchError)}
</Text>
</View>
)}
{/* No results */}
{searchResults &&
searchResults.length === 0 &&
!isSearching &&
!searchError && (
<View style={styles.emptyContainer}>
<Ionicons
name='document-text-outline'
size={32}
color='rgba(255,255,255,0.4)'
/>
<Text style={styles.emptyText}>
{t("player.no_subtitles_found") ||
"No subtitles found"}
</Text>
</View>
)}
{/* Results list */}
{searchResults &&
searchResults.length > 0 &&
!isSearching && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.resultsScroll}
contentContainerStyle={styles.resultsScrollContent}
>
{searchResults.map((result, index) => (
<SubtitleResultCard
key={result.id}
result={result}
hasTVPreferredFocus={index === 0}
isDownloading={downloadingId === result.id}
onPress={() => handleDownload(result)}
/>
))}
</ScrollView>
)}
</View>
{/* API Key hint if no fallback available */}
{!hasOpenSubtitlesApiKey && (
<View style={styles.apiKeyHint}>
<Ionicons
name='information-circle-outline'
size={16}
color='rgba(255,255,255,0.4)'
/>
<Text style={styles.apiKeyHintText}>
{t("player.add_opensubtitles_key_hint") ||
"Add OpenSubtitles API key in settings for client-side fallback"}
</Text>
</View>
)}
</>
)}
{/* Cancel button */}
{isReady && (
<View style={styles.cancelButtonContainer}>
<TVCancelButton
onPress={onClose}
label={t("common.cancel") || "Cancel"}
/>
</View>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
};
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,
},
trackCard: {
width: 180,
height: 80,
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
},
trackCardText: {
fontSize: 16,
textAlign: "center",
},
trackCardSublabel: {
fontSize: 12,
marginTop: 2,
},
checkmark: {
position: "absolute",
top: 8,
right: 8,
},
languageScroll: {
overflow: "visible",
},
languageScrollContent: {
paddingHorizontal: 48,
paddingVertical: 8,
gap: 10,
},
languageCard: {
width: 120,
height: 60,
borderRadius: 12,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
},
languageCardText: {
fontSize: 15,
fontWeight: "500",
},
languageCardCode: {
fontSize: 11,
marginTop: 2,
},
resultsScroll: {
overflow: "visible",
},
resultsScrollContent: {
paddingHorizontal: 48,
paddingVertical: 8,
gap: 12,
},
resultCard: {
width: 220,
minHeight: 120,
borderRadius: 14,
padding: 14,
borderWidth: 1,
},
providerBadge: {
alignSelf: "flex-start",
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 6,
marginBottom: 8,
},
providerText: {
fontSize: 11,
fontWeight: "600",
textTransform: "uppercase",
letterSpacing: 0.5,
},
resultName: {
fontSize: 14,
fontWeight: "500",
marginBottom: 8,
lineHeight: 18,
},
resultMeta: {
flexDirection: "row",
alignItems: "center",
gap: 12,
marginBottom: 8,
},
resultMetaText: {
fontSize: 12,
},
ratingContainer: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
downloadCountContainer: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
flagsContainer: {
flexDirection: "row",
gap: 6,
flexWrap: "wrap",
},
flag: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
flagText: {
fontSize: 10,
fontWeight: "600",
color: "#fff",
},
downloadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.5)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
},
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",
},
});

View File

@@ -0,0 +1,283 @@
import { type FC, memo, useCallback, useEffect, useState } from "react";
import { Platform, StyleSheet, Text, View } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { TechnicalInfo } from "@/modules/mpv-player";
import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT } from "./constants";
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
interface TechnicalInfoOverlayProps {
showControls: boolean;
visible: boolean;
getTechnicalInfo: () => Promise<TechnicalInfo>;
playMethod?: PlayMethod;
transcodeReasons?: string[];
}
const formatBitrate = (bitsPerSecond: number): string => {
const mbps = bitsPerSecond / 1_000_000;
if (mbps >= 1) {
return `${mbps.toFixed(1)} Mbps`;
}
const kbps = bitsPerSecond / 1_000;
return `${kbps.toFixed(0)} Kbps`;
};
const formatCodec = (codec: string): string => {
// Normalize common codec names
const codecMap: Record<string, string> = {
h264: "H.264",
hevc: "HEVC",
h265: "HEVC",
vp9: "VP9",
vp8: "VP8",
av1: "AV1",
aac: "AAC",
ac3: "AC3",
eac3: "E-AC3",
dts: "DTS",
truehd: "TrueHD",
flac: "FLAC",
opus: "Opus",
mp3: "MP3",
};
return codecMap[codec.toLowerCase()] || codec.toUpperCase();
};
const formatFps = (fps: number): string => {
// Common frame rates
if (Math.abs(fps - 23.976) < 0.01) return "23.976";
if (Math.abs(fps - 29.97) < 0.01) return "29.97";
if (Math.abs(fps - 59.94) < 0.01) return "59.94";
if (Number.isInteger(fps)) return fps.toString();
return fps.toFixed(2);
};
const getPlayMethodLabel = (method: PlayMethod): string => {
switch (method) {
case "DirectPlay":
return "Direct Play";
case "DirectStream":
return "Direct Stream";
case "Transcode":
return "Transcoding";
default:
return method;
}
};
const getPlayMethodColor = (method: PlayMethod): string => {
switch (method) {
case "DirectPlay":
return "#4ade80"; // green
case "DirectStream":
return "#60a5fa"; // blue
case "Transcode":
return "#fbbf24"; // yellow/amber
default:
return "white";
}
};
const formatTranscodeReason = (reason: string): string => {
// Convert camelCase/PascalCase to readable format
const reasonMap: Record<string, string> = {
ContainerNotSupported: "Container not supported",
VideoCodecNotSupported: "Video codec not supported",
AudioCodecNotSupported: "Audio codec not supported",
SubtitleCodecNotSupported: "Subtitle codec not supported",
AudioIsExternal: "Audio is external",
SecondaryAudioNotSupported: "Secondary audio not supported",
VideoProfileNotSupported: "Video profile not supported",
VideoLevelNotSupported: "Video level not supported",
VideoResolutionNotSupported: "Resolution not supported",
VideoBitDepthNotSupported: "Bit depth not supported",
VideoFramerateNotSupported: "Framerate not supported",
RefFramesNotSupported: "Ref frames not supported",
AnamorphicVideoNotSupported: "Anamorphic video not supported",
InterlacedVideoNotSupported: "Interlaced video not supported",
AudioChannelsNotSupported: "Audio channels not supported",
AudioProfileNotSupported: "Audio profile not supported",
AudioSampleRateNotSupported: "Sample rate not supported",
AudioBitDepthNotSupported: "Audio bit depth not supported",
ContainerBitrateExceedsLimit: "Bitrate exceeds limit",
VideoBitrateNotSupported: "Video bitrate not supported",
AudioBitrateNotSupported: "Audio bitrate not supported",
UnknownVideoStreamInfo: "Unknown video stream",
UnknownAudioStreamInfo: "Unknown audio stream",
DirectPlayError: "Direct play error",
VideoRangeTypeNotSupported: "HDR not supported",
VideoCodecTagNotSupported: "Video codec tag not supported",
};
return reasonMap[reason] || reason;
};
export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
({
showControls,
visible,
getTechnicalInfo,
playMethod,
transcodeReasons,
}) => {
const { settings } = useSettings();
const insets = useSafeAreaInsets();
const [info, setInfo] = useState<TechnicalInfo | null>(null);
const opacity = useSharedValue(0);
// Animate visibility based on visible prop only (stays visible regardless of controls)
useEffect(() => {
opacity.value = withTiming(visible ? 1 : 0, {
duration: 300,
easing: Easing.out(Easing.quad),
});
}, [visible, opacity]);
// Fetch technical info periodically when visible
const fetchInfo = useCallback(async () => {
try {
const data = await getTechnicalInfo();
setInfo(data);
} catch (_error) {
// Silently fail - the info is optional
}
}, [getTechnicalInfo]);
useEffect(() => {
if (!visible) {
return;
}
// Fetch immediately
fetchInfo();
// Then fetch every 2 seconds
const interval = setInterval(fetchInfo, 2000);
return () => clearInterval(interval);
}, [visible, fetchInfo]);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
// Hide on TV platforms
if (Platform.isTV) return null;
// Don't render if not visible
if (!visible) return null;
return (
<Animated.View
style={[
styles.container,
animatedStyle,
{
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,
},
]}
pointerEvents='none'
>
<View style={styles.infoBox}>
{playMethod && (
<Text
style={[
styles.infoText,
{ color: getPlayMethodColor(playMethod) },
]}
>
{getPlayMethodLabel(playMethod)}
</Text>
)}
{transcodeReasons && transcodeReasons.length > 0 && (
<Text style={[styles.infoText, styles.reasonText]}>
{transcodeReasons.map(formatTranscodeReason).join(", ")}
</Text>
)}
{info?.videoWidth && info?.videoHeight && (
<Text style={styles.infoText}>
{info.videoWidth}x{info.videoHeight}
</Text>
)}
{info?.videoCodec && (
<Text style={styles.infoText}>
Video: {formatCodec(info.videoCodec)}
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
</Text>
)}
{info?.audioCodec && (
<Text style={styles.infoText}>
Audio: {formatCodec(info.audioCodec)}
</Text>
)}
{(info?.videoBitrate || info?.audioBitrate) && (
<Text style={styles.infoText}>
Bitrate:{" "}
{info.videoBitrate
? formatBitrate(info.videoBitrate)
: info.audioBitrate
? formatBitrate(info.audioBitrate)
: "N/A"}
</Text>
)}
{info?.cacheSeconds !== undefined && (
<Text style={styles.infoText}>
Buffer: {info.cacheSeconds.toFixed(1)}s
</Text>
)}
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
<Text style={[styles.infoText, styles.warningText]}>
Dropped: {info.droppedFrames} frames
</Text>
)}
{!info && !playMethod && (
<Text style={styles.infoText}>Loading...</Text>
)}
</View>
</Animated.View>
);
},
);
TechnicalInfoOverlay.displayName = "TechnicalInfoOverlay";
const styles = StyleSheet.create({
container: {
position: "absolute",
zIndex: 15,
},
infoBox: {
backgroundColor: "rgba(0, 0, 0, 0.5)",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 8,
minWidth: 150,
},
infoText: {
color: "white",
fontSize: 12,
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
lineHeight: 18,
},
warningText: {
color: "#ff9800",
},
reasonText: {
color: "#fbbf24",
fontSize: 10,
},
});

View File

@@ -1,6 +1,6 @@
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

View File

@@ -30,11 +30,15 @@ const SUBTITLE_SIZE_PRESETS = [
interface DropdownViewProps {
playbackSpeed?: number;
setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
showTechnicalInfo?: boolean;
onToggleTechnicalInfo?: () => void;
}
const DropdownView = ({
playbackSpeed = 1.0,
setPlaybackSpeed,
showTechnicalInfo = false,
onToggleTechnicalInfo,
}: DropdownViewProps) => {
const { subtitleTracks, audioTracks } = useVideoContext();
const { item, mediaSource } = usePlayerContext();
@@ -161,6 +165,21 @@ const DropdownView = ({
});
}
// Technical Info (at bottom)
if (onToggleTechnicalInfo) {
groups.push({
options: [
{
type: "action" as const,
label: showTechnicalInfo
? "Hide Technical Info"
: "Show Technical Info",
onPress: onToggleTechnicalInfo,
},
],
});
}
return groups;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
@@ -175,6 +194,8 @@ const DropdownView = ({
updateSettings,
playbackSpeed,
setPlaybackSpeed,
showTechnicalInfo,
onToggleTechnicalInfo,
// Note: subtitleTracks and audioTracks are intentionally excluded
// because we use subtitleTracksKey and audioTracksKey for stability
]);

View File

@@ -1,8 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useState } from "react";
import { Platform } 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;
@@ -19,165 +17,76 @@ if (Platform.isTV) {
}
interface UseRemoteControlProps {
progress: SharedValue<number>;
min: SharedValue<number>;
max: SharedValue<number>;
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;
// Legacy props - kept for backwards compatibility with mobile Controls.tsx
// These are ignored in the simplified implementation
progress?: SharedValue<number>;
min?: SharedValue<number>;
max?: SharedValue<number>;
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,
togglePlay,
onBack,
}: UseRemoteControlProps) {
// Keep these for backward compatibility with the component
const remoteScrubProgress = useSharedValue<number | null>(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 longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
// MPV uses ms
const SCRUB_INTERVAL = CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS;
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 });
}, []);
const [showRemoteBubble] = useState(false);
const [isSliding] = useState(false);
const [time] = useState({ hours: 0, minutes: 0, seconds: 0 });
// 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;
// Handle back/menu button press (tvOS: menu, Android TV: back)
if (evt.eventType === "menu" || evt.eventType === "back") {
if (onBack) {
onBack();
}
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;
return;
}
if (!showControls) toggleControls();
// Handle play/pause button press on TV remote
if (evt.eventType === "playPause") {
if (togglePlay) {
togglePlay();
}
return;
}
// Show controls on any D-pad press
if (!showControls) {
toggleControls();
}
});
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,
};

220
docs/research/hdr-mpv.md Normal file
View File

@@ -0,0 +1,220 @@
# 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.
## Current Implementation
Streamyfin uses MPVKit with the `vo_avfoundation` video output driver, which renders video to `AVSampleBufferDisplayLayer`. This enables Picture-in-Picture (PiP) support but has HDR limitations.
**Current code in `MpvPlayerView.swift`:**
```swift
#if !os(tvOS)
if #available(iOS 17.0, *) {
displayLayer.wantsExtendedDynamicRangeContent = true
}
#endif
```
The tvOS exclusion was intentional because `wantsExtendedDynamicRangeContent` was believed to be iOS-only, but this may not be accurate for tvOS 17.0+.
---
## Research Findings
### 1. This is a Known Industry-Wide Limitation
The same HDR issue exists in multiple projects:
| Project | Player | HDR Status |
|---------|--------|------------|
| [Swiftfin](https://github.com/jellyfin/Swiftfin/issues/811) | VLCKit | Washed out, no HDR signal |
| [Plex](https://freetime.mikeconnelly.com/archives/8360) | mpv (Enhanced Player) | No HDR support |
| Infuse | Custom Metal engine | Works correctly |
**Key quote from mpv maintainer** ([issue #9633](https://github.com/mpv-player/mpv/issues/9633)):
> "mpv doesn't support metal at all (likely won't ever)"
### 2. Why Infuse Works
Infuse uses a **custom Metal-based video rendering engine** built from scratch - not mpv, not VLCKit, not AVPlayer. This allows them to properly handle HDR passthrough with the correct pixel formats and color spaces.
Source: [Firecore Community](https://community.firecore.com/t/what-player-does-infuse-use/38003)
### 3. Swiftfin's Solution
Swiftfin offers two players:
- **VLCKit player** (default) - No HDR support
- **Native player (AVPlayer)** - HDR works correctly
When using the native player with proper stream configuration (HEVC `hvc1`, fMP4-HLS), Apple TV correctly switches to HDR mode.
Source: [Swiftfin Issue #331](https://github.com/jellyfin/Swiftfin/issues/331)
### 4. Apple's HDR Requirements
According to [WWDC22: Display HDR video in EDR with AVFoundation and Metal](https://developer.apple.com/videos/play/wwdc2022/110565/):
**Required for EDR (Extended Dynamic Range):**
```swift
// On CAMetalLayer
layer.wantsExtendedDynamicRangeContent = true
layer.pixelFormat = MTLPixelFormatRGBA16Float
layer.colorspace = kCGColorSpaceExtendedLinearDisplayP3
```
**For AVPlayerItemVideoOutput:**
```swift
let videoColorProperties = [
AVVideoColorPrimariesKey: AVVideoColorPrimaries_P3_D65,
AVVideoTransferFunctionKey: AVVideoTransferFunction_Linear,
AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_2020
]
```
### 5. CVPixelBuffer HDR Metadata
For pixel buffers to be recognized as HDR, they need colorspace attachments:
```c
CVBufferSetAttachment(pixelBuffer, kCVImageBufferColorPrimariesKey,
kCVImageBufferColorPrimaries_ITU_R_2020,
kCVAttachmentMode_ShouldPropagate);
CVBufferSetAttachment(pixelBuffer, kCVImageBufferTransferFunctionKey,
kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ, // HDR10
kCVAttachmentMode_ShouldPropagate);
CVBufferSetAttachment(pixelBuffer, kCVImageBufferYCbCrMatrixKey,
kCVImageBufferYCbCrMatrix_ITU_R_2020,
kCVAttachmentMode_ShouldPropagate);
```
### 6. macOS EDR Research
From [mpv issue #7341](https://github.com/mpv-player/mpv/issues/7341), testing showed:
- **mpv playback**: `maximumExtendedDynamicRangeColorComponentValue: 1.0`
- **QuickTime playback**: `maximumExtendedDynamicRangeColorComponentValue: 2.0`
QuickTime uses `CAOpenGLLayer.wantsExtendedDynamicRangeContent` to enable EDR.
### 7. iOS/tvOS OpenGL Limitations
From [mpv issue #8467](https://github.com/mpv-player/mpv/issues/8467):
> "Disabling HDR peak computation (one or more of the following is not supported: compute shaders=0, SSBO=0)"
Apple's OpenGL implementation lacks compute shader and SSBO support, making traditional HDR peak computation impossible.
---
## Potential Solutions
### ~~Option A: Enable EDR on tvOS (Quick Test)~~ - RULED OUT
**Status:** Tested and failed - `wantsExtendedDynamicRangeContent` is **not available on tvOS**.
The API is iOS-only. Attempting to use it on tvOS results in:
> 'wantsExtendedDynamicRangeContent' is unavailable in tvOS
This confirms tvOS requires a different approach for HDR.
### Option B: Modify vo_avfoundation in MPVKit
Add HDR colorspace metadata when creating CVPixelBuffers.
**Location:** `streamyfin/MPVKit` fork, vo_avfoundation driver
**Required changes:**
1. Detect HDR content (check video color primaries/transfer function)
2. Attach colorspace metadata to pixel buffers
3. Possibly configure AVSampleBufferDisplayLayer for HDR
**Pros:** Fixes the root cause
**Cons:** Requires modifying and rebuilding MPVKit
### Option C: Dual Player Approach (Like Swiftfin)
Implement AVPlayer-based playback for HDR content, keep mpv for everything else.
**Pros:** Proven solution, full HDR/DV support
**Cons:** Significant development effort, two player implementations to maintain
### Option D: Accept Limitation
Document that HDR passthrough is not supported with the mpv player on tvOS.
**Pros:** No development work
**Cons:** Poor user experience for HDR content
---
## Recommended Approach
1. **First:** Try Option A (enable EDR on tvOS) - simple test
2. **If that fails:** Investigate Option B (modify vo_avfoundation in MPVKit)
3. **Long-term:** Consider Option C (dual player) for full HDR support
---
## 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/)
- [WWDC20: Edit and play back HDR video](https://developer.apple.com/videos/play/wwdc2020/10009/)
### Related Projects
- [Swiftfin HDR Issues](https://github.com/jellyfin/Swiftfin/issues/811)
- [Swiftfin Match Content](https://github.com/jellyfin/Swiftfin/issues/331)
- [mpv HDR on iOS](https://github.com/mpv-player/mpv/issues/9633)
- [mpv macOS EDR](https://github.com/mpv-player/mpv/issues/7341)
- [mpv HDR passthrough](https://github.com/mpv-player/mpv/issues/11812)
### Articles
- [Plex's mpv Player](https://freetime.mikeconnelly.com/archives/8360)
- [Rendering HDR Video with AVFoundation and Metal](https://metalbyexample.com/hdr-video/)
---
## Current Implementation Status
**What we've implemented so far:**
1. **HDR Detection** (`MPVLayerRenderer.swift`)
- Reads `video-params/primaries` and `video-params/gamma` from mpv
- Detects HDR10 (bt.2020 + pq), HLG, Dolby Vision
- Logs: `HDR Detection - primaries: bt.2020, gamma: pq, fps: 23.976`
2. **AVDisplayCriteria** (`MpvPlayerView.swift`)
- Sets `preferredDisplayCriteria` on tvOS 17.0+ when HDR detected
- Creates CMFormatDescription with proper HDR color extensions
- Logs: `🎬 HDR: Setting display criteria to hdr10, fps: 23.976`
3. **target-colorspace-hint** (`MPVLayerRenderer.swift`)
- Added `target-colorspace-hint=yes` for tvOS to signal colorspace to display
**What's NOT working:**
- TV doesn't show HDR indicator
- Colors still appear washed out
- The display mode switch may not be happening
---
## Next Steps for Investigation
1. **Verify AVDisplayCriteria is being honored:**
- Check if Apple TV settings allow app-requested display mode changes
- Verify the CMFormatDescription is correctly formed
2. **Examine vo_avfoundation pixel buffer creation:**
- Clone MPVKit source
- Find where CVPixelBuffers are created
- Check if colorspace attachments are being set
3. **Test with AVSampleBufferDisplayLayer debugging:**
- Log pixel buffer attachments
- Verify layer configuration
4. **Consider testing with VLCKit:**
- Swiftfin's VLCKit has same issue
- Their solution: use AVPlayer for HDR content

244
docs/tv-focus-guide.md Normal file
View File

@@ -0,0 +1,244 @@
# 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.
## 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<View | null>(null);
// 2. Place an invisible focus guide between sections
{targetRef && (
<TVFocusGuideView
destinations={[targetRef]}
style={{ height: 1, width: "100%" }}
/>
)}
// 3. Pass the state setter as a callback ref to the target
<TargetComponent ref={setTargetRef} />
```
### 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<View>(null);
<TVFocusGuideView destinations={targetRef.current ? [targetRef.current] : []} />
// ✅ Works - useState triggers re-render when ref is set
const [targetRef, setTargetRef] = useState<View | null>(null);
<TVFocusGuideView destinations={targetRef ? [targetRef] : []} />
```
## Complete Example: Bidirectional Navigation
This example shows how to create focus navigation between a vertical list of buttons and a horizontal ScrollView of cards.
### Step 1: Convert Components to forwardRef
Any component that needs to be a focus destination must forward its ref:
```typescript
const TVOptionButton = React.forwardRef<
View,
{
label: string;
onPress: () => void;
}
>(({ label, onPress }, ref) => {
return (
<Pressable ref={ref} onPress={onPress}>
<Text>{label}</Text>
</Pressable>
);
});
const TVActorCard = React.forwardRef<
View,
{
name: string;
onPress: () => void;
}
>(({ name, onPress }, ref) => {
return (
<Pressable ref={ref} onPress={onPress}>
<Text>{name}</Text>
</Pressable>
);
});
```
### Step 2: Track Refs with State
```typescript
const MyScreen: React.FC = () => {
// Track the first actor card (for downward navigation)
const [firstActorRef, setFirstActorRef] = useState<View | null>(null);
// Track the last option button (for upward navigation)
const [lastButtonRef, setLastButtonRef] = useState<View | null>(null);
// ...
};
```
### Step 3: Place Focus Guides
```typescript
return (
<View style={{ flex: 1 }}>
{/* Option buttons */}
<View>
<TVOptionButton label="Quality" onPress={...} />
<TVOptionButton label="Audio" onPress={...} />
<TVOptionButton
ref={setLastButtonRef} // Last button gets the ref
label="Subtitles"
onPress={...}
/>
</View>
{/* Focus guide: options → cast (downward navigation) */}
{firstActorRef && (
<TVFocusGuideView
destinations={[firstActorRef]}
style={{ height: 1, width: "100%" }}
/>
)}
{/* Cast section */}
<View>
<Text>Cast</Text>
{/* Focus guide: cast → options (upward navigation) */}
{lastButtonRef && (
<TVFocusGuideView
destinations={[lastButtonRef]}
style={{ height: 1, width: "100%" }}
/>
)}
<ScrollView horizontal>
{actors.map((actor, index) => (
<TVActorCard
key={actor.id}
ref={index === 0 ? setFirstActorRef : undefined} // First card gets the ref
name={actor.name}
onPress={...}
/>
))}
</ScrollView>
</View>
</View>
);
```
### Step 4: Handle Dynamic "Last" Element
When the last button varies based on conditions (e.g., subtitle button only shows if subtitles exist), compute which one is last:
```typescript
// Determine which button is last
const lastOptionButton = useMemo(() => {
if (hasSubtitles) return "subtitle";
if (hasAudio) return "audio";
return "quality";
}, [hasSubtitles, hasAudio]);
// Pass ref only to the last one
<TVOptionButton
ref={lastOptionButton === "quality" ? setLastButtonRef : undefined}
label="Quality"
onPress={...}
/>
<TVOptionButton
ref={lastOptionButton === "audio" ? setLastButtonRef : undefined}
label="Audio"
onPress={...}
/>
<TVOptionButton
ref={lastOptionButton === "subtitle" ? setLastButtonRef : undefined}
label="Subtitles"
onPress={...}
/>
```
## Focus Guide Placement
The focus guide should be placed **between** the source and destination sections:
```
┌─────────────────────────┐
│ Option Buttons │ ← Source (going down)
│ [Quality] [Audio] │
└─────────────────────────┘
┌─────────────────────────┐
│ TVFocusGuideView │ ← Invisible guide (height: 1px)
│ destinations=[actor1] │ Catches downward navigation
└─────────────────────────┘
┌─────────────────────────┐
│ TVFocusGuideView │ ← Invisible guide (height: 1px)
│ destinations=[lastBtn] │ Catches upward navigation
├─────────────────────────┤
│ Actor Cards │ ← Destination (going down)
│ [👤] [👤] [👤] [👤] │ Source (going up)
└─────────────────────────┘
```
## Tips and Gotchas
1. **Guard against null refs**: Only render the focus guide when the ref is set:
```typescript
{targetRef && <TVFocusGuideView destinations={[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
<TVFocusGuideView destinations={[ref1, ref2, ref3]} />
```
4. **Focus trapping**: Use `trapFocusUp`, `trapFocusDown`, etc. to prevent focus from leaving a region (useful for modals):
```typescript
<TVFocusGuideView trapFocusUp trapFocusDown trapFocusLeft trapFocusRight>
{/* Modal content */}
</TVFocusGuideView>
```
5. **Auto focus**: Use `autoFocus` to automatically focus the first focusable child:
```typescript
<TVFocusGuideView autoFocus>
{/* First focusable child will receive focus */}
</TVFocusGuideView>
```
## Reference Implementation
See `components/ItemContent.tv.tsx` for a complete implementation of bidirectional focus navigation between playback options and the cast list.

View File

@@ -43,6 +43,13 @@
"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",
@@ -68,9 +75,17 @@
"env": {
"EXPO_TV": "1"
}
},
"production_tv": {
"environment": "production",
"channel": "0.52.0",
"env": {
"EXPO_TV": "1"
}
}
},
"submit": {
"production": {}
"production": {},
"production_tv": {}
}
}

View File

@@ -7,7 +7,10 @@ import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"
* React hook wrapper for getDefaultPlaySettings.
* Used in UI components for initial playback (no previous track state).
*/
const useDefaultPlaySettings = (item: BaseItemDto, settings: Settings | null) =>
const useDefaultPlaySettings = (
item: BaseItemDto | null | undefined,
settings: Settings | null,
) =>
useMemo(() => {
const { mediaSource, audioIndex, subtitleIndex, bitrate } =
getDefaultPlaySettings(item, settings);

286
hooks/useRemoteSubtitles.ts Normal file
View File

@@ -0,0 +1,286 @@
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 { apiAtom } from "@/providers/JellyfinProvider";
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<SubtitleSearchResult[]> => {
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<SubtitleSearchResult[]> => {
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<OpenSubtitlesApi["search"]>[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<void> => {
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)
*/
const downloadOpenSubtitles = useCallback(
async (fileId: number): Promise<string> => {
if (!openSubtitlesApi) {
throw new Error("OpenSubtitles API key not configured");
}
// Get download link
const response = await openSubtitlesApi.download(fileId);
// Download to cache directory
const fileName = response.file_name || `subtitle_${fileId}.srt`;
const subtitlesDir = new Directory(Paths.cache, "subtitles");
// Ensure directory exists
if (!subtitlesDir.exists) {
subtitlesDir.create({ intermediates: true });
}
// Create file and download
const destination = new File(subtitlesDir, fileName);
await File.downloadFileAsync(response.link, destination);
return destination.uri;
},
[openSubtitlesApi],
);
/**
* 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 localPath = await downloadOpenSubtitles(result.fileId);
return { type: "local" as const, path: localPath };
}
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();
},
};
}

36
hooks/useTVOptionModal.ts Normal file
View File

@@ -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<T> {
title: string;
options: TVOptionItem<T>[];
onSelect: (value: T) => void;
cardWidth?: number;
cardHeight?: number;
}
export const useTVOptionModal = () => {
const router = useRouter();
const showOptions = useCallback(
<T>(params: ShowOptionsParams<T>) => {
// 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 };
};

Some files were not shown because too many files have changed in this diff Show More