Compare commits

..

74 Commits

Author SHA1 Message Date
Fredrik Burmester
0c7a9a1218 wip 2025-11-16 10:46:24 +01:00
Fredrik Burmester
94362169b6 fix: version 2025-11-16 10:25:20 +01:00
Fredrik Burmester
8aefdac50f fix: use setupxcode with correct xcode version 2025-11-16 10:24:44 +01:00
Fredrik Burmester
665a79924a fix: use macos 26 runner 2025-11-16 09:57:24 +01:00
Fredrik Burmester
b9ddcf8404 fix: update to latest macos runner 2025-11-16 09:30:37 +01:00
Fredrik Burmester
64ffc8db8b fix: lint issues 2025-11-16 08:50:01 +01:00
Gauvain
2a61124a0d chore(deps): upgrade dev dependencies and test utilities (#1195)
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 / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (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: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-15 20:24:04 +01:00
Fredrik Burmester
36178c2082 fix: apple tv runtime crash
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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-11-15 18:23:25 +01:00
Fredrik Burmester
e1c69a9ec9 fix: boot apple tv (working playing video) 2025-11-15 18:10:16 +01:00
Fredrik Burmester
01110b8d13 fix: clean toast message jellyseerr movie request (#1201) 2025-11-15 17:39:07 +01:00
Fredrik Burmester
21034f5671 fix: remove log 2025-11-15 17:36:25 +01:00
Fredrik Burmester
1439bcee0d fix: update okhttp v5 and fix android download crash issues (#1203) 2025-11-15 11:07:35 +01:00
Fredrik Burmester
9a906e6d39 fix: auto-filling would cause state not to be updated (#1200) 2025-11-15 11:06:23 +01:00
Jaakko Rantamäki
48de7b7c6d feat: Liquid Glass Icon (#1070)
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: Fredrik Burmester <fredrik.burmester@gmail.com>
2025-11-15 09:31:58 +01:00
renovate[bot]
85e5c25206 chore(deps): Update github/codeql-action action to v4.31.3 (#1180)
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>
2025-11-14 20:56:45 +01:00
renovate[bot]
3dc84818e8 chore(deps): Update actions/dependency-review-action action to v4.8.2 (#1175)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-14 20:56:34 +01:00
renovate[bot]
18102a3045 chore(deps): Update dependency react-native-device-info to v15 (#1182)
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-14 20:56:22 +01:00
Fredrik Burmester
2be78a232c fix: linting (#1184) 2025-11-14 19:34:59 +01:00
Fredrik Burmester
30dc3980e3 refactor: better settings (#1178)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-11-14 08:03:00 +01:00
Fredrik Burmester
f7da29b9c9 fix: better api calls
Some checks failed
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (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
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
🏷️🔀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
2025-11-12 21:30:20 +01:00
Fredrik Burmester
7a5f0b52b6 fix: bottom padding 2025-11-12 21:28:18 +01:00
Fredrik Burmester
62dfe7c9e1 fix: go back to old play button for ios 2025-11-12 21:28:13 +01:00
Fredrik Burmester
50d559d528 chore: version
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
🌐 Translation Sync / sync-translations (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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-11-11 14:41:24 +01:00
Fredrik Burmester
38aba3d67a fix: project id notifications 2025-11-11 11:28:39 +01:00
Fredrik Burmester
5765793d79 fix: go back to expo tokens 2025-11-11 10:56:50 +01:00
Fredrik Burmester
222ba13529 fix: episode list 2025-11-11 09:51:10 +01:00
Fredrik Burmester
de6c2072c9 fix: storage calculation 2025-11-11 09:25:27 +01:00
Fredrik Burmester
76ec8e0e46 fix(android): improve modal 2025-11-11 09:12:00 +01:00
Fredrik Burmester
389d9e2d31 fix(android): modal open when we don't want to 2025-11-11 09:07:34 +01:00
Fredrik Burmester
485dc6eeac feat: Expo 54 (new arch) support + new in-house download module (#1174)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: sarendsen <coding-mosses0z@icloud.com>
Co-authored-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-11-11 08:53:23 +01:00
Fredrik Burmester
154788cf91 chore: version
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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2025-11-08 09:56:36 +01:00
Fredrik Burmester
3e181eca72 fix: refresh control with large header + refresh button 2025-11-08 09:55:53 +01:00
Fredrik Burmester
f5b9e03dd9 fix(android): don't show controls in pip
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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2025-11-07 22:13:41 +01:00
Fredrik Burmester
196f91400b fix: same id in carousel 2025-11-07 22:13:28 +01:00
Fredrik Burmester
51a14c6058 fix: linting 2025-11-07 16:12:03 +01:00
Fredrik Burmester
5432476ca1 fix: update deps 2025-11-07 16:11:43 +01:00
Fredrik Burmester
9d9ec974ff chore: deps 2025-11-07 15:51:11 +01:00
Fredrik Burmester
0dadfd3d90 fix: improve carousel behavior and settings option (#1166)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-07 15:14:35 +01:00
lance chant
96d6220f5e fix: fixing #1163 (#1167)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2025-11-07 15:14:13 +01:00
lance chant
b847baa314 fix: fixed issue where subtitles didnt change (#1168)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2025-11-07 15:13:17 +01:00
Fredrik Burmester
a4cce27737 fix: try to fix android notifications
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
2025-11-07 13:31:06 +01:00
Fredrik Burmester
ce37351099 fix(android): possible fix for notifications 2025-11-07 08:08:01 +01:00
Fredrik Burmester
e4e1e556bf chore: version 2025-11-07 08:07:02 +01:00
lostb1t
1ff723b29f Update ItemContent.tsx
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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-11-06 16:16:51 +01:00
lostb1t
11d35c846d Update ItemContent.tsx 2025-11-06 16:10:12 +01:00
lostb1t
ce82f3044b Update ItemContent.tsx
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
2025-11-06 13:34:55 +01:00
lostb1t
df638dae28 Update ItemContent.tsx 2025-11-06 13:32:28 +01:00
Jonah
9a5e49ae16 fix: omit includeItemTypes in getLatestMedia if possible (#1141)
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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-11-04 16:22:10 +01:00
renovate[bot]
4f2120f85d chore(deps): Update github/codeql-action action to v4.31.2 (#1152)
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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-31 10:20:39 +01:00
Fredrik Burmester
8eeea35441 chore: bump version to 0.40.0 2025-10-31 08:37:50 +01:00
Fredrik Burmester
781464b768 chore: update to bun 1.3 2025-10-31 08:25:02 +01:00
renovate[bot]
61d60c2e74 chore(deps): Update dependency @biomejs/biome to v2.2.7 (#1142)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (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 / 🚑 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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-26 14:39:42 +01:00
Uruk
6c722e1fd9 fix: remove trailing space from translation key
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
🌐 Translation Sync / sync-translations (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Removes extraneous whitespace at the end of the "Enable Marlin Search" translation string to ensure consistent formatting across the localization file.
2025-10-25 16:06:14 +02:00
Chris
e117e98c8e docs: readme.md
Added translations, updated prerequisites, fixed Discord links, and minor tweaks
2025-10-25 15:40:59 +02:00
renovate[bot]
f5c791cce4 chore(deps): Update dependency lint-staged to v16.2.6 (#1149)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 13:05:34 +02:00
renovate[bot]
eaebb79f4c chore(deps): Update dependency lint-staged to v16.2.5 (#1144)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 12:56:28 +02:00
renovate[bot]
7e82d9c7b6 chore(deps): Update actions/upload-artifact action to v5 (#1147)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 12:56:16 +02:00
renovate[bot]
c62c8ca077 chore(deps): Update dependency @babel/core to v7.28.5 (#1148)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 12:56:07 +02:00
renovate[bot]
25cfcf0735 chore(deps): Update crowdin/github-action action to v2.12.0 (#1145)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 12:55:38 +02:00
renovate[bot]
f19b33eff6 chore(deps): Update github/codeql-action action to v4.31.0 (#1146)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 12:54:08 +02:00
renovate[bot]
1c4f54584f chore(deps): Update dependency expo-doctor to v1.17.11 (#1143)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 12:41:52 +02:00
Chris
87d02078c8 docs: Add AI Assistance Disclosure Clause
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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Update introduces a new section outlining requirements for contributors who use AI tools while working on the project.
2025-10-24 10:01:36 +02:00
Chris
1e9fba6f85 docs: updating CONTRIBUTING.md
Added AI assistance disclosure clause
2025-10-24 09:55:07 +02:00
Uruk
dbcf5759da docs: remove markdown link formatting from commit guide
Some checks failed
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (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
🏗️ 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
🚦 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
🌐 Translation Sync / sync-translations (push) Has been cancelled
Simplifies the Conventional Commits reference by converting it from markdown link syntax to plain text with URL, improving readability in the documentation file.
2025-10-19 22:45:23 +02:00
renovate[bot]
37364b235f chore(deps): Update github/codeql-action action to v4.30.9 (#1138)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-19 22:41:49 +02:00
renovate[bot]
fcb8e83845 chore(deps): Update actions/setup-node action to v6 (#1128)
Some checks failed
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (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
🔒 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 / 🔍 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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 22:08:14 +02:00
renovate[bot]
16046a4a15 chore(deps): Update dependency expo-doctor to v1.17.10 (#1125)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 22:07:31 +02:00
renovate[bot]
13b29bdee0 chore(deps): Update dependency lint-staged to v16.2.4 (#1126)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 22:06:53 +02:00
renovate[bot]
bf4a1f9462 chore(deps): Update actions/dependency-review-action action to v4.8.1 (#1123)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 22:04:40 +02:00
renovate[bot]
264439858d chore(deps): Update dependency @biomejs/biome to v2.2.6 (#1124)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 22:04:25 +02:00
renovate[bot]
6576a58884 chore(deps): Update github/codeql-action action to v4.30.8 (#1127)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 22:03:29 +02:00
Chris
3eae2f3fde docs: improve README.md
Some checks failed
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (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
🏗️ 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
🏷️🔀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
🌐 Translation Sync / sync-translations (push) Has been cancelled
Improve structure and consistency, fix grammar and spelling issues, reorder content for clarity, add GitHub download button, update Discord badge to a more suitable version, and include various other improvements
2025-10-12 19:26:22 +02:00
Chris
deae6ab21a docs: improve README.md
Some checks failed
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (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
🏗️ 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
🚦 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
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
- Rename Jellyseer to Seerr
- Remove dots from feature list
- Add download button linking to GitHub
- Refine phrasing in Contributing section
- Correct grammar and spelling errors
2025-10-09 21:51:02 +02:00
Chris
5478179367 Get it on Github button
Button to replace text string "Or download the APKs here on GitHub for Android
2025-10-09 21:22:51 +02:00
237 changed files with 10766 additions and 5662 deletions

View File

@@ -1,15 +1 @@
# Streamyfin-specific debug flag EXPO_PUBLIC_WRITE_DEBUG=1
EXPO_PUBLIC_WRITE_DEBUG=1
# Performance optimization (official Metro flag)
EXPO_USE_METRO_REQUIRE=1
# TV development support (used in metro.config.js)
# EXPO_TV=1
# Uncomment the above line ONLY when working on TV features. Leave commented for mobile-only development to avoid issues.
# Fast resolver optimization (2025 feature)
EXPO_USE_FAST_RESOLVER=1
# Bundle analysis for monitoring
EXPO_ATLAS=1

View File

@@ -1,26 +1 @@
# Streamyfin Production Configuration EXPO_PUBLIC_WRITE_DEBUG=0
EXPO_PUBLIC_WRITE_DEBUG=0
# Production Performance Optimizations
NODE_ENV=production
EXPO_USE_METRO_REQUIRE=1
EXPO_USE_FAST_RESOLVER=1
# Production Build Optimizations
EXPO_OPTIMIZE_BUNDLE_SIZE=1
EXPO_NO_CLIENT_ENV_VARS=1
EXPO_LEGACY_BUNDLER=0
# Bundle Analysis (for monitoring)
EXPO_ATLAS=0
# Production Cache Optimizations
METRO_CACHE=1
# Security & Performance
EXPO_NO_DOTENV=1
FAST_REFRESH=0
# Production Bundle Features
EXPO_USE_HERMES=1
EXPO_MINIFY=1

View File

@@ -1,11 +1,13 @@
# Contributing to Streamyfin # Contributing to Streamyfin
Thank you for your interest in contributing to the Streamyfin mobile app project! This document provides guidelines to smoothly collaborate on the Streamyfin codebase and help improve the app for all users. Thank you for your interest in contributing to the Streamyfin project. This document outlines the guidelines for effective collaboration across the Streamyfin codebase and aims to ensure a smooth, productive experience for all contributors.
--- ---
## Table of Contents ## Table of Contents
- [AI Assistance Disclosure](#ai-assistance-disclosure)
- [Reporting Issues](#reporting-issues) - [Reporting Issues](#reporting-issues)
- [Reporting Security Vulnerabilities](#reporting-security-vulnerabilities) - [Reporting Security Vulnerabilities](#reporting-security-vulnerabilities)
- [Requesting Features & Enhancements](#requesting-features--enhancements) - [Requesting Features & Enhancements](#requesting-features--enhancements)
@@ -19,6 +21,25 @@ Thank you for your interest in contributing to the Streamyfin mobile app project
--- ---
## AI Assistance Disclosure
> [!IMPORTANT]
> If any AI tool was used while contributing to Streamyfin, it must be disclosed in the pull request.
State in your PR whether AI assistance was used and to what extent (for example, *docs only* or *code generation*).
If AI-generated text was used in PR discussions or responses, disclose that as well.
Minor autocomplete or keyword suggestions do not require disclosure.
### Examples
> This PR was written primarily by Claude Code.
> I used Cursor to explore parts of the codebase, but the implementation is fully manual.
Failing to disclose AI usage wastes maintainers time and complicates review efforts.
AI-assisted contributions are welcome, but contributors remain fully responsible for the code they submit.
Always disclose AI involvement to maintain transparency and respect for maintainers time.
## Reporting Issues ## Reporting Issues
Streamyfin uses GitHub issues to track bugs and improvements. Before opening a new issue: Streamyfin uses GitHub issues to track bugs and improvements. Before opening a new issue:
@@ -46,11 +67,11 @@ When creating a new feature request:
- Check if the idea or similar request exists. - Check if the idea or similar request exists.
- Use reactions like 👍 to support existing requests. - Use reactions like 👍 to support existing requests.
- Provide a clear explanation of the use case and benefits. - Clearly describe the use case and potential benefits.
- Include screenshots when relevant.
--- ---
## Developing the Mobile App ## Developing Streamyfin
### Codebase Overview ### Codebase Overview
@@ -136,6 +157,8 @@ When opening a PR:
- Provide a detailed description in the PR body, explaining what, why, and any impacts. - Provide a detailed description in the PR body, explaining what, why, and any impacts.
- Include screenshots or recordings if UI changes are involved. - Include screenshots or recordings if UI changes are involved.
- Ensure CI checks are green (lint, type-check, build). - Ensure CI checks are green (lint, type-check, build).
- Confirm that the branch is **up to date with `main`** before submission.
- Mention if AI-generated code or content was used (see [AI Assistance Disclosure](#ai-assistance-disclosure)).
- Do not include secrets, tokens, or production credentials. Redact sensitive data in logs and screenshots. - Do not include secrets, tokens, or production credentials. Redact sensitive data in logs and screenshots.
- Keep PRs focused; avoid bundling unrelated changes together. - Keep PRs focused; avoid bundling unrelated changes together.
@@ -159,4 +182,4 @@ PRs require review and approval by maintainers before merging.---
--- ---
Thank you for helping make Streamyfin a better app for everyone! Thank you for contributing to make Streamyfin better for everyone!

View File

@@ -79,7 +79,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
## Commit Messages ## Commit Messages
Use [Conventional Commits](https://www.conventionalcommits.org/): Use Conventional Commits (https://www.conventionalcommits.org/):
Exemples: Exemples:
- `feat(player): add Chromecast support` - `feat(player): add Chromecast support`
- `fix(auth): handle expired JWT tokens` - `fix(auth): handle expired JWT tokens`

View File

@@ -76,7 +76,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact - name: 📤 Upload APK artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }} name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
path: | path: |
@@ -147,7 +147,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact - name: 📤 Upload APK artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }} name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
path: | path: |
@@ -156,7 +156,7 @@ jobs:
build-ios-phone: build-ios-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin')) if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
runs-on: macos-15 runs-on: macos-26
name: 🍎 Build iOS IPA (Phone) name: 🍎 Build iOS IPA (Phone)
permissions: permissions:
contents: read contents: read
@@ -170,6 +170,11 @@ jobs:
submodules: recursive submodules: recursive
show-progress: false show-progress: false
- name: 🟢 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with: with:
@@ -191,6 +196,11 @@ jobs:
- name: 🛠️ Generate project files - name: 🛠️ Generate project files
run: bun run prebuild run: bun run prebuild
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "26.0.1"
- name: 🏗️ Setup EAS - name: 🏗️ Setup EAS
uses: expo/expo-github-action@main uses: expo/expo-github-action@main
with: with:
@@ -210,7 +220,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact - name: 📤 Upload IPA artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }} name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
path: build-*.ipa path: build-*.ipa
@@ -219,7 +229,7 @@ jobs:
# Disabled for now - uncomment when ready to build iOS TV # Disabled for now - uncomment when ready to build iOS TV
# build-ios-tv: # build-ios-tv:
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin')) # if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
# runs-on: macos-15 # runs-on: macos-26
# name: 🍎 Build iOS IPA (TV) # name: 🍎 Build iOS IPA (TV)
# permissions: # permissions:
# contents: read # contents: read
@@ -233,6 +243,11 @@ jobs:
# submodules: recursive # submodules: recursive
# show-progress: false # show-progress: false
# #
# - name: 🟢 Setup Node.js
# uses: actions/setup-node@v4
# with:
# node-version: '20'
#
# - name: 🍞 Setup Bun # - name: 🍞 Setup Bun
# uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 # uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
# with: # with:
@@ -254,6 +269,11 @@ jobs:
# - name: 🛠️ Generate project files # - name: 🛠️ Generate project files
# run: bun run prebuild:tv # run: bun run prebuild:tv
# #
# - name: 🔧 Setup Xcode
# uses: maxim-lobanov/setup-xcode@v1
# with:
# xcode-version: '26.0.1'
#
# - name: 🏗️ Setup EAS # - name: 🏗️ Setup EAS
# uses: expo/expo-github-action@main # uses: expo/expo-github-action@main
# with: # with:

View File

@@ -31,13 +31,13 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: 🏁 Initialize CodeQL - name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7 uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild - name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7 uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
- name: 🧪 Perform CodeQL Analysis - name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7 uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3

View File

@@ -28,7 +28,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin - name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2.11.0 uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0
with: with:
upload_sources: true upload_sources: true
upload_translations: true upload_translations: true

View File

@@ -57,7 +57,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Dependency Review - name: Dependency Review
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0 uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
with: with:
fail-on-severity: high fail-on-severity: high
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }} base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
@@ -107,7 +107,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: "🟢 Setup Node.js" - name: "🟢 Setup Node.js"
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version: '24.x' node-version: '24.x'

View File

@@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: "🟢 Setup Node.js" - name: "🟢 Setup Node.js"
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version: '24.x' node-version: '24.x'
cache: 'npm' cache: 'npm'

3
.gitignore vendored
View File

@@ -64,4 +64,5 @@ credentials.json
streamyfin-4fec1-firebase-adminsdk.json streamyfin-4fec1-firebase-adminsdk.json
# Version and Backup Files # Version and Backup Files
/version-backup-* /version-backup-*
modules/background-downloader/android/build/*

177
.vscode/settings.json vendored
View File

@@ -1,178 +1,25 @@
{ {
// ==========================================
// FORMATTING & LINTING
// ==========================================
// Biome as default formatter
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.formatOnPaste": true, "editor.codeActionsOnSave": {
"editor.formatOnType": false, "source.fixAll.biome": "explicit"
// Language-specific formatters
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
}, },
"[typescript]": { "[typescript]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome"
"editor.formatOnSave": true
}, },
"[typescriptreact]": { "[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome"
"editor.formatOnSave": true },
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
}, },
"[javascriptreact]": { "[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome"
"editor.formatOnSave": true
}, },
"[json]": { "[json]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome"
"editor.formatOnSave": true
}, },
"[jsonc]": { "typescript.tsdk": "node_modules/typescript/lib",
"editor.defaultFormatter": "biomejs.biome", "typescript.enablePromptUseWorkspaceTsdk": true,
"editor.formatOnSave": true "editor.formatOnSaveMode": "file"
},
"[swift]": {
"editor.insertSpaces": true,
"editor.tabSize": 2
},
// ==========================================
// TYPESCRIPT & JAVASCRIPT
// ==========================================
// TypeScript performance optimizations
"typescript.preferences.includePackageJsonAutoImports": "auto",
"typescript.suggest.autoImports": true,
"typescript.updateImportsOnFileMove.enabled": "always",
"typescript.preferences.preferTypeOnlyAutoImports": true,
"typescript.preferences.importModuleSpecifier": "relative",
"typescript.preferences.includeCompletionsForImportStatements": true,
"typescript.preferences.includeCompletionsWithSnippetText": true,
// JavaScript settings
"javascript.preferences.importModuleSpecifier": "relative",
"javascript.suggest.autoImports": true,
"javascript.updateImportsOnFileMove.enabled": "always",
// ==========================================
// REACT NATIVE & EXPO
// ==========================================
// File associations for React Native
"files.associations": {
"*.expo.ts": "typescript",
"*.expo.tsx": "typescriptreact",
"*.expo.js": "javascript",
"*.expo.jsx": "javascriptreact",
"metro.config.js": "javascript",
"babel.config.js": "javascript",
"app.config.js": "javascript",
"eas.json": "jsonc"
},
// React Native specific settings
"emmet.includeLanguages": {
"typescriptreact": "html",
"javascriptreact": "html"
},
"emmet.triggerExpansionOnTab": true,
// Exclude build directories from search
"search.exclude": {
"**/node_modules": true
},
// ==========================================
// EDITOR PERFORMANCE & UX
// ==========================================
// Performance optimizations
"editor.largeFileOptimizations": true,
"files.watcherExclude": {
"**/.git/objects/**": true,
"**/.git/subtree-cache/**": true,
"**/node_modules/**": true,
"**/.expo/**": true,
"**/ios/**": true,
"**/android/**": true,
"**/build/**": true,
"**/dist/**": true
},
// Better editor behavior
"editor.suggestSelection": "first",
"editor.quickSuggestions": {
"strings": true,
"comments": true,
"other": true
},
"editor.snippetSuggestions": "top",
"editor.tabCompletion": "on",
"editor.wordBasedSuggestions": "off",
// ==========================================
// TERMINAL & DEVELOPMENT
// ==========================================
// Terminal settings for Bun (Windows-specific)
"terminal.integrated.profiles.windows": {
"Command Prompt": {
"path": "C:\\Windows\\System32\\cmd.exe",
"env": {
"PATH": "${env:PATH};./node_modules/.bin"
}
}
},
// ==========================================
// WORKSPACE & NAVIGATION
// ==========================================
// Better workspace navigation
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.expand": false,
"explorer.fileNesting.patterns": {
"*.ts": "${capture}.js",
"*.tsx": "${capture}.js",
"*.js": "${capture}.js,${capture}.js.map,${capture}.min.js,${capture}.d.ts",
"*.jsx": "${capture}.js",
"package.json": "package-lock.json,yarn.lock,bun.lock,bun.lockb,.yarnrc,.yarnrc.yml",
"tsconfig.json": "tsconfig.*.json",
".env": ".env.*",
"app.json": "app.config.js,eas.json,expo-env.d.ts",
"README.md": "LICENSE.txt,SECURITY.md,CODE_OF_CONDUCT.md,CONTRIBUTING.md"
},
// Better breadcrumbs and navigation
"breadcrumbs.enabled": true,
"outline.showVariables": true,
"outline.showConstants": true,
// ==========================================
// GIT & VERSION CONTROL
// ==========================================
// Git integration
"git.autofetch": true,
"git.enableSmartCommit": true,
"git.confirmSync": false,
"git.ignoreLimitWarning": true,
// ==========================================
// CODE QUALITY & ERRORS
// ==========================================
// Better error detection
"typescript.validate.enable": true,
"javascript.validate.enable": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
},
// Problem matcher for better error display
"typescript.tsc.autoDetect": "on"
} }

232
GLOBAL_MODAL_GUIDE.md Normal file
View File

@@ -0,0 +1,232 @@
# Global Modal System with Gorhom Bottom Sheet
This guide explains how to use the global modal system implemented in this project.
## Overview
The global modal system allows you to trigger a bottom sheet modal from anywhere in your app programmatically, and render any component inside it.
## Architecture
The system consists of three main parts:
1. **GlobalModalProvider** (`providers/GlobalModalProvider.tsx`) - Context provider that manages modal state
2. **GlobalModal** (`components/GlobalModal.tsx`) - The actual modal component rendered at root level
3. **useGlobalModal** hook - Hook to interact with the modal from anywhere
## Setup (Already Configured)
The system is already integrated into your app:
```tsx
// In app/_layout.tsx
<BottomSheetModalProvider>
<GlobalModalProvider>
{/* Your app content */}
<GlobalModal />
</GlobalModalProvider>
</BottomSheetModalProvider>
```
## Usage
### Basic Usage
```tsx
import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { View, Text } from "react-native";
function MyComponent() {
const { showModal, hideModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6'>
<Text className='text-white text-2xl'>Hello from Modal!</Text>
</View>
);
};
return (
<Button onPress={handleOpenModal} title="Open Modal" />
);
}
```
### With Custom Options
```tsx
const handleOpenModal = () => {
showModal(
<YourCustomComponent />,
{
snapPoints: ["25%", "50%", "90%"], // Custom snap points
enablePanDownToClose: true, // Allow swipe to close
backgroundStyle: { // Custom background
backgroundColor: "#000000",
},
}
);
};
```
### Programmatic Control
```tsx
// Open modal
showModal(<Content />);
// Close modal from within the modal content
function ModalContent() {
const { hideModal } = useGlobalModal();
return (
<View>
<Button onPress={hideModal} title="Close" />
</View>
);
}
// Close modal from outside
hideModal();
```
### In Event Handlers or Functions
```tsx
function useApiCall() {
const { showModal } = useGlobalModal();
const fetchData = async () => {
try {
const result = await api.fetch();
// Show success modal
showModal(
<SuccessMessage data={result} />
);
} catch (error) {
// Show error modal
showModal(
<ErrorMessage error={error} />
);
}
};
return fetchData;
}
```
## API Reference
### `useGlobalModal()`
Returns an object with the following properties:
- **`showModal(content, options?)`** - Show the modal with given content
- `content: ReactNode` - Any React component or element to render
- `options?: ModalOptions` - Optional configuration object
- **`hideModal()`** - Programmatically hide the modal
- **`isVisible: boolean`** - Current visibility state of the modal
### `ModalOptions`
```typescript
interface ModalOptions {
enableDynamicSizing?: boolean; // Auto-size based on content (default: true)
snapPoints?: (string | number)[]; // Fixed snap points (e.g., ["50%", "90%"])
enablePanDownToClose?: boolean; // Allow swipe down to close (default: true)
backgroundStyle?: object; // Custom background styles
handleIndicatorStyle?: object; // Custom handle indicator styles
}
```
## Examples
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
- Simple content modal
- Modal with custom snap points
- Complex component in modal
- Success/error modals triggered from functions
## Default Styling
The modal uses these default styles (can be overridden via options):
```typescript
{
enableDynamicSizing: true,
enablePanDownToClose: true,
backgroundStyle: {
backgroundColor: "#171717", // Dark background
},
handleIndicatorStyle: {
backgroundColor: "white",
},
}
```
## Best Practices
1. **Keep content in separate components** - Don't inline large JSX in `showModal()` calls
2. **Use the hook in custom hooks** - Create specialized hooks like `useShowSuccessModal()` for reusable modal patterns
3. **Handle cleanup** - The modal automatically clears content when closed
4. **Avoid nesting** - Don't show modals from within modals
5. **Consider UX** - Only use for important, contextual information that requires user attention
## Using with PlatformDropdown
When using `PlatformDropdown` with option groups, avoid setting a `title` on the `OptionGroup` if you're already passing a `title` prop to `PlatformDropdown`. This prevents nested menu behavior on iOS where users have to click through an extra layer.
```tsx
// Good - No title in option group (title is on PlatformDropdown)
const optionGroups: OptionGroup[] = [
{
options: items.map((item) => ({
type: "radio",
label: item.name,
value: item,
selected: item.id === selected?.id,
onPress: () => onChange(item),
})),
},
];
<PlatformDropdown
groups={optionGroups}
title="Select Item" // Title here
// ...
/>
// Bad - Causes nested menu on iOS
const optionGroups: OptionGroup[] = [
{
title: "Items", // This creates a nested Picker on iOS
options: items.map((item) => ({
type: "radio",
label: item.name,
value: item,
selected: item.id === selected?.id,
onPress: () => onChange(item),
})),
},
];
```
## Troubleshooting
### Modal doesn't appear
- Ensure `GlobalModalProvider` is above the component calling `useGlobalModal()`
- Check that `BottomSheetModalProvider` is present in the tree
- Verify `GlobalModal` component is rendered
### Content is cut off
- Use `enableDynamicSizing: true` for auto-sizing
- Or specify appropriate `snapPoints`
### Modal won't close
- Ensure `enablePanDownToClose` is `true`
- Check that backdrop is clickable
- Use `hideModal()` for programmatic closing

161
README.md
View File

@@ -5,146 +5,135 @@
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%"> <img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
</p> </p>
**Streamyfin is a simple, user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.** **Streamyfin is a user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
--- ---
<p align="center"> <p align="center">
<img src="./assets/images/screenshots/screenshot1.png" width="22%"> <img src="./assets/images/screenshots/screenshot1.png" width="20%">
&nbsp; &nbsp;
<img src="./assets/images/screenshots/screenshot3.png" width="22%"> <img src="./assets/images/screenshots/screenshot3.png" width="20%">
&nbsp; &nbsp;
<img src="./assets/images/screenshots/screenshot2.png" width="22%"> <img src="./assets/images/screenshots/screenshot2.png" width="20%">
&nbsp; &nbsp;
<img src="./assets/images/jellyseerr.PNG" width="23%"> <img src="./assets/images/jellyseerr.PNG" width="21%">
</p> </p>
## 🌟 Features ## 🌟 Features
- 🚀 **Skip Intro / Credits Support** - 🚀 **Skip Intro / Credits Support**: Lets you quickly skip intros and credits during playback
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking. - 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking
- 📥 **Download media** (Experimental): Save your media locally and watch it offline. - 📥 **Download media**: Save your media locally and watch it offline
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device. - ⚙️ **Settings management**: Manage app configurations for all users through our plugin
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin. - 🤖 **Seerr (formerly Jellyseerr) integration**: Request media directly in the app
- 🤖 **Jellyseerr integration**: Request media directly in the app. - 👁️ **Sessions view:** View all active sessions currently streaming on your server
- 👁️ **Sessions View:** View all active sessions currently streaming on your server. - 📡 **Chromecast**: Cast your media to any Chromecast-enabled device
## 🧪 Experimental Features ## 🧪 Experimental Features
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These features are still in development, and your patience and feedback are much appreciated as we work to improve them. Streamyfin offers exciting experimental features such as media downloading and Chromecast support. These features are under active development, and your feedback and patience help us make them even better.
### 📥 Downloading ### 📥 Downloading
Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode. Downloading works by using FFmpeg to convert an HLS stream into a video file on your device. This lets you download and watch any content that you can stream. The conversion is handled in real time by Jellyfin on the server during the download. While this may take a bit longer, it ensures compatibility with any file your server can transcode.
### 🎥 Chromecast
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos, but we're working on adding support for subtitles and other features.
### 🧩 Streamyfin Plugin ### 🧩 Streamyfin Plugin
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example: The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
- Auto log in to Jellyseerr without the user having to do anything - Automatic Seerr login with no user input required
- Choose the default languages - Set your preferred default languages
- Set download method and search provider - Configure download method and search provider
- Customize home screen - Personalize your home screen
- And much more... - And much more
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin) [Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
### 📡 Chromecast
Chromecast support is currently under development. Video casting is already available, and we're actively working on adding subtitle support and additional features.
### 🔍 Jellysearch ### 🔍 Jellysearch
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! [Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients. > A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
## 🛣️ Roadmap for V1 ## 🛣️ Roadmap
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests. Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
## 📥 Get it now ## 📥 Download Streamyfin
<div style="display: flex; gap: 5px;"> <div style="display: flex; gap: 5px;">
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a> <a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a> <a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get Streamyfin on Google Play Store" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
<a href="https://github.com/streamyfin/streamyfin/releases/latest"><img height=50 alt="Get Streamyfin on Github" src="./assets/Download_on_Github_.png"/></a>
</div> </div>
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android. ### 🧪 Beta Testing
### 🧪 Beta testing To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This grants you immediate access to the ⁠🧪-beta-releases channel on Discord and lets me know youve subscribed. This is where I share APKs and IPAs. It does not provide automatic TestFlight access, so please send me a DM (Cagemaster) with the email you use for Apple so we can add you manually.
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the ⁠🧪-public-beta channel on Discord and I'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that I can manually add you. **Note**: Anyone actively contributing to Streamyfins source code will receive automatic access to beta releases.
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
## 🚀 Getting Started ## 🚀 Getting Started
### Prerequisites ### ⚙️ Prerequisites
- Ensure you have an active Jellyfin server. - Your device is on the same network as the Jellyfin server (for local connections)
- Make sure your device is connected to the same network as your Jellyfin server. - Your Jellyfin server is up and running with remote access enabled if you plan to connect from outside your local network
- Your server version is up to date (older versions may cause compatibility issues)
- You have a valid Jellyfin user account with access to the media libraries you want to view
- If using features such as **downloads** or **Seerr integration**, confirm the required plugins are installed and configured on your Jellyfin server
## 🙌 Contributing ## 🙌 Contributing
We welcome any help to make Streamyfin better. If you'd like to contribute, please fork the repository and submit a pull request. For major changes, it's best to open an issue first to discuss your ideas. We welcome contributions that improve Streamyfin. Start by forking the repository and submitting a pull request. For major changes or new features, please open an issue first to discuss your ideas and ensure alignment with the project.
### 👨‍💻 Development info ## 🌍 Translations
[![Crowdin Translation Status](https://badges.crowdin.net/streamyfin/localized.svg)](https://crowdin.com/project/streamyfin)
Streamyfin is available in multiple languages, and were always looking for contributors to help make the app accessible worldwide.
You can contribute translations directly on our [Crowdin project page](https://crowdin.com/project/streamyfin).
### 👨‍💻 Development Info
1. Use node `>20` 1. Use node `>20`
2. Install dependencies `bun i && bun run submodule-reload` 2. Install dependencies `bun i && bun run submodule-reload`
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/) 3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/) 4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
4. run `npm run prebuild` 4. run `npm run prebuild`
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app. 5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
For the TV version suffix the npm commands with `:tv`. For the TV version suffix the npm commands with `:tv`.
`npm run prebuild:tv` `npm run prebuild:tv`
`npm run ios:tv or npm run android:tv` `npm run ios:tv or npm run android:tv`
## 📄 License ## 👋 Get in Touch with Us
Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0). Need assistance or have any questions?
This means you are free to use, modify, and distribute this software. The MPL-2.0 is a copyleft license that allows for more flexibility in combining the software with proprietary code.
Key points of the MPL-2.0:
- You can use the software for any purpose - **Discord:** [Join our server](https://discord.gg/BuGG9ZNhaE)
- You can modify the software and distribute modified versions - **GitHub Issues:** [Report bugs or request features](https://github.com/streamyfin/streamyfin/issues)
- You must include the original copyright and license notices - **Email:** [developer@streamyfin.app](mailto:developer@streamyfin.app)
- You must disclose your source code for any modifications to the covered files
- Larger works may combine MPL code with code under other licenses
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license
- For the full text of the license, please see the LICENSE file in this repository
## 🌐 Connect with Us
Join our Discord: [![](https://dcbadge.limes.pink/api/server/https://discord.gg/BuGG9ZNhaE)](https://discord.gg/BuGG9ZNhaE)
Need support or have questions:
- GitHub Issues: Report bugs or request features here.
- Email: [developer@streamyfin.app](mailto:developer@streamyfin.app)
## ❓ FAQ ## ❓ FAQ
1. Q: Why can't I see my libraries in Streamyfin? 1. Q: Why can't I see my libraries in Streamyfin?
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only. A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only
2. Q: Why can't I see my music library? 2. Q: Why can't I see my music library?
A: We don't currently support music and are unlikely to support music in the near future. A: We don't currently support music and are unlikely to support music in the near future
## 📝 Credits ## 📝 Credits
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries. Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built using Expo, React Native, and other open-source libraries.
## ✨ Acknowledgements ## 🎖️ Core Developers
We would like to thank the Jellyfin team for their great software and awesome support on discord.
Special shoutout to the JF official clients for being an inspiration to ours.
### 🏆 Core Developers
Thanks to the following contributors for their significant contributions: Thanks to the following contributors for their significant contributions:
@@ -229,21 +218,41 @@ Thanks to the following contributors for their significant contributions:
</table> </table>
</div> </div>
And all other developers who have contributed to Streamyfin, thank you for your contributions. ## ✨ Acknowledgements
I'd also like to thank the following people and projects for their contributions to Streamyfin: We would like to thank the Jellyfin team for their excellent software and support on Discord.
Special thanks to the official Jellyfin clients, which have served as an inspiration for Streamyfin.
We also thank all other developers who have contributed to Streamyfin, your efforts are greatly appreciated.
A special mention to the following people and projects for their contributions:
- [Reiverr](https://github.com/aleksilassila/reiverr) for invaluable help with understanding the Jellyfin API
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for providing the TypeScript SDK
- [Seerr](https://github.com/seerr-team/seerr) for enabling API integration with their project
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
- The Jellyfin devs for always being helpful in the Discord.
## ⭐ Star History ## ⭐ Star History
[![Star History Chart](https://api.star-history.com/svg?repos=streamyfin/streamyfin&type=Date)](https://star-history.com/#streamyfin/streamyfin&Date) [![Star History Chart](https://api.star-history.com/svg?repos=streamyfin/streamyfin&type=Date)](https://star-history.com/#streamyfin/streamyfin&Date)
## 📄 License
Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0).
This means you are free to use, modify, and distribute this software. The MPL-2.0 is a copyleft license that allows for more flexibility in combining the software with proprietary code.
Key points of the MPL-2.0:
- You can use the software for any purpose
- You can modify the software and distribute modified versions
- You must include the original copyright and license notices
- You must disclose your source code for any modifications to the covered files
- Larger works may combine MPL code with code under other licenses
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license
- For the full text of the license, please see the LICENSE file in this repository
## ⚠️ Disclaimer ## ⚠️ Disclaimer
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions or support requests related to piracy are strictly prohibited across all our channels. Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions, support requests, or references to piracy, as well as any tools, software, or websites related to piracy, are strictly prohibited across all our channels.
## 🤝 Sponsorship ## 🤝 Sponsorship
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster) VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)

View File

@@ -1,23 +1,21 @@
module.exports = ({ config }) => { module.exports = ({ config }) => {
if (process.env.EXPO_TV === "1") { if (process.env.EXPO_TV !== "1") {
// Add TV-specific plugin for TV builds
config.plugins.push("@react-native-tvos/config-tv");
} else {
// Add non-TV specific plugins for phone builds
config.plugins.push("expo-background-task"); config.plugins.push("expo-background-task");
config.plugins.push([ config.plugins.push([
"react-native-google-cast", "react-native-google-cast",
{ useDefaultExpandedMediaControls: true }, { useDefaultExpandedMediaControls: true },
]); ]);
// Add the background downloader plugin only for non-TV builds
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
} }
// Only override googleServicesFile if env var is set
const androidConfig = {};
if (process.env.GOOGLE_SERVICES_JSON) {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
}
return { return {
android: { ...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
googleServicesFile: process.env.GOOGLE_SERVICES_JSON,
},
...config, ...config,
}; };
}; };

View File

@@ -2,12 +2,13 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.39.0", "version": "0.47.1",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"jsEngine": "hermes", "jsEngine": "hermes",
"newArchEnabled": true,
"assetBundlePatterns": ["**/*"], "assetBundlePatterns": ["**/*"],
"ios": { "ios": {
"requireFullScreen": true, "requireFullScreen": true,
@@ -28,16 +29,12 @@
}, },
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "com.fredrikburmester.streamyfin", "bundleIdentifier": "com.fredrikburmester.streamyfin",
"icon": { "icon": "./assets/images/icon-ios-liquid-glass.icon",
"dark": "./assets/images/icon-ios-plain.png",
"light": "./assets/images/icon-ios-light.png",
"tinted": "./assets/images/icon-ios-tinted.png"
},
"appleTeamId": "MWD5K362T8" "appleTeamId": "MWD5K362T8"
}, },
"android": { "android": {
"jsEngine": "hermes", "jsEngine": "hermes",
"versionCode": 71, "versionCode": 84,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png", "foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png", "monochromeImage": "./assets/images/icon-android-themed.png",
@@ -53,6 +50,7 @@
"googleServicesFile": "./google-services.json" "googleServicesFile": "./google-services.json"
}, },
"plugins": [ "plugins": [
"@react-native-tvos/config-tv",
"expo-router", "expo-router",
"expo-font", "expo-font",
[ [
@@ -76,6 +74,7 @@
"useFrameworks": "static" "useFrameworks": "static"
}, },
"android": { "android": {
"buildArchs": ["arm64-v8a", "x86_64"],
"compileSdkVersion": 35, "compileSdkVersion": 35,
"targetSdkVersion": 35, "targetSdkVersion": 35,
"buildToolsVersion": "35.0.0", "buildToolsVersion": "35.0.0",
@@ -114,10 +113,6 @@
} }
} }
], ],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
[ [
"expo-splash-screen", "expo-splash-screen",
{ {
@@ -133,8 +128,12 @@
"color": "#9333EA" "color": "#9333EA"
} }
], ],
"./plugins/with-runtime-framework-headers.js", "expo-web-browser",
"react-native-bottom-tabs" ["./plugins/with-runtime-framework-headers.js"],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"]
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true
@@ -153,7 +152,6 @@
}, },
"updates": { "updates": {
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68" "url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
}, }
"newArchEnabled": false
} }
} }

View File

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

View File

@@ -1,5 +1,5 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { RefreshControl, ScrollView, View } from "react-native"; import { Platform, RefreshControl, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Favorites } from "@/components/home/Favorites"; import { Favorites } from "@/components/home/Favorites";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
@@ -28,7 +28,7 @@ export default function favorites() {
paddingBottom: 16, paddingBottom: 16,
}} }}
> >
<View className='my-4'> <View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
<Favorites /> <Favorites />
</View> </View>
</ScrollView> </ScrollView>

View File

@@ -30,7 +30,6 @@ export default function IndexLayout() {
{!Platform.isTV && ( {!Platform.isTV && (
<> <>
<Chromecast.Chromecast background='transparent' /> <Chromecast.Chromecast background='transparent' />
{user?.Policy?.IsAdministrator && <SessionsButton />} {user?.Policy?.IsAdministrator && <SessionsButton />}
<SettingsButton /> <SettingsButton />
</> </>
@@ -42,49 +41,237 @@ export default function IndexLayout() {
<Stack.Screen <Stack.Screen
name='downloads/index' name='downloads/index'
options={{ options={{
headerShown: true,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
title: t("home.downloads.downloads_title"), title: t("home.downloads.downloads_title"),
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='downloads/[seriesId]' name='downloads/[seriesId]'
options={{ options={{
headerShown: true,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
title: t("home.downloads.tvseries"), title: t("home.downloads.tvseries"),
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='sessions/index' name='sessions/index'
options={{ options={{
title: t("home.sessions.title"), title: t("home.sessions.title"),
headerShown: true,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings' name='settings'
options={{ options={{
title: t("home.settings.settings_title"), title: t("home.settings.settings_title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/marlin-search/page' name='settings/playback-controls/page'
options={{ options={{
title: "", title: t("home.settings.playback_controls.title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/jellyseerr/page' name='settings/audio-subtitles/page'
options={{ options={{
title: "", title: t("home.settings.audio_subtitles.title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/hide-libraries/page' name='settings/appearance/page'
options={{ options={{
title: "", title: t("home.settings.appearance.title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/appearance/hide-libraries/page'
options={{
title: t("home.settings.other.hide_libraries"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/plugins/page'
options={{
title: t("home.settings.plugins.plugins_title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/plugins/marlin-search/page'
options={{
title: "Marlin Search",
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/plugins/jellyseerr/page'
options={{
title: "Jellyseerr",
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/intro/page'
options={{
title: t("home.settings.intro.title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/logs/page' name='settings/logs/page'
options={{ options={{
title: "", title: t("home.settings.logs.logs_title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
@@ -92,6 +279,11 @@ export default function IndexLayout() {
options={{ options={{
headerShown: false, headerShown: false,
title: "", title: "",
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
presentation: "modal", presentation: "modal",
}} }}
/> />
@@ -102,6 +294,11 @@ export default function IndexLayout() {
name='collections/[collectionId]' name='collections/[collectionId]'
options={{ options={{
title: "", title: "",
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
headerShown: true, headerShown: true,
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",

View File

@@ -1,8 +1,10 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { FlashList } from "@shopify/flash-list";
import { router, useLocalSearchParams, useNavigation } from "expo-router"; import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native"; import { Alert, Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { EpisodeCard } from "@/components/downloads/EpisodeCard"; import { EpisodeCard } from "@/components/downloads/EpisodeCard";
import { import {
@@ -23,21 +25,23 @@ export default function page() {
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>( const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
{}, {},
); );
const { getDownloadedItems, deleteItems } = useDownload(); const { downloadedItems, deleteItems } = useDownload();
const insets = useSafeAreaInsets();
const series = useMemo(() => { const series = useMemo(() => {
try { try {
return ( return (
getDownloadedItems() downloadedItems
?.filter((f) => f.item.SeriesId === seriesId) ?.filter((f) => f.item.SeriesId === seriesId)
?.sort( ?.sort(
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!, (a, b) =>
(a.item.ParentIndexNumber ?? 0) - (b.item.ParentIndexNumber ?? 0),
) || [] ) || []
); );
} catch { } catch {
return []; return [];
} }
}, [getDownloadedItems]); }, [downloadedItems, seriesId]);
// Group episodes by season in a single pass // Group episodes by season in a single pass
const seasonGroups = useMemo(() => { const seasonGroups = useMemo(() => {
@@ -70,8 +74,9 @@ export default function page() {
}, [seasonGroups]); }, [seasonGroups]);
const seasonIndex = const seasonIndex =
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] || seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ??
episodeSeasonIndex || episodeSeasonIndex ??
series?.[0]?.item?.ParentIndexNumber ??
""; "";
const groupBySeason = useMemo<BaseItemDto[]>(() => { const groupBySeason = useMemo<BaseItemDto[]>(() => {
@@ -80,9 +85,9 @@ export default function page() {
const initialSeasonIndex = useMemo( const initialSeasonIndex = useMemo(
() => () =>
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ?? groupBySeason?.[0]?.ParentIndexNumber ??
series?.[0]?.item?.ParentIndexNumber, series?.[0]?.item?.ParentIndexNumber,
[groupBySeason], [groupBySeason, series],
); );
useEffect(() => { useEffect(() => {
@@ -91,7 +96,7 @@ export default function page() {
title: series[0].item.SeriesName, title: series[0].item.SeriesName,
}); });
} else { } else {
storage.delete(seriesId); storage.remove(seriesId);
router.back(); router.back();
} }
}, [series]); }, [series]);
@@ -107,44 +112,70 @@ export default function page() {
}, },
{ {
text: "Delete", text: "Delete",
onPress: () => deleteItems(groupBySeason), onPress: () =>
deleteItems(
groupBySeason
.map((item) => item.Id)
.filter((id) => id !== undefined),
),
style: "destructive", style: "destructive",
}, },
], ],
); );
}, [groupBySeason]); }, [groupBySeason, deleteItems]);
const ListHeaderComponent = useCallback(() => {
if (series.length === 0) return null;
return (
<View className='flex flex-row items-center justify-start pb-2'>
<SeasonDropdown
item={series[0].item}
seasons={uniqueSeasons}
state={seasonIndexState}
initialSeasonIndex={initialSeasonIndex!}
onSelect={(season) => {
setSeasonIndexState((prev) => ({
...prev,
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
}));
}}
/>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'>
<Text className='text-xs font-bold'>{groupBySeason.length}</Text>
</View>
<View className='bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto'>
<TouchableOpacity onPress={deleteSeries}>
<Ionicons name='trash' size={20} color='white' />
</TouchableOpacity>
</View>
</View>
);
}, [
series,
uniqueSeasons,
seasonIndexState,
initialSeasonIndex,
groupBySeason,
deleteSeries,
]);
return ( return (
<View className='flex-1'> <View className='flex-1'>
{series.length > 0 && ( <FlashList
<View className='flex flex-row items-center justify-start my-2 px-4'> key={seasonIndex}
<SeasonDropdown data={groupBySeason}
item={series[0].item} renderItem={({ item }) => <EpisodeCard item={item} />}
seasons={uniqueSeasons} keyExtractor={(item, index) => item.Id ?? `episode-${index}`}
state={seasonIndexState} ListHeaderComponent={ListHeaderComponent}
initialSeasonIndex={initialSeasonIndex!} contentInsetAdjustmentBehavior='automatic'
onSelect={(season) => { contentContainerStyle={{
setSeasonIndexState((prev) => ({ paddingHorizontal: 16,
...prev, paddingLeft: insets.left + 16,
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber, paddingRight: insets.right + 16,
})); paddingTop: Platform.OS === "android" ? 10 : 8,
}} }}
/> />
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'>
<Text className='text-xs font-bold'>{groupBySeason.length}</Text>
</View>
<View className='bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto'>
<TouchableOpacity onPress={deleteSeries}>
<Ionicons name='trash' size={20} color='white' />
</TouchableOpacity>
</View>
</View>
)}
<ScrollView key={seasonIndex} className='px-4'>
{groupBySeason.map((episode, index) => (
<EpisodeCard key={index} item={episode} />
))}
</ScrollView>
</View> </View>
); );
} }

View File

@@ -1,17 +1,17 @@
import { Ionicons } from "@expo/vector-icons"; import { BottomSheetModal } from "@gorhom/bottom-sheet";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { useNavigation, useRouter } from "expo-router"; import { useNavigation, useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native"; import {
Alert,
Platform,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ActiveDownloads from "@/components/downloads/ActiveDownloads"; import ActiveDownloads from "@/components/downloads/ActiveDownloads";
@@ -26,18 +26,15 @@ import { writeToLog } from "@/utils/log";
export default function page() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation(); const { t } = useTranslation();
const [queue, setQueue] = useAtom(queueAtom); const [_queue, _setQueue] = useAtom(queueAtom);
const { const { downloadedItems, deleteFileByType, deleteAllFiles } = useDownload();
removeProcess,
getDownloadedItems,
deleteFileByType,
deleteAllFiles,
} = useDownload();
const router = useRouter(); const router = useRouter();
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [showMigration, setShowMigration] = useState(false); const [showMigration, setShowMigration] = useState(false);
const _insets = useSafeAreaInsets();
const migration_20241124 = () => { const migration_20241124 = () => {
Alert.alert( Alert.alert(
t("home.downloads.new_app_version_requires_re_download"), t("home.downloads.new_app_version_requires_re_download"),
@@ -62,7 +59,7 @@ export default function page() {
); );
}; };
const downloadedFiles = getDownloadedItems(); const downloadedFiles = useMemo(() => downloadedItems, [downloadedItems]);
const movies = useMemo(() => { const movies = useMemo(() => {
try { try {
@@ -106,7 +103,10 @@ export default function page() {
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}> <TouchableOpacity
onPress={bottomSheetModalRef.current?.present}
className='px-2'
>
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} /> <DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
</TouchableOpacity> </TouchableOpacity>
), ),
@@ -119,7 +119,7 @@ export default function page() {
} }
}, [showMigration]); }, [showMigration]);
const deleteMovies = () => const _deleteMovies = () =>
deleteFileByType("Movie") deleteFileByType("Movie")
.then(() => .then(() =>
toast.success( toast.success(
@@ -130,7 +130,7 @@ export default function page() {
writeToLog("ERROR", reason); writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies")); toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
}); });
const deleteShows = () => const _deleteShows = () =>
deleteFileByType("Episode") deleteFileByType("Episode")
.then(() => .then(() =>
toast.success( toast.success(
@@ -141,38 +141,39 @@ export default function page() {
writeToLog("ERROR", reason); writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries")); toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
}); });
const deleteOtherMedia = () => const _deleteOtherMedia = () =>
Promise.all( Promise.all(
otherMedia.map((item) => otherMedia
deleteFileByType(item.item.Type) .filter((item) => item.item.Type)
.then(() => .map((item) =>
toast.success( deleteFileByType(item.item.Type!)
t("home.downloads.toasts.deleted_media_successfully", { .then(() =>
type: item.item.Type, toast.success(
}), t("home.downloads.toasts.deleted_media_successfully", {
), type: item.item.Type,
) }),
.catch((reason) => { ),
writeToLog("ERROR", reason); )
toast.error( .catch((reason) => {
t("home.downloads.toasts.failed_to_delete_media", { writeToLog("ERROR", reason);
type: item.item.Type, toast.error(
}), t("home.downloads.toasts.failed_to_delete_media", {
); type: item.item.Type,
}), }),
), );
}),
),
); );
const deleteAllMedia = async () =>
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
return ( return (
<> <ScrollView
<View style={{ flex: 1 }}> showsVerticalScrollIndicator={false}
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'> contentInsetAdjustmentBehavior='automatic'
<View className='py-4'> >
<View className='mb-4 flex flex-col space-y-4 px-4'> <View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
<View className='bg-neutral-900 p-4 rounded-2xl'> <View className='mb-4 flex flex-col space-y-4 px-4'>
{/* Queue card - hidden */}
{/* <View className='bg-neutral-900 p-4 rounded-2xl'>
<Text className='text-lg font-bold'> <Text className='text-lg font-bold'>
{t("home.downloads.queue")} {t("home.downloads.queue")}
</Text> </Text>
@@ -214,139 +215,96 @@ export default function page() {
{t("home.downloads.no_items_in_queue")} {t("home.downloads.no_items_in_queue")}
</Text> </Text>
)} )}
</View> </View> */}
<ActiveDownloads /> <ActiveDownloads />
</View>
{movies.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.movies")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{movies?.length}</Text>
</View>
</View> </View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
{movies.length > 0 && ( <View className='px-4 flex flex-row'>
<View className='mb-4'> {movies?.map((item) => (
<View className='flex flex-row items-center justify-between mb-2 px-4'> <TouchableItemRouter
<Text className='text-lg font-bold'> item={item.item}
{t("home.downloads.movies")} isOffline
</Text> key={item.item.Id}
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'> >
<Text className='text-xs font-bold'>{movies?.length}</Text> <MovieCard item={item.item} />
</View> </TouchableItemRouter>
</View> ))}
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{movies?.map((item) => (
<TouchableItemRouter
item={item.item}
isOffline
key={item.item.Id}
>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
</View>
</ScrollView>
</View> </View>
)} </ScrollView>
{groupedBySeries.length > 0 && ( </View>
<View className='mb-4'> )}
<View className='flex flex-row items-center justify-between mb-2 px-4'> {groupedBySeries.length > 0 && (
<Text className='text-lg font-bold'> <View className='mb-4'>
{t("home.downloads.tvseries")} <View className='flex flex-row items-center justify-between mb-2 px-4'>
</Text> <Text className='text-lg font-bold'>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'> {t("home.downloads.tvseries")}
<Text className='text-xs font-bold'> </Text>
{groupedBySeries?.length} <View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
</Text> <Text className='text-xs font-bold'>
</View> {groupedBySeries?.length}
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{groupedBySeries?.map((items) => (
<View
className='mb-2 last:mb-0'
key={items[0].item.SeriesId}
>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
</View>
))}
</View>
</ScrollView>
</View>
)}
{otherMedia.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.other_media")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{otherMedia?.length}
</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{otherMedia?.map((item) => (
<TouchableItemRouter
item={item.item}
isOffline
key={item.item.Id}
>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
</View>
</ScrollView>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className='flex px-4'>
<Text className='opacity-50'>
{t("home.downloads.no_downloaded_items")}
</Text> </Text>
</View> </View>
)} </View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{groupedBySeries?.map((items) => (
<View className='mb-2 last:mb-0' key={items[0].item.SeriesId}>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
</View>
))}
</View>
</ScrollView>
</View> </View>
</ScrollView>
</View>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)} )}
>
<BottomSheetView> {otherMedia.length > 0 && (
<View className='p-4 space-y-4 mb-4'> <View className='mb-4'>
<Button color='purple' onPress={deleteMovies}> <View className='flex flex-row items-center justify-between mb-2 px-4'>
{t("home.downloads.delete_all_movies_button")} <Text className='text-lg font-bold'>
</Button> {t("home.downloads.other_media")}
<Button color='purple' onPress={deleteShows}> </Text>
{t("home.downloads.delete_all_tvseries_button")} <View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
</Button> <Text className='text-xs font-bold'>{otherMedia?.length}</Text>
{otherMedia.length > 0 && ( </View>
<Button color='purple' onPress={deleteOtherMedia}> </View>
{t("home.downloads.delete_all_other_media_button")} <ScrollView horizontal showsHorizontalScrollIndicator={false}>
</Button> <View className='px-4 flex flex-row'>
)} {otherMedia?.map((item) => (
<Button color='red' onPress={deleteAllMedia}> <TouchableItemRouter
{t("home.downloads.delete_all_button")} item={item.item}
</Button> isOffline
key={item.item.Id}
>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
</View>
</ScrollView>
</View> </View>
</BottomSheetView> )}
</BottomSheetModal> {downloadedFiles?.length === 0 && (
</> <View className='flex px-4'>
<Text className='opacity-50'>
{t("home.downloads.no_downloaded_items")}
</Text>
</View>
)}
</View>
</ScrollView>
); );
} }

View File

@@ -1,5 +1,16 @@
import { HomeIndex } from "@/components/settings/HomeIndex"; import { useSettings } from "@/utils/atoms/settings";
import { Home } from "../../../../components/home/Home";
import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
export default function page() { const Index = () => {
return <HomeIndex />; const { settings } = useSettings();
} const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
if (showLargeHomeCarousel) {
return <HomeWithCarousel />;
}
return <Home />;
};
export default Index;

View File

@@ -1,11 +1,9 @@
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { import { HardwareAccelerationType } from "@jellyfin/sdk/lib/generated-client";
HardwareAccelerationType,
type SessionInfoDto,
} from "@jellyfin/sdk/lib/generated-client";
import { import {
GeneralCommandType, GeneralCommandType,
PlaystateCommand, PlaystateCommand,
SessionInfoDto,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
@@ -13,7 +11,7 @@ import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { Badge } from "@/components/Badge"; import { Badge } from "@/components/Badge";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
@@ -49,14 +47,13 @@ export default function page() {
<FlashList <FlashList
contentInsetAdjustmentBehavior='automatic' contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{ contentContainerStyle={{
paddingTop: 17, paddingTop: Platform.OS === "android" ? 17 : 0,
paddingHorizontal: 17, paddingHorizontal: 17,
paddingBottom: 150, paddingBottom: 150,
}} }}
data={sessions} data={sessions}
renderItem={({ item }) => <SessionCard session={item} />} renderItem={({ item }) => <SessionCard session={item} />}
keyExtractor={(item) => item.Id || ""} keyExtractor={(item) => item.Id || ""}
estimatedItemSize={200}
/> />
); );
} }

View File

@@ -8,34 +8,16 @@ import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector"; import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { AudioToggles } from "@/components/settings/AudioToggles";
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
import DownloadSettings from "@/components/settings/DownloadSettings";
import { GestureControls } from "@/components/settings/GestureControls";
import { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles";
import { OtherSettings } from "@/components/settings/OtherSettings";
import { PluginSettings } from "@/components/settings/PluginSettings";
import { QuickConnect } from "@/components/settings/QuickConnect"; import { QuickConnect } from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings"; import { StorageSettings } from "@/components/settings/StorageSettings";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { UserInfo } from "@/components/settings/UserInfo"; import { UserInfo } from "@/components/settings/UserInfo";
import { useHaptic } from "@/hooks/useHaptic";
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
import { storage } from "@/utils/mmkv";
export default function settings() { export default function settings() {
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [_user] = useAtom(userAtom); const [_user] = useAtom(userAtom);
const { logout } = useJellyfin(); const { logout } = useJellyfin();
const successHapticFeedback = useHaptic("success");
const onClearLogsClicked = async () => {
clearLogs();
successHapticFeedback();
};
const navigation = useNavigation(); const navigation = useNavigation();
useEffect(() => { useEffect(() => {
@@ -46,7 +28,7 @@ export default function settings() {
logout(); logout();
}} }}
> >
<Text className='text-red-600'> <Text className='text-red-600 px-2'>
{t("home.settings.log_out_button")} {t("home.settings.log_out_button")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -56,61 +38,58 @@ export default function settings() {
return ( return (
<ScrollView <ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{ contentContainerStyle={{
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<View className='p-4 flex flex-col gap-y-4'> <View
<UserInfo /> className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<View className='mb-4'>
<UserInfo />
</View>
<QuickConnect className='mb-4' /> <QuickConnect className='mb-4' />
<MediaProvider> <View className='mb-4'>
<MediaToggles className='mb-4' /> <AppLanguageSelector />
<GestureControls className='mb-4' /> </View>
<AudioToggles className='mb-4' />
<SubtitleToggles className='mb-4' />
</MediaProvider>
<OtherSettings />
{!Platform.isTV && <DownloadSettings />}
<PluginSettings />
<AppLanguageSelector />
{!Platform.isTV && <ChromecastSettings />}
<ListGroup title={"Intro"}>
<ListItem
onPress={() => {
router.push("/intro/page");
}}
title={t("home.settings.intro.show_intro")}
/>
<ListItem
textColor='red'
onPress={() => {
storage.set("hasShownIntro", false);
}}
title={t("home.settings.intro.reset_intro")}
/>
</ListGroup>
<View className='mb-4'> <View className='mb-4'>
<ListGroup title={t("home.settings.logs.logs_title")}> <ListGroup title={t("home.settings.categories.title")}>
<ListItem
onPress={() => router.push("/settings/playback-controls/page")}
showArrow
title={t("home.settings.playback_controls.title")}
/>
<ListItem
onPress={() => router.push("/settings/audio-subtitles/page")}
showArrow
title={t("home.settings.audio_subtitles.title")}
/>
<ListItem
onPress={() => router.push("/settings/appearance/page")}
showArrow
title={t("home.settings.appearance.title")}
/>
<ListItem
onPress={() => router.push("/settings/plugins/page")}
showArrow
title={t("home.settings.plugins.plugins_title")}
/>
<ListItem
onPress={() => router.push("/settings/intro/page")}
showArrow
title={t("home.settings.intro.title")}
/>
<ListItem <ListItem
onPress={() => router.push("/settings/logs/page")} onPress={() => router.push("/settings/logs/page")}
showArrow showArrow
title={t("home.settings.logs.logs_title")} title={t("home.settings.logs.logs_title")}
/> />
<ListItem
textColor='red'
onPress={onClearLogsClicked}
title={t("home.settings.logs.delete_all_logs")}
/>
</ListGroup> </ListGroup>
</View> </View>

View File

@@ -0,0 +1,79 @@
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { ScrollView, Switch, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const { settings, updateSettings, pluginSettings } = useSettings();
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const { data, isLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({
userId: user?.Id,
});
return response.data.Items || null;
},
});
if (!settings) return null;
if (isLoading)
return (
<View className='mt-4'>
<Loader />
</View>
);
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<DisabledSetting
disabled={pluginSettings?.hiddenLibraries?.locked === true}
className='px-4'
>
<ListGroup title={t("home.settings.other.hide_libraries")}>
{data?.map((view) => (
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
<Switch
value={settings.hiddenLibraries?.includes(view.Id!) || false}
onValueChange={(value) => {
updateSettings({
hiddenLibraries: value
? [...(settings.hiddenLibraries || []), view.Id!]
: settings.hiddenLibraries?.filter(
(id) => id !== view.Id,
),
});
}}
/>
</ListItem>
))}
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_liraries_you_want_to_hide")}
</Text>
</DisabledSetting>
</ScrollView>
);
}

View File

@@ -0,0 +1,25 @@
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppearanceSettings } from "@/components/settings/AppearanceSettings";
export default function AppearancePage() {
const insets = useSafeAreaInsets();
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<AppearanceSettings />
<View className='h-24' />
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,29 @@
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AudioToggles } from "@/components/settings/AudioToggles";
import { MediaProvider } from "@/components/settings/MediaContext";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
export default function AudioSubtitlesPage() {
const insets = useSafeAreaInsets();
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<MediaProvider>
<AudioToggles className='mb-4' />
<SubtitleToggles className='mb-4' />
</MediaProvider>
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,45 @@
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { storage } from "@/utils/mmkv";
export default function IntroPage() {
const router = useRouter();
const insets = useSafeAreaInsets();
const { t } = useTranslation();
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<ListGroup title={t("home.settings.intro.title")}>
<ListItem
onPress={() => {
router.push("/intro/page");
}}
title={t("home.settings.intro.show_intro")}
/>
<ListItem
textColor='red'
onPress={() => {
storage.set("hasShownIntro", false);
}}
title={t("home.settings.intro.reset_intro")}
/>
</ListGroup>
<View className='h-24' />
</View>
</ScrollView>
);
}

View File

@@ -1,16 +0,0 @@
import DisabledSetting from "@/components/settings/DisabledSetting";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const { pluginSettings } = useSettings();
return (
<DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className='p-4'
>
<JellyseerrSettings />
</DisabledSetting>
);
}

View File

@@ -1,15 +1,21 @@
import * as FileSystem from "expo-file-system"; import { File, Paths } from "expo-file-system";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import * as Sharing from "expo-sharing"; import type * as SharingType from "expo-sharing";
import { useCallback, useEffect, useId, useMemo, useState } from "react"; import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollView, TouchableOpacity, View } from "react-native"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import Collapsible from "react-native-collapsible"; import Collapsible from "react-native-collapsible";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { FilterButton } from "@/components/filters/FilterButton"; import { FilterButton } from "@/components/filters/FilterButton";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { LogLevel, useLog, writeErrorLog } from "@/utils/log"; import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
// Conditionally import expo-sharing only on non-TV platforms
const Sharing = Platform.isTV
? null
: (require("expo-sharing") as typeof SharingType);
export default function Page() { export default function Page() {
const navigation = useNavigation(); const navigation = useNavigation();
const { logs } = useLog(); const { logs } = useLog();
@@ -33,6 +39,7 @@ export default function Page() {
const _orderId = useId(); const _orderId = useId();
const _levelsId = useId(); const _levelsId = useId();
const insets = useSafeAreaInsets();
const filteredLogs = useMemo( const filteredLogs = useMemo(
() => () =>
@@ -47,27 +54,30 @@ export default function Page() {
// Sharing it as txt while its formatted allows us to share it with many more applications // Sharing it as txt while its formatted allows us to share it with many more applications
const share = useCallback(async () => { const share = useCallback(async () => {
const uri = `${FileSystem.documentDirectory}logs.txt`; if (!Sharing) return;
const logsFile = new File(Paths.document, "logs.txt");
setLoading(true); setLoading(true);
FileSystem.writeAsStringAsync(uri, JSON.stringify(filteredLogs)) try {
.then(() => { logsFile.write(JSON.stringify(filteredLogs));
setLoading(false); await Sharing.shareAsync(logsFile.uri, { mimeType: "txt", UTI: "txt" });
Sharing.shareAsync(uri, { mimeType: "txt", UTI: "txt" }); } catch (e: any) {
}) writeErrorLog("Something went wrong attempting to export", e);
.catch((e) => } finally {
writeErrorLog("Something went wrong attempting to export", e), setLoading(false);
) }
.finally(() => setLoading(false)); }, [filteredLogs, Sharing]);
}, [filteredLogs]);
useEffect(() => { useEffect(() => {
if (Platform.isTV) return;
navigation.setOptions({ navigation.setOptions({
headerRight: () => headerRight: () =>
loading ? ( loading ? (
<Loader /> <Loader />
) : ( ) : (
<TouchableOpacity onPress={share}> <TouchableOpacity onPress={share} className='px-2'>
<Text>{t("home.settings.logs.export_logs")}</Text> <Text>{t("home.settings.logs.export_logs")}</Text>
</TouchableOpacity> </TouchableOpacity>
), ),
@@ -75,7 +85,12 @@ export default function Page() {
}, [share, loading]); }, [share, loading]);
return ( return (
<> <View
className='flex-1'
style={{
paddingTop: insets.top + 48,
}}
>
<View className='flex flex-row justify-end py-2 px-4 space-x-2'> <View className='flex flex-row justify-end py-2 px-4 space-x-2'>
<FilterButton <FilterButton
id={orderFilterId} id={orderFilterId}
@@ -157,6 +172,6 @@ export default function Page() {
)} )}
</View> </View>
</ScrollView> </ScrollView>
</> </View>
); );
} }

View File

@@ -1,122 +0,0 @@
import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Linking,
Switch,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const navigation = useNavigation();
const { t } = useTranslation();
const { settings, updateSettings, pluginSettings } = useSettings();
const queryClient = useQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
const onSave = (val: string) => {
updateSettings({
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
});
toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
};
const handleOpenLink = () => {
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
};
const disabled = useMemo(() => {
return (
pluginSettings?.searchEngine?.locked === true &&
pluginSettings?.marlinServerUrl?.locked === true
);
}, [pluginSettings]);
useEffect(() => {
if (!pluginSettings?.marlinServerUrl?.locked) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)}>
<Text className='text-blue-500'>
{t("home.settings.plugins.marlin_search.save_button")}
</Text>
</TouchableOpacity>
),
});
}
}, [navigation, value]);
if (!settings) return null;
return (
<DisabledSetting disabled={disabled} className='px-4'>
<ListGroup>
<DisabledSetting
disabled={pluginSettings?.searchEngine?.locked === true}
showText={!pluginSettings?.marlinServerUrl?.locked}
>
<ListItem
title={t(
"home.settings.plugins.marlin_search.enable_marlin_search",
)}
onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<Switch
value={settings.searchEngine === "Marlin"}
onValueChange={(value) => {
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
/>
</ListItem>
</DisabledSetting>
</ListGroup>
<DisabledSetting
disabled={pluginSettings?.marlinServerUrl?.locked === true}
showText={!pluginSettings?.searchEngine?.locked}
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
>
<View className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}>
<Text className='mr-4'>
{t("home.settings.plugins.marlin_search.url")}
</Text>
<TextInput
editable={settings.searchEngine === "Marlin"}
className='text-white'
placeholder={t(
"home.settings.plugins.marlin_search.server_url_placeholder",
)}
value={value}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={(text) => setValue(text)}
/>
</View>
</DisabledSetting>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
<Text className='text-blue-500' onPress={handleOpenLink}>
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
</Text>
</Text>
</DisabledSetting>
);
}

View File

@@ -0,0 +1,35 @@
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { GestureControls } from "@/components/settings/GestureControls";
import { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles";
import { PlaybackControlsSettings } from "@/components/settings/PlaybackControlsSettings";
import { ChromecastSettings } from "../../../../../../components/settings/ChromecastSettings";
export default function PlaybackControlsPage() {
const insets = useSafeAreaInsets();
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<View className='mb-4'>
<MediaProvider>
<MediaToggles className='mb-4' />
<GestureControls className='mb-4' />
<PlaybackControlsSettings />
</MediaProvider>
</View>
{!Platform.isTV && <ChromecastSettings />}
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,27 @@
import { ScrollView } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets();
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className='px-4'
>
<JellyseerrSettings />
</DisabledSetting>
</ScrollView>
);
}

View File

@@ -0,0 +1,138 @@
import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Linking,
ScrollView,
Switch,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const navigation = useNavigation();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings();
const queryClient = useQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
const onSave = (val: string) => {
updateSettings({
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
});
toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
};
const handleOpenLink = () => {
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
};
const disabled = useMemo(() => {
return (
pluginSettings?.searchEngine?.locked === true &&
pluginSettings?.marlinServerUrl?.locked === true
);
}, [pluginSettings]);
useEffect(() => {
if (!pluginSettings?.marlinServerUrl?.locked) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
<Text className='text-blue-500'>
{t("home.settings.plugins.marlin_search.save_button")}
</Text>
</TouchableOpacity>
),
});
}
}, [navigation, value]);
if (!settings) return null;
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<DisabledSetting disabled={disabled} className='px-4'>
<ListGroup>
<DisabledSetting
disabled={pluginSettings?.searchEngine?.locked === true}
showText={!pluginSettings?.marlinServerUrl?.locked}
>
<ListItem
title={t(
"home.settings.plugins.marlin_search.enable_marlin_search",
)}
onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<Switch
value={settings.searchEngine === "Marlin"}
onValueChange={(value) => {
updateSettings({
searchEngine: value ? "Marlin" : "Jellyfin",
});
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
/>
</ListItem>
</DisabledSetting>
</ListGroup>
<DisabledSetting
disabled={pluginSettings?.marlinServerUrl?.locked === true}
showText={!pluginSettings?.searchEngine?.locked}
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
>
<View
className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}
>
<Text className='mr-4'>
{t("home.settings.plugins.marlin_search.url")}
</Text>
<TextInput
editable={settings.searchEngine === "Marlin"}
className='text-white'
placeholder={t(
"home.settings.plugins.marlin_search.server_url_placeholder",
)}
value={value}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={(text) => setValue(text)}
/>
</View>
</DisabledSetting>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
<Text className='text-blue-500' onPress={handleOpenLink}>
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
</Text>
</Text>
</DisabledSetting>
</ScrollView>
);
}

View File

@@ -0,0 +1,24 @@
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { PluginSettings } from "@/components/settings/PluginSettings";
export default function PluginsPage() {
const insets = useSafeAreaInsets();
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View
className='px-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<PluginSettings />
</View>
</ScrollView>
);
}

View File

@@ -16,6 +16,7 @@ import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FlatList, View } from "react-native"; import { FlatList, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton"; import { FilterButton } from "@/components/filters/FilterButton";
@@ -204,154 +205,154 @@ const page: React.FC = () => {
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const _insets = useSafeAreaInsets();
const ListHeaderComponent = useCallback( const ListHeaderComponent = useCallback(
() => ( () => (
<View className=''> <FlatList
<FlatList horizontal
horizontal showsHorizontalScrollIndicator={false}
showsHorizontalScrollIndicator={false} contentContainerStyle={{
contentContainerStyle={{ display: "flex",
display: "flex", paddingHorizontal: 15,
paddingHorizontal: 15, paddingVertical: 16,
paddingVertical: 16, flexDirection: "row",
flexDirection: "row", }}
}} extraData={[
extraData={[ selectedGenres,
selectedGenres, selectedYears,
selectedYears, selectedTags,
selectedTags, sortBy,
sortBy, sortOrder,
sortOrder, ]}
]} data={[
data={[ {
{ key: "reset",
key: "reset", component: <ResetFiltersButton />,
component: <ResetFiltersButton />, },
}, {
{ key: "genre",
key: "genre", component: (
component: ( <FilterButton
<FilterButton className='mr-1'
className='mr-1' id={collectionId}
id={collectionId} queryKey='genreFilter'
queryKey='genreFilter' queryFn={async () => {
queryFn={async () => { if (!api) return null;
if (!api) return null; const response = await getFilterApi(
const response = await getFilterApi( api,
api, ).getQueryFiltersLegacy({
).getQueryFiltersLegacy({ userId: user?.Id,
userId: user?.Id, parentId: collectionId,
parentId: collectionId, });
}); return response.data.Genres || [];
return response.data.Genres || []; }}
}} set={setSelectedGenres}
set={setSelectedGenres} values={selectedGenres}
values={selectedGenres} title={t("library.filters.genres")}
title={t("library.filters.genres")} renderItemLabel={(item) => item.toString()}
renderItemLabel={(item) => item.toString()} searchFilter={(item, search) =>
searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase())
item.toLowerCase().includes(search.toLowerCase()) }
} />
/> ),
), },
}, {
{ key: "year",
key: "year", component: (
component: ( <FilterButton
<FilterButton className='mr-1'
className='mr-1' id={collectionId}
id={collectionId} queryKey='yearFilter'
queryKey='yearFilter' queryFn={async () => {
queryFn={async () => { if (!api) return null;
if (!api) return null; const response = await getFilterApi(
const response = await getFilterApi( api,
api, ).getQueryFiltersLegacy({
).getQueryFiltersLegacy({ userId: user?.Id,
userId: user?.Id, parentId: collectionId,
parentId: collectionId, });
}); return response.data.Years || [];
return response.data.Years || []; }}
}} set={setSelectedYears}
set={setSelectedYears} values={selectedYears}
values={selectedYears} title={t("library.filters.years")}
title={t("library.filters.years")} renderItemLabel={(item) => item.toString()}
renderItemLabel={(item) => item.toString()} searchFilter={(item, search) => item.includes(search)}
searchFilter={(item, search) => item.includes(search)} />
/> ),
), },
}, {
{ key: "tags",
key: "tags", component: (
component: ( <FilterButton
<FilterButton className='mr-1'
className='mr-1' id={collectionId}
id={collectionId} queryKey='tagsFilter'
queryKey='tagsFilter' queryFn={async () => {
queryFn={async () => { if (!api) return null;
if (!api) return null; const response = await getFilterApi(
const response = await getFilterApi( api,
api, ).getQueryFiltersLegacy({
).getQueryFiltersLegacy({ userId: user?.Id,
userId: user?.Id, parentId: collectionId,
parentId: collectionId, });
}); return response.data.Tags || [];
return response.data.Tags || []; }}
}} set={setSelectedTags}
set={setSelectedTags} values={selectedTags}
values={selectedTags} title={t("library.filters.tags")}
title={t("library.filters.tags")} renderItemLabel={(item) => item.toString()}
renderItemLabel={(item) => item.toString()} searchFilter={(item, search) =>
searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase())
item.toLowerCase().includes(search.toLowerCase()) }
} />
/> ),
), },
}, {
{ key: "sortBy",
key: "sortBy", component: (
component: ( <FilterButton
<FilterButton className='mr-1'
className='mr-1' id={collectionId}
id={collectionId} queryKey='sortBy'
queryKey='sortBy' queryFn={async () => sortOptions.map((s) => s.key)}
queryFn={async () => sortOptions.map((s) => s.key)} set={setSortBy}
set={setSortBy} values={sortBy}
values={sortBy} title={t("library.filters.sort_by")}
title={t("library.filters.sort_by")} renderItemLabel={(item) =>
renderItemLabel={(item) => sortOptions.find((i) => i.key === item)?.value || ""
sortOptions.find((i) => i.key === item)?.value || "" }
} searchFilter={(item, search) =>
searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase())
item.toLowerCase().includes(search.toLowerCase()) }
} />
/> ),
), },
}, {
{ key: "sortOrder",
key: "sortOrder", component: (
component: ( <FilterButton
<FilterButton className='mr-1'
className='mr-1' id={collectionId}
id={collectionId} queryKey='sortOrder'
queryKey='sortOrder' queryFn={async () => sortOrderOptions.map((s) => s.key)}
queryFn={async () => sortOrderOptions.map((s) => s.key)} set={setSortOrder}
set={setSortOrder} values={sortOrder}
values={sortOrder} title={t("library.filters.sort_order")}
title={t("library.filters.sort_order")} renderItemLabel={(item) =>
renderItemLabel={(item) => sortOrderOptions.find((i) => i.key === item)?.value || ""
sortOrderOptions.find((i) => i.key === item)?.value || "" }
} searchFilter={(item, search) =>
searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase())
item.toLowerCase().includes(search.toLowerCase()) }
} />
/> ),
), },
}, ]}
]} renderItem={({ item }) => item.component}
renderItem={({ item }) => item.component} keyExtractor={(item) => item.key}
keyExtractor={(item) => item.key} />
/>
</View>
), ),
[ [
collectionId, collectionId,
@@ -393,7 +394,6 @@ const page: React.FC = () => {
data={flatData} data={flatData}
renderItem={renderItem} renderItem={renderItem}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
estimatedItemSize={255}
numColumns={ numColumns={
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5 orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
} }

View File

@@ -19,31 +19,29 @@ import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags"; import { GenreTags } from "@/components/GenreTags";
import Cast from "@/components/jellyseerr/Cast"; import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts"; import DetailFacts from "@/components/jellyseerr/DetailFacts";
import RequestModal from "@/components/jellyseerr/RequestModal";
import { OverviewText } from "@/components/OverviewText"; import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { JellyserrRatings } from "@/components/Ratings"; import { JellyserrRatings } from "@/components/Ratings";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons"; import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions"; import { ItemActions } from "@/components/series/SeriesActions";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest"; import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import { import {
type IssueType, type IssueType,
IssueTypeName, IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue"; } from "@/utils/jellyseerr/server/constants/issue";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type { import type {
MovieResult, MovieResult,
TvResult, TvResult,
} from "@/utils/jellyseerr/server/models/Search"; } from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import RequestModal from "@/components/jellyseerr/RequestModal";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
const Page: React.FC = () => { const Page: React.FC = () => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
@@ -65,6 +63,7 @@ const Page: React.FC = () => {
const [issueType, setIssueType] = useState<IssueType>(); const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>(); const [issueMessage, setIssueMessage] = useState<string>();
const [requestBody, _setRequestBody] = useState<MediaRequestBody>(); const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
const advancedReqModalRef = useRef<BottomSheetModal>(null); const advancedReqModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -115,6 +114,10 @@ const Page: React.FC = () => {
} }
}, [jellyseerrApi, details, result, issueType, issueMessage]); }, [jellyseerrApi, details, result, issueType, issueMessage]);
const handleIssueModalDismiss = useCallback(() => {
setIssueTypeDropdownOpen(false);
}, []);
const setRequestBody = useCallback( const setRequestBody = useCallback(
(body: MediaRequestBody) => { (body: MediaRequestBody) => {
_setRequestBody(body); _setRequestBody(body);
@@ -128,9 +131,11 @@ const Page: React.FC = () => {
mediaId: Number(result.id!), mediaId: Number(result.id!),
mediaType: mediaType!, mediaType: mediaType!,
tvdbId: details?.externalIds?.tvdbId, tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons ...(mediaType === MediaType.TV && {
?.filter?.((s) => s.seasonNumber !== 0) seasons: (details as TvDetails)?.seasons
?.map?.((s) => s.seasonNumber), ?.filter?.((s) => s.seasonNumber !== 0)
?.map?.((s) => s.seasonNumber),
}),
}; };
if (hasAdvancedRequestPermission) { if (hasAdvancedRequestPermission) {
@@ -156,11 +161,31 @@ const Page: React.FC = () => {
[details], [details],
); );
const issueTypeOptionGroups = useMemo(
() => [
{
title: t("jellyseerr.types"),
options: Object.entries(IssueTypeName)
.reverse()
.map(([key, value]) => ({
type: "radio" as const,
label: value,
value: key,
selected: key === String(issueType),
onPress: () => setIssueType(key as unknown as IssueType),
})),
},
],
[issueType, t],
);
useEffect(() => { useEffect(() => {
if (details) { if (details) {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'> <TouchableOpacity
className={`rounded-full pl-1.5 ${Platform.OS === "android" ? "" : "bg-neutral-800/80"}`}
>
<ItemActions item={details} /> <ItemActions item={details} />
</TouchableOpacity> </TouchableOpacity>
), ),
@@ -355,6 +380,8 @@ const Page: React.FC = () => {
backgroundColor: "#171717", backgroundColor: "#171717",
}} }}
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
stackBehavior='push'
onDismiss={handleIssueModalDismiss}
> >
<BottomSheetView> <BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'> <View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
@@ -364,50 +391,25 @@ const Page: React.FC = () => {
</Text> </Text>
</View> </View>
<View className='flex flex-col space-y-2 items-start'> <View className='flex flex-col space-y-2 items-start'>
<View className='flex flex-col'> <View className='flex flex-col w-full'>
<DropdownMenu.Root> <Text className='opacity-50 mb-1 text-xs'>
<DropdownMenu.Trigger> {t("jellyseerr.issue_type")}
<View className='flex flex-col'> </Text>
<Text className='opacity-50 mb-1 text-xs'> <PlatformDropdown
{t("jellyseerr.issue_type")} groups={issueTypeOptionGroups}
trigger={
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
</Text> </Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
</Text>
</TouchableOpacity>
</View> </View>
</DropdownMenu.Trigger> }
<DropdownMenu.Content title={t("jellyseerr.types")}
loop={false} open={issueTypeDropdownOpen}
side='bottom' onOpenChange={setIssueTypeDropdownOpen}
align='center' />
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>
{t("jellyseerr.types")}
</DropdownMenu.Label>
{Object.entries(IssueTypeName)
.reverse()
.map(([key, value], _idx) => (
<DropdownMenu.Item
key={value}
onSelect={() =>
setIssueType(key as unknown as IssueType)
}
>
<DropdownMenu.ItemTitle>
{value}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View> </View>
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'> <View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>

View File

@@ -87,14 +87,15 @@ export default function page() {
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text> <Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
<Text className='opacity-50'> <Text className='opacity-50'>
{t("jellyseerr.born")}{" "} {t("jellyseerr.born")}{" "}
{new Date(data?.details?.birthday!).toLocaleDateString( {data?.details?.birthday &&
`${locale}-${region}`, new Date(data.details.birthday).toLocaleDateString(
{ `${locale}-${region}`,
year: "numeric", {
month: "long", year: "numeric",
day: "numeric", month: "long",
}, day: "numeric",
)}{" "} },
)}{" "}
| {data?.details?.placeOfBirth} | {data?.details?.placeOfBirth}
</Text> </Text>
</> </>

View File

@@ -33,7 +33,6 @@ export default function page() {
<View className='flex flex-1'> <View className='flex flex-1'>
<FlashList <FlashList
data={channels?.Items} data={channels?.Items}
estimatedItemSize={76}
renderItem={({ item }) => ( renderItem={({ item }) => (
<View className='flex flex-row items-center px-4 mb-2'> <View className='flex flex-row items-center px-4 mb-2'>
<View className='w-22 mr-4 rounded-lg overflow-hidden'> <View className='w-22 mr-4 rounded-lg overflow-hidden'>

View File

@@ -65,9 +65,11 @@ const page: React.FC = () => {
const { data: allEpisodes, isLoading } = useQuery({ const { data: allEpisodes, isLoading } = useQuery({
queryKey: ["AllEpisodes", item?.Id], queryKey: ["AllEpisodes", item?.Id],
queryFn: async () => { queryFn: async () => {
const res = await getTvShowsApi(api!).getEpisodes({ if (!api || !user?.Id || !item?.Id) return [];
seriesId: item?.Id!,
userId: user?.Id!, const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.Id,
userId: user.Id,
enableUserData: true, enableUserData: true,
// Note: Including trick play is necessary to enable trick play downloads // Note: Including trick play is necessary to enable trick play downloads
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"], fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],

View File

@@ -271,145 +271,143 @@ const Page = () => {
const ListHeaderComponent = useCallback( const ListHeaderComponent = useCallback(
() => ( () => (
<View className=''> <FlatList
<FlatList horizontal
horizontal showsHorizontalScrollIndicator={false}
showsHorizontalScrollIndicator={false} contentContainerStyle={{
contentContainerStyle={{ display: "flex",
display: "flex", paddingHorizontal: 15,
paddingHorizontal: 15, paddingVertical: 16,
paddingVertical: 16, flexDirection: "row",
flexDirection: "row", }}
}} data={[
data={[ {
{ key: "reset",
key: "reset", component: <ResetFiltersButton />,
component: <ResetFiltersButton />, },
}, {
{ key: "genre",
key: "genre", component: (
component: ( <FilterButton
<FilterButton className='mr-1'
className='mr-1' id={libraryId}
id={libraryId} queryKey='genreFilter'
queryKey='genreFilter' queryFn={async () => {
queryFn={async () => { if (!api) return null;
if (!api) return null; const response = await getFilterApi(
const response = await getFilterApi( api,
api, ).getQueryFiltersLegacy({
).getQueryFiltersLegacy({ userId: user?.Id,
userId: user?.Id, parentId: libraryId,
parentId: libraryId, });
}); return response.data.Genres || [];
return response.data.Genres || []; }}
}} set={setSelectedGenres}
set={setSelectedGenres} values={selectedGenres}
values={selectedGenres} title={t("library.filters.genres")}
title={t("library.filters.genres")} renderItemLabel={(item) => item.toString()}
renderItemLabel={(item) => item.toString()} searchFilter={(item, search) =>
searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase())
item.toLowerCase().includes(search.toLowerCase()) }
} />
/> ),
), },
}, {
{ key: "year",
key: "year", component: (
component: ( <FilterButton
<FilterButton className='mr-1'
className='mr-1' id={libraryId}
id={libraryId} queryKey='yearFilter'
queryKey='yearFilter' queryFn={async () => {
queryFn={async () => { if (!api) return null;
if (!api) return null; const response = await getFilterApi(
const response = await getFilterApi( api,
api, ).getQueryFiltersLegacy({
).getQueryFiltersLegacy({ userId: user?.Id,
userId: user?.Id, parentId: libraryId,
parentId: libraryId, });
}); return response.data.Years || [];
return response.data.Years || []; }}
}} set={setSelectedYears}
set={setSelectedYears} values={selectedYears}
values={selectedYears} title={t("library.filters.years")}
title={t("library.filters.years")} renderItemLabel={(item) => item.toString()}
renderItemLabel={(item) => item.toString()} searchFilter={(item, search) => item.includes(search)}
searchFilter={(item, search) => item.includes(search)} />
/> ),
), },
}, {
{ key: "tags",
key: "tags", component: (
component: ( <FilterButton
<FilterButton className='mr-1'
className='mr-1' id={libraryId}
id={libraryId} queryKey='tagsFilter'
queryKey='tagsFilter' queryFn={async () => {
queryFn={async () => { if (!api) return null;
if (!api) return null; const response = await getFilterApi(
const response = await getFilterApi( api,
api, ).getQueryFiltersLegacy({
).getQueryFiltersLegacy({ userId: user?.Id,
userId: user?.Id, parentId: libraryId,
parentId: libraryId, });
}); return response.data.Tags || [];
return response.data.Tags || []; }}
}} set={setSelectedTags}
set={setSelectedTags} values={selectedTags}
values={selectedTags} title={t("library.filters.tags")}
title={t("library.filters.tags")} renderItemLabel={(item) => item.toString()}
renderItemLabel={(item) => item.toString()} searchFilter={(item, search) =>
searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase())
item.toLowerCase().includes(search.toLowerCase()) }
} />
/> ),
), },
}, {
{ key: "sortBy",
key: "sortBy", component: (
component: ( <FilterButton
<FilterButton className='mr-1'
className='mr-1' id={libraryId}
id={libraryId} queryKey='sortBy'
queryKey='sortBy' queryFn={async () => sortOptions.map((s) => s.key)}
queryFn={async () => sortOptions.map((s) => s.key)} set={setSortBy}
set={setSortBy} values={sortBy}
values={sortBy} title={t("library.filters.sort_by")}
title={t("library.filters.sort_by")} renderItemLabel={(item) =>
renderItemLabel={(item) => sortOptions.find((i) => i.key === item)?.value || ""
sortOptions.find((i) => i.key === item)?.value || "" }
} searchFilter={(item, search) =>
searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase())
item.toLowerCase().includes(search.toLowerCase()) }
} />
/> ),
), },
}, {
{ key: "sortOrder",
key: "sortOrder", component: (
component: ( <FilterButton
<FilterButton className='mr-1'
className='mr-1' id={libraryId}
id={libraryId} queryKey='sortOrder'
queryKey='sortOrder' queryFn={async () => sortOrderOptions.map((s) => s.key)}
queryFn={async () => sortOrderOptions.map((s) => s.key)} set={setSortOrder}
set={setSortOrder} values={sortOrder}
values={sortOrder} title={t("library.filters.sort_order")}
title={t("library.filters.sort_order")} renderItemLabel={(item) =>
renderItemLabel={(item) => sortOrderOptions.find((i) => i.key === item)?.value || ""
sortOrderOptions.find((i) => i.key === item)?.value || "" }
} searchFilter={(item, search) =>
searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase())
item.toLowerCase().includes(search.toLowerCase()) }
} />
/> ),
), },
}, ]}
]} renderItem={({ item }) => item.component}
renderItem={({ item }) => item.component} keyExtractor={(item) => item.key}
keyExtractor={(item) => item.key} />
/>
</View>
), ),
[ [
libraryId, libraryId,
@@ -453,7 +451,6 @@ const Page = () => {
renderItem={renderItem} renderItem={renderItem}
extraData={[orientation, nrOfCols]} extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
estimatedItemSize={244}
numColumns={nrOfCols} numColumns={nrOfCols}
onEndReached={() => { onEndReached={() => {
if (hasNextPage) { if (hasNextPage) {

View File

@@ -1,85 +1,208 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity } from "react-native"; import { Platform, View } from "react-native";
import { LibraryOptionsSheet } from "@/components/settings/LibraryOptionsSheet"; import { PlatformDropdown } from "@/components/PlatformDropdown";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export default function IndexLayout() { export default function IndexLayout() {
const { settings, updateSettings, pluginSettings } = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
const [optionsSheetOpen, setOptionsSheetOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
// Reset dropdown state when component unmounts or navigates away
useEffect(() => {
return () => {
setDropdownOpen(false);
};
}, []);
// Memoize callbacks to prevent recreating on every render
const handleDisplayRow = useCallback(() => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "row",
},
});
}, [settings.libraryOptions, updateSettings]);
const handleDisplayList = useCallback(() => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "list",
},
});
}, [settings.libraryOptions, updateSettings]);
const handleImageStylePoster = useCallback(() => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "poster",
},
});
}, [settings.libraryOptions, updateSettings]);
const handleImageStyleCover = useCallback(() => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "cover",
},
});
}, [settings.libraryOptions, updateSettings]);
const handleToggleTitles = useCallback(() => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showTitles: !settings.libraryOptions.showTitles,
},
});
}, [settings.libraryOptions, updateSettings]);
const handleToggleStats = useCallback(() => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showStats: !settings.libraryOptions.showStats,
},
});
}, [settings.libraryOptions, updateSettings]);
// Memoize groups to prevent recreating the array on every render
const dropdownGroups = useMemo(
() => [
{
title: t("library.options.display"),
options: [
{
type: "radio" as const,
label: t("library.options.row"),
value: "row",
selected: settings.libraryOptions.display === "row",
onPress: handleDisplayRow,
},
{
type: "radio" as const,
label: t("library.options.list"),
value: "list",
selected: settings.libraryOptions.display === "list",
onPress: handleDisplayList,
},
],
},
{
title: t("library.options.image_style"),
options: [
{
type: "radio" as const,
label: t("library.options.poster"),
value: "poster",
selected: settings.libraryOptions.imageStyle === "poster",
onPress: handleImageStylePoster,
},
{
type: "radio" as const,
label: t("library.options.cover"),
value: "cover",
selected: settings.libraryOptions.imageStyle === "cover",
onPress: handleImageStyleCover,
},
],
},
{
title: "Options",
options: [
{
type: "toggle" as const,
label: t("library.options.show_titles"),
value: settings.libraryOptions.showTitles,
onToggle: handleToggleTitles,
disabled: settings.libraryOptions.imageStyle === "poster",
},
{
type: "toggle" as const,
label: t("library.options.show_stats"),
value: settings.libraryOptions.showStats,
onToggle: handleToggleStats,
},
],
},
],
[
t,
settings.libraryOptions,
handleDisplayRow,
handleDisplayList,
handleImageStylePoster,
handleImageStyleCover,
handleToggleTitles,
handleToggleStats,
],
);
if (!settings?.libraryOptions) return null; if (!settings?.libraryOptions) return null;
return ( return (
<> <Stack>
<Stack> <Stack.Screen
<Stack.Screen name='index'
name='index' options={{
options={{ headerShown: !Platform.isTV,
headerShown: !Platform.isTV, headerTitle: t("tabs.library"),
headerTitle: t("tabs.library"), headerBlurEffect: "none",
headerBlurEffect: "none", headerTransparent: Platform.OS === "ios",
headerTransparent: Platform.OS === "ios", headerShadowVisible: false,
headerShadowVisible: false, headerRight: () =>
headerRight: () => !pluginSettings?.libraryOptions?.locked &&
!pluginSettings?.libraryOptions?.locked && !Platform.isTV && (
!Platform.isTV && ( <PlatformDropdown
<TouchableOpacity open={dropdownOpen}
onPress={() => setOptionsSheetOpen(true)} onOpenChange={setDropdownOpen}
className='flex flex-row items-center justify-center w-9 h-9' trigger={
> <View className='pl-1.5'>
<Ionicons <Ionicons
name='ellipsis-horizontal-outline' name='ellipsis-horizontal-outline'
size={24} size={24}
color='white' color='white'
/> />
</TouchableOpacity> </View>
), }
}} title={t("library.options.display")}
/> groups={dropdownGroups}
<Stack.Screen />
name='[libraryId]' ),
options={{ }}
title: "",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name='collections/[collectionId]'
options={{
title: "",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
</Stack>
<LibraryOptionsSheet
open={optionsSheetOpen}
setOpen={setOptionsSheetOpen}
settings={settings.libraryOptions}
updateSettings={(options) =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
...options,
},
})
}
disabled={pluginSettings?.libraryOptions?.locked}
/> />
</> <Stack.Screen
name='[libraryId]'
options={{
title: "",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name='collections/[collectionId]'
options={{
title: "",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
</Stack>
); );
} }

View File

@@ -7,7 +7,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { StyleSheet, View } from "react-native"; import { Platform, StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
@@ -84,11 +84,11 @@ export default function index() {
extraData={settings} extraData={settings}
contentInsetAdjustmentBehavior='automatic' contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{ contentContainerStyle={{
paddingTop: 17, paddingTop: Platform.OS === "android" ? 17 : 0,
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17, paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
paddingBottom: 150, paddingBottom: 150,
paddingLeft: insets.left, paddingLeft: insets.left + 17,
paddingRight: insets.right, paddingRight: insets.right + 17,
}} }}
data={libraries} data={libraries}
renderItem={({ item }) => <LibraryItemCard library={item} />} renderItem={({ item }) => <LibraryItemCard library={item} />}
@@ -105,7 +105,6 @@ export default function index() {
<View className='h-4' /> <View className='h-4' />
) )
} }
estimatedItemSize={200}
/> />
); );
} }

View File

@@ -28,7 +28,7 @@ export default function SearchLayout() {
options={{ options={{
title: "", title: "",
headerShown: !Platform.isTV, headerShown: !Platform.isTV,
headerBlurEffect: "prominent", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
}} }}

View File

@@ -24,8 +24,6 @@ import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Input } from "@/components/common/Input"; import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { Tag } from "@/components/GenreTags";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { import {
JellyseerrSearchSort, JellyseerrSearchSort,
@@ -33,8 +31,10 @@ import {
} from "@/components/jellyseerr/JellyseerrIndexPage"; } from "@/components/jellyseerr/JellyseerrIndexPage";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster"; import SeriesPoster from "@/components/posters/SeriesPoster";
import { DiscoverFilters } from "@/components/search/DiscoverFilters";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton"; import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper"; import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
@@ -284,67 +284,30 @@ export default function search() {
)} )}
<View <View
className='flex flex-col' className='flex flex-col'
style={{ style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
marginTop: Platform.OS === "android" ? 16 : 0,
}}
> >
{jellyseerrApi && ( {jellyseerrApi && (
<ScrollView <View className='pl-4 pr-4 flex flex-row'>
horizontal <SearchTabButtons
className='flex flex-row flex-wrap space-x-2 px-4 mb-2' searchType={searchType}
> setSearchType={setSearchType}
<TouchableOpacity onPress={() => setSearchType("Library")}> t={t}
<Tag />
text={t("search.library")}
textClass='p-1'
className={
searchType === "Library" ? "bg-purple-600" : undefined
}
/>
</TouchableOpacity>
<TouchableOpacity onPress={() => setSearchType("Discover")}>
<Tag
text={t("search.discover")}
textClass='p-1'
className={
searchType === "Discover" ? "bg-purple-600" : undefined
}
/>
</TouchableOpacity>
{searchType === "Discover" && {searchType === "Discover" &&
!loading && !loading &&
noResults && noResults &&
debouncedSearch.length > 0 && ( debouncedSearch.length > 0 && (
<View className='flex flex-row justify-end items-center space-x-1'> <DiscoverFilters
<FilterButton searchFilterId={searchFilterId}
id={searchFilterId} orderFilterId={orderFilterId}
queryKey='jellyseerr_search' jellyseerrOrderBy={jellyseerrOrderBy}
queryFn={async () => setJellyseerrOrderBy={setJellyseerrOrderBy}
Object.keys(JellyseerrSearchSort).filter((v) => jellyseerrSortOrder={jellyseerrSortOrder}
Number.isNaN(Number(v)), setJellyseerrSortOrder={setJellyseerrSortOrder}
) t={t}
} />
set={(value) => setJellyseerrOrderBy(value[0])}
values={[jellyseerrOrderBy]}
title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
}
disableSearch={true}
/>
<FilterButton
id={orderFilterId}
queryKey='jellysearr_search'
queryFn={async () => ["asc", "desc"]}
set={(value) => setJellyseerrSortOrder(value[0])}
values={[jellyseerrSortOrder]}
title={t("library.filters.sort_order")}
renderItemLabel={(item) => t(`library.filters.${item}`)}
disableSearch={true}
/>
</View>
)} )}
</ScrollView> </View>
)} )}
<View className='mt-2'> <View className='mt-2'>

View File

@@ -55,6 +55,7 @@ export default function TabLayout() {
backgroundColor: "#121212", backgroundColor: "#121212",
}} }}
tabBarActiveTintColor={Colors.primary} tabBarActiveTintColor={Colors.primary}
activeIndicatorColor={"#392c3b"}
scrollEdgeAppearance='default' scrollEdgeAppearance='default'
> >
<NativeTabs.Screen redirect name='index' /> <NativeTabs.Screen redirect name='index' />
@@ -70,10 +71,7 @@ export default function TabLayout() {
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? (_e) => require("@/assets/icons/house.fill.png") ? (_e) => require("@/assets/icons/house.fill.png")
: ({ focused }) => : (_e) => ({ sfSymbol: "house.fill" }),
focused
? { sfSymbol: "house.fill" }
: { sfSymbol: "house" },
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
@@ -84,14 +82,12 @@ export default function TabLayout() {
})} })}
name='(search)' name='(search)'
options={{ options={{
role: "search",
title: t("tabs.search"), title: t("tabs.search"),
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? (_e) => require("@/assets/icons/magnifyingglass.png") ? (_e) => require("@/assets/icons/magnifyingglass.png")
: ({ focused }) => : (_e) => ({ sfSymbol: "magnifyingglass" }),
focused
? { sfSymbol: "magnifyingglass" }
: { sfSymbol: "magnifyingglass" },
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
@@ -100,14 +96,8 @@ export default function TabLayout() {
title: t("tabs.favorites"), title: t("tabs.favorites"),
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? ({ focused }) => ? (_e) => require("@/assets/icons/heart.fill.png")
focused : (_e) => ({ sfSymbol: "heart.fill" }),
? require("@/assets/icons/heart.fill.png")
: require("@/assets/icons/heart.png")
: ({ focused }) =>
focused
? { sfSymbol: "heart.fill" }
: { sfSymbol: "heart" },
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
@@ -117,10 +107,7 @@ export default function TabLayout() {
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? (_e) => require("@/assets/icons/server.rack.png") ? (_e) => require("@/assets/icons/server.rack.png")
: ({ focused }) => : (_e) => ({ sfSymbol: "rectangle.stack.fill" }),
focused
? { sfSymbol: "rectangle.stack.fill" }
: { sfSymbol: "rectangle.stack" },
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
@@ -131,10 +118,7 @@ export default function TabLayout() {
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? (_e) => require("@/assets/icons/list.png") ? (_e) => require("@/assets/icons/list.png")
: ({ focused }) => : (_e) => ({ sfSymbol: "list.dash.fill" }),
focused
? { sfSymbol: "list.dash.fill" }
: { sfSymbol: "list.dash" },
}} }}
/> />
</NativeTabs> </NativeTabs>

View File

@@ -29,6 +29,7 @@ import {
VLCColor, VLCColor,
} from "@/constants/SubtitleConstants"; } from "@/constants/SubtitleConstants";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation";
import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets"; import { useWebSocket } from "@/hooks/useWebsockets";
@@ -56,6 +57,7 @@ export default function page() {
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
const [isPipMode, setIsPipMode] = useState(false);
const [aspectRatio, setAspectRatio] = useState< const [aspectRatio, setAspectRatio] = useState<
"default" | "16:9" | "4:3" | "1:1" | "21:9" "default" | "16:9" | "4:3" | "1:1" | "21:9"
>("default"); >("default");
@@ -75,7 +77,10 @@ export default function page() {
: require("react-native-volume-manager"); : require("react-native-volume-manager");
const downloadUtils = useDownload(); const downloadUtils = useDownload();
const downloadedFiles = downloadUtils.getDownloadedItems(); const downloadedFiles = useMemo(
() => downloadUtils.getDownloadedItems(),
[downloadUtils.getDownloadedItems],
);
const revalidateProgressCache = useInvalidatePlaybackProgressCache(); const revalidateProgressCache = useInvalidatePlaybackProgressCache();
@@ -105,6 +110,7 @@ export default function page() {
playbackPosition?: string; playbackPosition?: string;
}>(); }>();
const { settings } = useSettings(); const { settings } = useSettings();
const { lockOrientation, unlockOrientation } = useOrientation();
const offline = offlineStr === "true"; const offline = offlineStr === "true";
const playbackManager = usePlaybackManager(); const playbackManager = usePlaybackManager();
@@ -167,6 +173,16 @@ export default function page() {
} }
}, [itemId, offline, api, user?.Id]); }, [itemId, offline, api, user?.Id]);
useEffect(() => {
if (settings?.defaultVideoOrientation) {
lockOrientation(settings.defaultVideoOrientation);
}
return () => {
unlockOrientation();
};
}, [settings?.defaultVideoOrientation]);
interface Stream { interface Stream {
mediaSource: MediaSourceInfo; mediaSource: MediaSourceInfo;
sessionId: string; sessionId: string;
@@ -282,12 +298,14 @@ export default function page() {
}; };
const reportPlaybackStopped = useCallback(async () => { const reportPlaybackStopped = useCallback(async () => {
if (!item?.Id || !stream?.sessionId) return;
const currentTimeInTicks = msToTicks(progress.get()); const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({ await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!, itemId: item.Id,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
positionTicks: currentTimeInTicks, positionTicks: currentTimeInTicks,
playSessionId: stream?.sessionId!, playSessionId: stream.sessionId,
}); });
}, [ }, [
api, api,
@@ -318,9 +336,9 @@ export default function page() {
}, [navigation, stop]); }, [navigation, stop]);
const currentPlayStateInfo = useCallback(() => { const currentPlayStateInfo = useCallback(() => {
if (!stream) return; if (!stream || !item?.Id) return;
return { return {
itemId: item?.Id!, itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined, audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
@@ -727,9 +745,12 @@ export default function page() {
); );
writeToLog("ERROR", "Video Error", e.nativeEvent); writeToLog("ERROR", "Video Error", e.nativeEvent);
}} }}
onPipStarted={(e) => {
setIsPipMode(e.nativeEvent.pipStarted);
}}
/> />
</View> </View>
{isMounted === true && item && ( {isMounted === true && item && !isPipMode && (
<Controls <Controls
mediaSource={stream?.mediaSource} mediaSource={stream?.mediaSource}
item={item} item={item}
@@ -761,6 +782,7 @@ export default function page() {
setAspectRatio={setAspectRatio} setAspectRatio={setAspectRatio}
setScaleFactor={setScaleFactor} setScaleFactor={setScaleFactor}
isVlc isVlc
api={api}
downloadedFiles={downloadedFiles} downloadedFiles={downloadedFiles}
/> />
)} )}

View File

@@ -1,18 +1,24 @@
import "@/augmentations"; import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider"; import { DownloadProvider } from "@/providers/DownloadProvider";
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
import { import {
apiAtom, apiAtom,
getOrSetDeviceId, getOrSetDeviceId,
getTokenFromStorage,
JellyfinProvider, JellyfinProvider,
} from "@/providers/JellyfinProvider"; } from "@/providers/JellyfinProvider";
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { type Settings, useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { import {
BACKGROUND_FETCH_TASK, BACKGROUND_FETCH_TASK,
BACKGROUND_FETCH_TASK_SESSIONS, BACKGROUND_FETCH_TASK_SESSIONS,
@@ -26,44 +32,29 @@ import {
} from "@/utils/log"; } from "@/utils/log";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
: null;
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device";
import * as FileSystem from "expo-file-system";
const Notifications = !Platform.isTV ? require("expo-notifications") : null; const Notifications = !Platform.isTV ? require("expo-notifications") : null;
import { getLocales } from "expo-localization";
import { router, Stack, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef, useState } from "react";
import { I18nextProvider } from "react-i18next";
import { Appearance, AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import "react-native-reanimated";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { getLocales } from "expo-localization";
import type { EventSubscription } from "expo-modules-core"; import type { EventSubscription } from "expo-modules-core";
import type { import type {
Notification, Notification,
NotificationResponse, NotificationResponse,
} from "expo-notifications/build/Notifications.types"; } from "expo-notifications/build/Notifications.types";
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types"; import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
import { useAtom } from "jotai"; import { router, Stack, useSegments } from "expo-router";
import { Toaster } from "sonner-native"; import * as SplashScreen from "expo-splash-screen";
import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider, useAtom } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react";
import { I18nextProvider } from "react-i18next";
import { Appearance } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { userAtom } from "@/providers/JellyfinProvider"; import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store"; import { store } from "@/utils/store";
import "react-native-reanimated";
import { Toaster } from "sonner-native";
if (!Platform.isTV) { if (!Platform.isTV) {
Notifications.setNotificationHandler({ Notifications.setNotificationHandler({
@@ -131,24 +122,7 @@ if (!Platform.isTV) {
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
console.log("TaskManager ~ trigger"); console.log("TaskManager ~ trigger");
// Background fetch task placeholder - currently unused
const settingsData = storage.getString("settings");
if (!settingsData) return BackgroundTask.BackgroundTaskResult.Failed;
const settings: Partial<Settings> = JSON.parse(settingsData);
if (!settings?.autoDownload)
return BackgroundTask.BackgroundTaskResult.Failed;
const token = getTokenFromStorage();
const deviceId = getOrSetDeviceId();
const baseDirectory = FileSystem.documentDirectory;
if (!token || !deviceId || !baseDirectory)
return BackgroundTask.BackgroundTaskResult.Failed;
// Be sure to return the successful result type!
return BackgroundTask.BackgroundTaskResult.Success; return BackgroundTask.BackgroundTaskResult.Success;
}); });
} }
@@ -213,11 +187,7 @@ export default function RootLayout() {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 0, staleTime: 30000,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retryOnMount: true,
}, },
}, },
}); });
@@ -226,8 +196,7 @@ function Layout() {
const { settings } = useSettings(); const { settings } = useSettings();
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const appState = useRef(AppState.currentState); const _segments = useSegments();
const segments = useSegments();
useEffect(() => { useEffect(() => {
i18n.changeLanguage( i18n.changeLanguage(
@@ -256,7 +225,7 @@ function Layout() {
} else console.log("No token available"); } else console.log("No token available");
}, [api, expoPushToken, user]); }, [api, expoPushToken, user]);
async function registerNotifications() { const registerNotifications = useCallback(async () => {
if (Platform.OS === "android") { if (Platform.OS === "android") {
console.log("Setting android notification channel 'default'"); console.log("Setting android notification channel 'default'");
await Notifications?.setNotificationChannelAsync("default", { await Notifications?.setNotificationChannelAsync("default", {
@@ -287,11 +256,21 @@ function Layout() {
// only create push token for real devices (pointless for emulators) // only create push token for real devices (pointless for emulators)
if (Device.isDevice) { if (Device.isDevice) {
Notifications?.getExpoPushTokenAsync() Notifications?.getExpoPushTokenAsync({
.then((token: ExpoPushToken) => token && setExpoPushToken(token)) projectId: "e79219d1-797f-4fbe-9fa1-cfd360690a68",
.catch((reason: any) => console.log("Failed to get token", reason)); })
.then((token: ExpoPushToken) => {
if (token) {
console.log("Expo push token obtained:", token.data);
setExpoPushToken(token);
}
})
.catch((reason: any) => {
console.error("Failed to get push token:", reason);
writeErrorLog("Failed to get Expo push token", reason);
});
} }
} }, [user]);
useEffect(() => { useEffect(() => {
if (!Platform.isTV) { if (!Platform.isTV) {
@@ -355,119 +334,70 @@ function Layout() {
responseListener.current?.remove(); responseListener.current?.remove();
}; };
} }
}, [user, api]); }, [user]);
useEffect(() => {
if (Platform.isTV) {
return;
}
if (segments.includes("direct-player" as never)) {
if (
!settings.followDeviceOrientation &&
settings.defaultVideoOrientation
) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
}
return;
}
if (settings.followDeviceOrientation === true) {
ScreenOrientation.unlockAsync();
} else {
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
}
}, [
settings.followDeviceOrientation,
settings.defaultVideoOrientation,
segments,
]);
useEffect(() => {
if (Platform.isTV) {
return;
}
const subscription = AppState.addEventListener("change", (nextAppState) => {
if (
appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
BackGroundDownloader.checkForExistingDownloads().catch(
(error: unknown) => {
writeErrorLog("Failed to resume background downloads", error);
},
);
}
});
BackGroundDownloader.checkForExistingDownloads().catch((error: unknown) => {
writeErrorLog("Failed to resume background downloads", error);
});
return () => {
subscription.remove();
};
}, []);
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<JellyfinProvider> <JellyfinProvider>
<PlaySettingsProvider> <NetworkStatusProvider>
<LogProvider> <PlaySettingsProvider>
<WebSocketProvider> <LogProvider>
<DownloadProvider> <WebSocketProvider>
<BottomSheetModalProvider> <DownloadProvider>
<SystemBars style='light' hidden={false} /> <GlobalModalProvider>
<ThemeProvider value={DarkTheme}> <BottomSheetModalProvider>
<Stack initialRouteName='(auth)/(tabs)'> <ThemeProvider value={DarkTheme}>
<Stack.Screen <SystemBars style='light' hidden={false} />
name='(auth)/(tabs)' <Stack initialRouteName='(auth)/(tabs)'>
options={{ <Stack.Screen
headerShown: false, name='(auth)/(tabs)'
title: "", options={{
header: () => null, headerShown: false,
}} title: "",
/> header: () => null,
<Stack.Screen }}
name='(auth)/player' />
options={{ <Stack.Screen
headerShown: false, name='(auth)/player'
title: "", options={{
header: () => null, headerShown: false,
}} title: "",
/> header: () => null,
<Stack.Screen }}
name='login' />
options={{ <Stack.Screen
headerShown: true, name='login'
title: "", options={{
headerTransparent: true, headerShown: true,
}} title: "",
/> headerTransparent: Platform.OS === "ios",
<Stack.Screen name='+not-found' /> }}
</Stack> />
<Toaster <Stack.Screen name='+not-found' />
duration={4000} </Stack>
toastOptions={{ <Toaster
style: { duration={4000}
backgroundColor: "#262626", toastOptions={{
borderColor: "#363639", style: {
borderWidth: 1, backgroundColor: "#262626",
}, borderColor: "#363639",
titleStyle: { borderWidth: 1,
color: "white", },
}, titleStyle: {
}} color: "white",
closeButton },
/> }}
</ThemeProvider> closeButton
</BottomSheetModalProvider> />
</DownloadProvider> <GlobalModal />
</WebSocketProvider> </ThemeProvider>
</LogProvider> </BottomSheetModalProvider>
</PlaySettingsProvider> </GlobalModalProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</NetworkStatusProvider>
</JellyfinProvider> </JellyfinProvider>
</QueryClientProvider> </QueryClientProvider>
); );

View File

@@ -4,17 +4,16 @@ import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import type React from "react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
Alert, Alert,
Keyboard, Keyboard,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
SafeAreaView,
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { z } from "zod"; import { z } from "zod";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input"; import { Input } from "@/components/common/Input";
@@ -43,14 +42,14 @@ const Login: React.FC = () => {
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false); const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [serverURL, setServerURL] = useState<string>(_apiUrl); const [serverURL, setServerURL] = useState<string>(_apiUrl || "");
const [serverName, setServerName] = useState<string>(""); const [serverName, setServerName] = useState<string>("");
const [credentials, setCredentials] = useState<{ const [credentials, setCredentials] = useState<{
username: string; username: string;
password: string; password: string;
}>({ }>({
username: _username, username: _username || "",
password: _password, password: _password || "",
}); });
/** /**
@@ -63,12 +62,13 @@ const Login: React.FC = () => {
address: _apiUrl, address: _apiUrl,
}); });
// Wait for server setup and state updates to complete
setTimeout(() => { setTimeout(() => {
if (_username && _password) { if (_username && _password) {
setCredentials({ username: _username, password: _password }); setCredentials({ username: _username, password: _password });
login(_username, _password); login(_username, _password);
} }
}, 300); }, 0);
} }
})(); })();
}, [_apiUrl, _username, _password]); }, [_apiUrl, _username, _password]);
@@ -82,10 +82,10 @@ const Login: React.FC = () => {
onPress={() => { onPress={() => {
removeServer(); removeServer();
}} }}
className='flex flex-row items-center' className='flex flex-row items-center pr-2 pl-1'
> >
<Ionicons name='chevron-back' size={18} color={Colors.primary} /> <Ionicons name='chevron-back' size={18} color={Colors.primary} />
<Text className='ml-2 text-purple-600'> <Text className=' ml-1 text-purple-600'>
{t("login.change_server")} {t("login.change_server")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -264,6 +264,12 @@ const Login: React.FC = () => {
onChangeText={(text: string) => onChangeText={(text: string) =>
setCredentials({ ...credentials, username: text }) setCredentials({ ...credentials, username: text })
} }
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.username) {
setCredentials({ ...credentials, username: newValue });
}
}}
value={credentials.username} value={credentials.username}
keyboardType='default' keyboardType='default'
returnKeyType='done' returnKeyType='done'
@@ -272,6 +278,8 @@ const Login: React.FC = () => {
clearButtonMode='while-editing' clearButtonMode='while-editing'
maxLength={500} maxLength={500}
extraClassName='mb-4' extraClassName='mb-4'
autoFocus={false}
blurOnSubmit={true}
/> />
{/* Password */} {/* Password */}
@@ -280,6 +288,12 @@ const Login: React.FC = () => {
onChangeText={(text: string) => onChangeText={(text: string) =>
setCredentials({ ...credentials, password: text }) setCredentials({ ...credentials, password: text })
} }
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.password) {
setCredentials({ ...credentials, password: newValue });
}
}}
value={credentials.password} value={credentials.password}
secureTextEntry secureTextEntry
keyboardType='default' keyboardType='default'
@@ -289,10 +303,17 @@ const Login: React.FC = () => {
clearButtonMode='while-editing' clearButtonMode='while-editing'
maxLength={500} maxLength={500}
extraClassName='mb-4' extraClassName='mb-4'
autoFocus={false}
blurOnSubmit={true}
/> />
<View className='mt-4'> <View className='mt-4'>
<Button onPress={handleLogin}>{t("login.login_button")}</Button> <Button
onPress={handleLogin}
disabled={!credentials.username.trim()}
>
{t("login.login_button")}
</Button>
</View> </View>
<View className='mt-3'> <View className='mt-3'>
<Button <Button
@@ -334,6 +355,8 @@ const Login: React.FC = () => {
autoCapitalize='none' autoCapitalize='none'
textContentType='URL' textContentType='URL'
maxLength={500} maxLength={500}
autoFocus={false}
blurOnSubmit={true}
/> />
{/* Full-width primary button */} {/* Full-width primary button */}
@@ -371,10 +394,11 @@ const Login: React.FC = () => {
// Mobile layout // Mobile layout
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}> <SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"} behavior={Platform.OS === "ios" ? "padding" : undefined}
style={{ flex: 1 }}
> >
{api?.basePath ? ( {api?.basePath ? (
<View className='flex flex-col h-full relative items-center justify-center'> <View className='flex flex-col flex-1 items-center justify-center'>
<View className='px-4 -mt-20 w-full'> <View className='px-4 -mt-20 w-full'>
<View className='flex flex-col space-y-2'> <View className='flex flex-col space-y-2'>
<Text className='text-2xl font-bold -mb-2'> <Text className='text-2xl font-bold -mb-2'>
@@ -393,6 +417,12 @@ const Login: React.FC = () => {
onChangeText={(text) => onChangeText={(text) =>
setCredentials({ ...credentials, username: text }) setCredentials({ ...credentials, username: text })
} }
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.username) {
setCredentials({ ...credentials, username: newValue });
}
}}
value={credentials.username} value={credentials.username}
keyboardType='default' keyboardType='default'
returnKeyType='done' returnKeyType='done'
@@ -409,6 +439,12 @@ const Login: React.FC = () => {
onChangeText={(text) => onChangeText={(text) =>
setCredentials({ ...credentials, password: text }) setCredentials({ ...credentials, password: text })
} }
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.password) {
setCredentials({ ...credentials, password: newValue });
}
}}
value={credentials.password} value={credentials.password}
secureTextEntry secureTextEntry
keyboardType='default' keyboardType='default'
@@ -422,6 +458,7 @@ const Login: React.FC = () => {
<Button <Button
onPress={handleLogin} onPress={handleLogin}
loading={loading} loading={loading}
disabled={!credentials.username.trim()}
className='flex-1 mr-2' className='flex-1 mr-2'
> >
{t("login.login_button")} {t("login.login_button")}
@@ -443,7 +480,7 @@ const Login: React.FC = () => {
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' /> <View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
</View> </View>
) : ( ) : (
<View className='flex flex-col h-full items-center justify-center w-full'> <View className='flex flex-col flex-1 items-center justify-center w-full'>
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'> <View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
<Image <Image
style={{ style={{

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
<g id="g10">
<path id="path88" d="M3547.01,1831.49C3493.38,1822.66 3262.53,1779.28 2992.01,1820.24C2424.16,1906.21 2154.85,2275.8 1882,2420.24C1473.31,2636.6 1060.97,2644.95 832,2592.03L832,1445.92C832,1321.76 863.078,1198.06 925.307,1090.27C1057.09,862.011 1323.38,718.405 1586.6,736.145C1695.48,743.482 1801.3,777.735 1895.64,832.199L3357.51,1676.21C3424.47,1714.87 3482.92,1761.76 3532.01,1815.41L3547.01,1831.49Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(149,41,235);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(98,22,247);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
<g id="g10">
<path id="path66" d="M3357.51,2903.64L1895.64,3747.65C1670.29,3877.76 1412.33,3877.76 1186.98,3747.65C961.629,3617.55 832.648,3394.14 832.648,3133.93L832.648,1445.92C832.648,1185.71 961.629,962.305 1186.98,832.199C1412.33,702.094 1670.29,702.094 1895.64,832.199L3357.51,1676.21C3582.86,1806.31 3711.84,2029.71 3711.84,2289.93C3711.84,2550.14 3582.86,2773.54 3357.51,2903.64ZM1721.48,3213.68L3098.31,2454.7C3163.9,2418.55 3193.45,2364.85 3193.45,2289.93C3193.45,2215 3163.93,2161.32 3098.31,2125.15L1721.48,1366.18C1655.87,1330.01 1596.09,1328.72 1531.21,1366.18C1466.34,1403.63 1436.08,1456.03 1436.08,1530.96L1436.08,3048.89C1436.08,3123.77 1466.35,3176.23 1531.21,3213.68C1596.08,3251.11 1655.89,3249.83 1721.48,3213.68" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(188,74,241);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(227,105,219);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g>
<g id="g10">
<path id="path88" d="M0,319.909L0,234C17.667,234.844 138.649,236.708 195,190C220.441,168.912 271.21,169.515 294.001,178.788C332.576,194.487 378.643,259.549 360,270.644C353.455,277.797 345.662,284.049 336.734,289.204L141.818,401.738C129.24,409 115.13,413.567 100.613,414.546C65.517,416.911 30.012,397.763 12.441,367.329C4.144,352.957 0,336.464 0,319.909Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(225,102,222);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(204,88,233);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
<g id="g10">
<path id="path28" d="M1427.29,1523.37C1427.29,1447.7 1457.85,1394.77 1523.38,1356.94C1588.91,1319.11 1649.28,1320.41 1715.55,1356.94L3106.14,2123.5C3172.42,2160.03 3202.24,2214.25 3202.24,2289.93C3202.24,2365.6 3172.39,2419.83 3106.14,2456.35L1715.55,3222.91C1649.31,3259.43 1588.89,3260.73 1523.38,3222.91C1457.87,3185.1 1427.29,3132.11 1427.29,3056.48L1427.29,1523.37" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.17673e-13,-1921.74,1921.74,1.17673e-13,2314.76,3250.79)"><stop offset="0" style="stop-color:rgb(93,17,250);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(143,40,236);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,184 @@
{
"fill": {
"solid": "display-p3:0.18039,0.18039,0.18039,1.00000"
},
"groups": [
{
"blur-material": 0.3,
"layers": [
{
"fill-specializations": [
{
"value": "none"
},
{
"appearance": "tinted",
"value": {
"automatic-gradient": "display-p3:0.76482,0.76482,0.76482,0.84903"
}
}
],
"glass": true,
"hidden": false,
"image-name": "streamyfin_logo_layer1.svg",
"name": "streamyfin_logo_layer1"
}
],
"opacity": 1,
"position": {
"scale": 1.7,
"translation-in-points": [30, 0]
},
"shadow": {
"kind": "none",
"opacity": 1
},
"specular": true,
"translucency": {
"enabled": true,
"value": 0.6
}
},
{
"blend-mode": "normal",
"blur-material": 0.8,
"hidden": false,
"layers": [
{
"blend-mode": "normal",
"fill-specializations": [
{
"value": "none"
},
{
"appearance": "tinted",
"value": {
"automatic-gradient": "gray:0.75000,1.00000"
}
}
],
"hidden": false,
"image-name": "streamyfin_logo_layer2.svg",
"name": "streamyfin_logo_layer2",
"opacity": 1,
"position": {
"scale": 1,
"translation-in-points": [0, 0]
}
}
],
"lighting": "individual",
"name": "Group",
"opacity": 1,
"position": {
"scale": 1.7,
"translation-in-points": [30, -0.01613253252572302]
},
"shadow": {
"kind": "layer-color",
"opacity": 0.35
},
"specular": true,
"translucency-specializations": [
{
"value": {
"enabled": true,
"value": 0.5
}
},
{
"appearance": "tinted",
"value": {
"enabled": true,
"value": 0.8
}
}
]
},
{
"blend-mode": "normal",
"blur-material": 0.5,
"layers": [
{
"fill-specializations": [
{
"appearance": "tinted",
"value": {
"automatic-gradient": "gray:0.29000,1.00000"
}
}
],
"glass": true,
"hidden": false,
"image-name": "streamyfin_logo_layer3.svg",
"name": "streamyfin_logo_layer3",
"opacity": 0.9
}
],
"name": "Group",
"opacity": 0.8,
"position": {
"scale": 1.7,
"translation-in-points": [30, 0]
},
"shadow": {
"kind": "none",
"opacity": 0.5
},
"specular": true,
"translucency": {
"enabled": true,
"value": 0.7
}
},
{
"blur-material": 0.5,
"hidden": false,
"layers": [
{
"glass": true,
"hidden-specializations": [
{
"value": false
},
{
"appearance": "tinted",
"value": true
}
],
"image-name": "streamyfin_logo_layer4.svg",
"name": "streamyfin_logo_layer4",
"opacity-specializations": [
{
"value": 1
},
{
"appearance": "tinted",
"value": 0
}
]
}
],
"lighting": "combined",
"name": "Group",
"opacity": 0.9,
"position": {
"scale": 1.7,
"translation-in-points": [30, 0]
},
"shadow": {
"kind": "neutral",
"opacity": 0.5
},
"specular": false,
"translucency": {
"enabled": true,
"value": 0.5
}
}
],
"supported-platforms": {
"circles": ["watchOS"],
"squares": "shared"
}
}

View File

@@ -1,4 +1,4 @@
import { MMKV } from "react-native-mmkv"; import { storage } from "@/utils/mmkv";
declare module "react-native-mmkv" { declare module "react-native-mmkv" {
interface MMKV { interface MMKV {
@@ -9,7 +9,7 @@ declare module "react-native-mmkv" {
// Add the augmentation methods directly to the MMKV prototype // Add the augmentation methods directly to the MMKV prototype
// This follows the recommended pattern while adding the helper methods your app uses // This follows the recommended pattern while adding the helper methods your app uses
MMKV.prototype.get = function <T>(key: string): T | undefined { (storage as any).get = function <T>(key: string): T | undefined {
try { try {
const serializedItem = this.getString(key); const serializedItem = this.getString(key);
if (!serializedItem) return undefined; if (!serializedItem) return undefined;
@@ -20,10 +20,10 @@ MMKV.prototype.get = function <T>(key: string): T | undefined {
} }
}; };
MMKV.prototype.setAny = function (key: string, value: any | undefined): void { (storage as any).setAny = function (key: string, value: any | undefined): void {
try { try {
if (value === undefined) { if (value === undefined) {
this.delete(key); this.remove(key);
} else { } else {
this.set(key, JSON.stringify(value)); this.set(key, JSON.stringify(value));
} }

View File

@@ -2,6 +2,6 @@ module.exports = (api) => {
api.cache(true); api.cache(true);
return { return {
presets: ["babel-preset-expo"], presets: ["babel-preset-expo"],
plugins: ["nativewind/babel", "react-native-reanimated/plugin"], plugins: ["nativewind/babel", "react-native-worklets/plugin"],
}; };
}; };

View File

@@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json", "$schema": "https://biomejs.dev/schemas/2.3.5/schema.json",
"files": { "files": {
"includes": [ "includes": [
"**/*", "**/*",

1111
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { FC } from "react"; import type { FC } from "react";
import { Platform, View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { RoundButton } from "@/components/RoundButton"; import { RoundButton } from "@/components/RoundButton";
import { useFavorite } from "@/hooks/useFavorite"; import { useFavorite } from "@/hooks/useFavorite";
@@ -11,24 +11,11 @@ interface Props extends ViewProps {
export const AddToFavorites: FC<Props> = ({ item, ...props }) => { export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
const { isFavorite, toggleFavorite } = useFavorite(item); const { isFavorite, toggleFavorite } = useFavorite(item);
if (Platform.OS === "ios") {
return (
<View {...props}>
<RoundButton
size='large'
icon={isFavorite ? "heart" : "heart-outline"}
onPress={toggleFavorite}
/>
</View>
);
}
return ( return (
<View {...props}> <View {...props}>
<RoundButton <RoundButton
size='large' size='large'
icon={isFavorite ? "heart" : "heart-outline"} icon={isFavorite ? "heart" : "heart-outline"}
fillColor={isFavorite ? "primary" : undefined}
onPress={toggleFavorite} onPress={toggleFavorite}
/> />
</View> </View>

View File

@@ -1,11 +1,9 @@
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo; source?: MediaSourceInfo;
@@ -20,6 +18,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
...props ...props
}) => { }) => {
const isTv = Platform.isTV; const isTv = Platform.isTV;
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const audioStreams = useMemo( const audioStreams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"), () => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
@@ -31,55 +31,58 @@ export const AudioTrackSelector: React.FC<Props> = ({
[audioStreams, selected], [audioStreams, selected],
); );
const { t } = useTranslation(); const optionGroups: OptionGroup[] = useMemo(
() => [
{
options:
audioStreams?.map((audio, idx) => ({
type: "radio" as const,
label: audio.DisplayTitle || `Audio Stream ${idx + 1}`,
value: audio.Index ?? idx,
selected: audio.Index === selected,
onPress: () => {
if (audio.Index !== null && audio.Index !== undefined) {
onChange(audio.Index);
}
},
})) || [],
},
],
[audioStreams, selected, onChange],
);
const handleOptionSelect = () => {
setOpen(false);
};
const trigger = (
<View className='flex flex-col' {...props}>
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.audio")}</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'
onPress={() => setOpen(true)}
>
<Text numberOfLines={1}>{selectedAudioSteam?.DisplayTitle}</Text>
</TouchableOpacity>
</View>
);
if (isTv) return null; if (isTv) return null;
return ( return (
<View <PlatformDropdown
className='flex shrink' groups={optionGroups}
style={{ trigger={trigger}
minWidth: 50, title={t("item_card.audio")}
open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
}} }}
> bottomSheetConfig={{
<DropdownMenu.Root> enablePanDownToClose: true,
<DropdownMenu.Trigger> }}
<View className='flex flex-col' {...props}> />
<Text className='opacity-50 mb-1 text-xs'>
{t("item_card.audio")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text className='' numberOfLines={1}>
{selectedAudioSteam?.DisplayTitle}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Audio streams</DropdownMenu.Label>
{audioStreams?.map((audio, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
if (audio.Index !== null && audio.Index !== undefined)
onChange(audio.Index);
}}
>
<DropdownMenu.ItemTitle>
{audio.DisplayTitle}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
); );
}; };

View File

@@ -1,10 +1,8 @@
import { Platform, TouchableOpacity, View } from "react-native"; import { useMemo, useState } from "react";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
export type Bitrate = { export type Bitrate = {
key: string; key: string;
@@ -61,6 +59,8 @@ export const BitrateSelector: React.FC<Props> = ({
...props ...props
}) => { }) => {
const isTv = Platform.isTV; const isTv = Platform.isTV;
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const sorted = useMemo(() => { const sorted = useMemo(() => {
if (inverted) if (inverted)
@@ -76,53 +76,59 @@ export const BitrateSelector: React.FC<Props> = ({
); );
}, [inverted]); }, [inverted]);
const { t } = useTranslation(); const optionGroups: OptionGroup[] = useMemo(
() => [
{
options: sorted.map((bitrate) => ({
type: "radio" as const,
label: bitrate.key,
value: bitrate,
selected: bitrate.value === selected?.value,
onPress: () => onChange(bitrate),
})),
},
],
[sorted, selected, onChange],
);
const handleOptionSelect = (optionId: string) => {
const selectedBitrate = sorted.find((b) => b.key === optionId);
if (selectedBitrate) {
onChange(selectedBitrate);
}
setOpen(false);
};
const trigger = (
<View className='flex flex-col' {...props}>
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.quality")}</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'
onPress={() => setOpen(true)}
>
<Text numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key}
</Text>
</TouchableOpacity>
</View>
);
if (isTv) return null; if (isTv) return null;
return ( return (
<View <PlatformDropdown
className='flex shrink' groups={optionGroups}
style={{ trigger={trigger}
minWidth: 60, title={t("item_card.quality")}
maxWidth: 200, open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
}} }}
> bottomSheetConfig={{
<DropdownMenu.Root> enablePanDownToClose: true,
<DropdownMenu.Trigger> }}
<View className='flex flex-col' {...props}> />
<Text className='opacity-50 mb-1 text-xs'>
{t("item_card.quality")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side='bottom'
align='center'
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
{sorted.map((b) => (
<DropdownMenu.Item
key={b.key}
onSelect={() => {
onChange(b);
}}
>
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
); );
}; };

View File

@@ -64,9 +64,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const { settings } = useSettings(); const { settings } = useSettings();
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false); const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
const { processes, startBackgroundDownload, getDownloadedItems } = const { processes, startBackgroundDownload, downloadedItems } = useDownload();
useDownload(); const downloadedFiles = downloadedItems;
const downloadedFiles = getDownloadedItems();
const [selectedOptions, setSelectedOptions] = useState< const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined SelectedOptions | undefined
@@ -90,11 +89,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
bottomSheetModalRef.current?.present(); bottomSheetModalRef.current?.present();
}, []); }, []);
const handleSheetChanges = useCallback((index: number) => { const handleSheetChanges = useCallback((_index: number) => {
// Ensure modal is fully dismissed when index is -1 // Modal state tracking handled by BottomSheetModal
if (index === -1) {
// Modal is fully closed
}
}, []); }, []);
const closeModal = useCallback(() => { const closeModal = useCallback(() => {
@@ -136,13 +132,15 @@ export const DownloadItems: React.FC<DownloadProps> = ({
return itemsNotDownloaded.length === 0; return itemsNotDownloaded.length === 0;
}, [items, itemsNotDownloaded]); }, [items, itemsNotDownloaded]);
const itemsProcesses = useMemo( const itemsProcesses = useMemo(
() => processes?.filter((p) => itemIds.includes(p.item.Id)), () =>
processes?.filter((p) => p?.item?.Id && itemIds.includes(p.item.Id)) ||
[],
[processes, itemIds], [processes, itemIds],
); );
const progress = useMemo(() => { const progress = useMemo(() => {
if (itemIds.length === 1) if (itemIds.length === 1)
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0); return itemsProcesses.reduce((acc, p) => acc + (p.progress || 0), 0);
return ( return (
((itemIds.length - ((itemIds.length -
queue.filter((q) => itemIds.includes(q.item.Id)).length) / queue.filter((q) => itemIds.includes(q.item.Id)).length) /
@@ -157,6 +155,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
itemsNotDownloaded.every((p) => queue.some((q) => p.Id === q.item.Id)) itemsNotDownloaded.every((p) => queue.some((q) => p.Id === q.item.Id))
); );
}, [queue, itemsNotDownloaded]); }, [queue, itemsNotDownloaded]);
const itemsInProgressOrQueued = useMemo(() => {
const inProgress = itemsProcesses.length;
const inQueue = queue.filter((q) => itemIds.includes(q.item.Id)).length;
return inProgress + inQueue;
}, [itemsProcesses, queue, itemIds]);
const navigateToDownloads = () => router.push("/downloads"); const navigateToDownloads = () => router.push("/downloads");
const onDownloadedPress = () => { const onDownloadedPress = () => {
@@ -256,13 +261,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
throw new Error("No item id"); throw new Error("No item id");
} }
// Ensure modal is dismissed before starting download closeModal();
await closeModal();
// Small delay to ensure modal is fully dismissed // Wait for modal dismiss animation to complete
setTimeout(() => { setTimeout(() => {
initiateDownload(...itemsToDownload); initiateDownload(...itemsToDownload);
}, 100); }, 300);
} else { } else {
toast.error( toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"), t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
@@ -282,7 +286,14 @@ export const DownloadItems: React.FC<DownloadProps> = ({
); );
const renderButtonContent = () => { const renderButtonContent = () => {
if (processes.length > 0 && itemsProcesses.length > 0) { // For single item downloads, show progress if item is being processed
// For multi-item downloads (season/series), show progress only if 2+ items are in progress or queued
const shouldShowProgress =
itemIds.length === 1
? itemsProcesses.length > 0
: itemsInProgressOrQueued > 1;
if (processes.length > 0 && shouldShowProgress) {
return progress === 0 ? ( return progress === 0 ? (
<Loader /> <Loader />
) : ( ) : (
@@ -336,9 +347,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
backgroundColor: "#171717", backgroundColor: "#171717",
}} }}
onChange={handleSheetChanges} onChange={handleSheetChanges}
onDismiss={() => {
// Ensure any pending state is cleared when modal is dismissed
}}
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
enablePanDownToClose enablePanDownToClose
enableDismissOnClose enableDismissOnClose
@@ -359,16 +367,18 @@ export const DownloadItems: React.FC<DownloadProps> = ({
})} })}
</Text> </Text>
</View> </View>
<View className='flex flex-col space-y-2 w-full items-start'> <View className='flex flex-col space-y-2 w-full'>
<BitrateSelector <View className='items-start'>
inverted <BitrateSelector
onChange={(val) => inverted
setSelectedOptions( onChange={(val) =>
(prev) => prev && { ...prev, bitrate: val }, setSelectedOptions(
) (prev) => prev && { ...prev, bitrate: val },
} )
selected={selectedOptions?.bitrate} }
/> selected={selectedOptions?.bitrate}
/>
</View>
{itemsNotDownloaded.length > 1 && ( {itemsNotDownloaded.length > 1 && (
<View className='flex flex-row items-center justify-between w-full py-2'> <View className='flex flex-row items-center justify-between w-full py-2'>
<Text>{t("item_card.download.download_unwatched_only")}</Text> <Text>{t("item_card.download.download_unwatched_only")}</Text>
@@ -380,21 +390,23 @@ export const DownloadItems: React.FC<DownloadProps> = ({
)} )}
{itemsNotDownloaded.length === 1 && ( {itemsNotDownloaded.length === 1 && (
<View> <View>
<MediaSourceSelector <View className='items-start'>
item={items[0]} <MediaSourceSelector
onChange={(val) => item={items[0]}
setSelectedOptions( onChange={(val) =>
(prev) => setSelectedOptions(
prev && { (prev) =>
...prev, prev && {
mediaSource: val, ...prev,
}, mediaSource: val,
) },
} )
selected={selectedOptions?.mediaSource} }
/> selected={selectedOptions?.mediaSource}
/>
</View>
{selectedOptions?.mediaSource && ( {selectedOptions?.mediaSource && (
<View className='flex flex-col space-y-2'> <View className='flex flex-col space-y-2 items-start'>
<AudioTrackSelector <AudioTrackSelector
source={selectedOptions.mediaSource} source={selectedOptions.mediaSource}
onChange={(val) => { onChange={(val) => {
@@ -427,11 +439,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
)} )}
</View> </View>
<Button <Button onPress={acceptDownloadOptions} color='purple'>
className='mt-auto'
onPress={acceptDownloadOptions}
color='purple'
>
{t("item_card.download.download_button")} {t("item_card.download.download_button")}
</Button> </Button>
</View> </View>

View File

@@ -0,0 +1,203 @@
/**
* Example Usage of Global Modal
*
* This file demonstrates how to use the global modal system from anywhere in your app.
* You can delete this file after understanding how it works.
*/
import { Ionicons } from "@expo/vector-icons";
import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
/**
* Example 1: Simple Content Modal
*/
export const SimpleModalExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6'>
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
<Text className='text-white mb-4'>
This is a simple modal with just some text content.
</Text>
<Text className='text-neutral-400'>
Swipe down or tap outside to close.
</Text>
</View>,
);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-purple-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Open Simple Modal</Text>
</TouchableOpacity>
);
};
/**
* Example 2: Modal with Custom Snap Points
*/
export const CustomSnapPointsExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6' style={{ minHeight: 400 }}>
<Text className='text-2xl font-bold mb-4 text-white'>
Custom Snap Points
</Text>
<Text className='text-white mb-4'>
This modal has custom snap points (25%, 50%, 90%).
</Text>
<View className='bg-neutral-800 p-4 rounded-lg'>
<Text className='text-white'>
Try dragging the modal to different heights!
</Text>
</View>
</View>,
{
snapPoints: ["25%", "50%", "90%"],
enableDynamicSizing: false,
},
);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-blue-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Custom Snap Points</Text>
</TouchableOpacity>
);
};
/**
* Example 3: Complex Component in Modal
*/
const SettingsModalContent = () => {
const { hideModal } = useGlobalModal();
const settings = [
{
id: 1,
title: "Notifications",
icon: "notifications-outline" as const,
enabled: true,
},
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
{
id: 3,
title: "Auto-play",
icon: "play-outline" as const,
enabled: false,
},
];
return (
<View className='p-6'>
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
{settings.map((setting, index) => (
<View
key={setting.id}
className={`flex-row items-center justify-between py-4 ${
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
}`}
>
<View className='flex-row items-center gap-3'>
<Ionicons name={setting.icon} size={24} color='white' />
<Text className='text-white text-lg'>{setting.title}</Text>
</View>
<View
className={`w-12 h-7 rounded-full ${
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
}`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
setting.enabled ? "translate-x-6" : "translate-x-1"
}`}
style={{ marginTop: 4 }}
/>
</View>
</View>
))}
<TouchableOpacity
onPress={hideModal}
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
>
<Text className='text-white font-semibold text-center'>Close</Text>
</TouchableOpacity>
</View>
);
};
export const ComplexModalExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(<SettingsModalContent />);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-green-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Complex Component</Text>
</TouchableOpacity>
);
};
/**
* Example 4: Modal Triggered from Function (e.g., API response)
*/
export const useShowSuccessModal = () => {
const { showModal } = useGlobalModal();
return (message: string) => {
showModal(
<View className='p-6 items-center'>
<View className='bg-green-500 rounded-full p-4 mb-4'>
<Ionicons name='checkmark' size={48} color='white' />
</View>
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
<Text className='text-white text-center'>{message}</Text>
</View>,
);
};
};
/**
* Main Demo Component
*/
export const GlobalModalDemo = () => {
const showSuccess = useShowSuccessModal();
return (
<View className='p-6 gap-4'>
<Text className='text-2xl font-bold mb-4 text-white'>
Global Modal Examples
</Text>
<SimpleModalExample />
<CustomSnapPointsExample />
<ComplexModalExample />
<TouchableOpacity
onPress={() => showSuccess("Operation completed successfully!")}
className='bg-orange-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Show Success Modal</Text>
</TouchableOpacity>
</View>
);
};

View File

@@ -0,0 +1,73 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
} from "@gorhom/bottom-sheet";
import { useCallback } from "react";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
/**
* GlobalModal Component
*
* This component renders a global bottom sheet modal that can be controlled
* from anywhere in the app using the useGlobalModal hook.
*
* Place this component at the root level of your app (in _layout.tsx)
* after BottomSheetModalProvider.
*/
export const GlobalModal = () => {
const { hideModal, modalState, modalRef } = useGlobalModal();
const handleSheetChanges = useCallback(
(index: number) => {
if (index === -1) {
hideModal();
}
},
[hideModal],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const defaultOptions = {
enableDynamicSizing: true,
enablePanDownToClose: true,
backgroundStyle: {
backgroundColor: "#171717",
},
handleIndicatorStyle: {
backgroundColor: "white",
},
};
// Merge default options with provided options
const modalOptions = { ...defaultOptions, ...modalState.options };
return (
<BottomSheetModal
ref={modalRef}
{...(modalOptions.snapPoints
? { snapPoints: modalOptions.snapPoints }
: { enableDynamicSizing: modalOptions.enableDynamicSizing })}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={modalOptions.handleIndicatorStyle}
backgroundStyle={modalOptions.backgroundStyle}
enablePanDownToClose={modalOptions.enablePanDownToClose}
enableDismissOnClose
stackBehavior='push'
style={{ zIndex: 1000 }}
>
{modalState.content}
</BottomSheetModal>
);
};

View File

@@ -204,7 +204,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'> <View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
<ItemHeader item={item} className='mb-2' /> <ItemHeader item={item} className='mb-2' />
{item.Type !== "Program" && !Platform.isTV && !isOffline && ( {item.Type !== "Program" && !Platform.isTV && !isOffline && (
<View className='flex flex-row items-center justify-start w-full h-16'> <View className='flex flex-row items-center justify-start w-full h-16 mb-2'>
<BitrateSheet <BitrateSheet
className='mr-1' className='mr-1'
onChange={(val) => onChange={(val) =>

View File

@@ -2,13 +2,11 @@ import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo, useState } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto; item: BaseItemDto;
@@ -23,7 +21,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
...props ...props
}) => { }) => {
const isTv = Platform.isTV; const isTv = Platform.isTV;
const [open, setOpen] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const getDisplayName = useCallback((source: MediaSourceInfo) => { const getDisplayName = useCallback((source: MediaSourceInfo) => {
@@ -46,50 +44,60 @@ export const MediaSourceSelector: React.FC<Props> = ({
return getDisplayName(selected); return getDisplayName(selected);
}, [selected, getDisplayName]); }, [selected, getDisplayName]);
const optionGroups: OptionGroup[] = useMemo(
() => [
{
options:
item.MediaSources?.map((source) => ({
type: "radio" as const,
label: getDisplayName(source),
value: source,
selected: source.Id === selected?.Id,
onPress: () => onChange(source),
})) || [],
},
],
[item.MediaSources, selected, getDisplayName, onChange],
);
const handleOptionSelect = (optionId: string) => {
const selectedSource = item.MediaSources?.find(
(source, idx) => `${source.Id || idx}` === optionId,
);
if (selectedSource) {
onChange(selectedSource);
}
setOpen(false);
};
const trigger = (
<View className='flex flex-col' {...props}>
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
onPress={() => setOpen(true)}
>
<Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity>
</View>
);
if (isTv) return null; if (isTv) return null;
return ( return (
<View <PlatformDropdown
className='flex shrink' groups={optionGroups}
style={{ trigger={trigger}
minWidth: 50, title={t("item_card.video")}
open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
}} }}
> bottomSheetConfig={{
<DropdownMenu.Root> enablePanDownToClose: true,
<DropdownMenu.Trigger> }}
<View className='flex flex-col' {...props}> />
<Text className='opacity-50 mb-1 text-xs'>
{t("item_card.video")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'>
<Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
{item.MediaSources?.map((source, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
onChange(source);
}}
>
<DropdownMenu.ItemTitle>
{getDisplayName(source)}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
); );
}; };

View File

@@ -99,7 +99,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
style={{ style={{
top: -50, top: -50,
}} }}
className='relative flex-1 bg-transparent pb-24' className='relative flex-1 bg-transparent pb-4'
> >
<LinearGradient <LinearGradient
// Background Linear Gradient // Background Linear Gradient

View File

@@ -0,0 +1,337 @@
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect } from "react";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
// Option types
export type RadioOption<T = any> = {
type: "radio";
label: string;
value: T;
selected: boolean;
onPress: () => void;
disabled?: boolean;
};
export type ToggleOption = {
type: "toggle";
label: string;
value: boolean;
onToggle: () => void;
disabled?: boolean;
};
export type Option = RadioOption | ToggleOption;
// Option group structure
export type OptionGroup = {
title?: string;
options: Option[];
};
interface PlatformDropdownProps {
trigger?: React.ReactNode;
title?: string;
groups: OptionGroup[];
open?: boolean;
onOpenChange?: (open: boolean) => void;
onOptionSelect?: (value?: any) => void;
expoUIConfig?: {
hostStyle?: any;
};
bottomSheetConfig?: {
enableDynamicSizing?: boolean;
enablePanDownToClose?: boolean;
};
}
const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
<View
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${
value ? "translate-x-6" : "translate-x-1"
}`}
/>
</View>
);
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
option,
isLast,
}) => {
const isToggle = option.type === "toggle";
const handlePress = isToggle ? option.onToggle : option.onPress;
return (
<>
<TouchableOpacity
onPress={handlePress}
disabled={option.disabled}
className={`px-4 py-3 flex flex-row items-center justify-between ${
option.disabled ? "opacity-50" : ""
}`}
>
<Text className='flex-1 text-white'>{option.label}</Text>
{isToggle ? (
<ToggleSwitch value={option.value} />
) : option.selected ? (
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
) : (
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
)}
</TouchableOpacity>
{!isLast && (
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='bg-neutral-700 mx-4'
/>
)}
</>
);
};
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
<View className='mb-6'>
{group.title && (
<Text className='text-lg font-semibold mb-3 text-neutral-300'>
{group.title}
</Text>
)}
<View
style={{
borderRadius: 12,
overflow: "hidden",
}}
className='bg-neutral-800 rounded-xl overflow-hidden'
>
{group.options.map((option, index) => (
<OptionItem
key={index}
option={option}
isLast={index === group.options.length - 1}
/>
))}
</View>
</View>
);
const BottomSheetContent: React.FC<{
title?: string;
groups: OptionGroup[];
onOptionSelect?: (value?: any) => void;
onClose?: () => void;
}> = ({ title, groups, onOptionSelect, onClose }) => {
const insets = useSafeAreaInsets();
// Wrap the groups to call onOptionSelect when an option is pressed
const wrappedGroups = groups.map((group) => ({
...group,
options: group.options.map((option) => {
if (option.type === "radio") {
return {
...option,
onPress: () => {
option.onPress();
onOptionSelect?.(option.value);
onClose?.();
},
};
}
if (option.type === "toggle") {
return {
...option,
onToggle: () => {
option.onToggle();
onOptionSelect?.(option.value);
},
};
}
return option;
}),
}));
return (
<BottomSheetScrollView
className='px-4 pb-8 pt-2'
style={{
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
}}
>
{title && <Text className='font-bold text-2xl mb-6'>{title}</Text>}
{wrappedGroups.map((group, index) => (
<OptionGroupComponent key={index} group={group} />
))}
</BottomSheetScrollView>
);
};
const PlatformDropdownComponent = ({
trigger,
title,
groups,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
onOptionSelect,
expoUIConfig,
bottomSheetConfig,
}: PlatformDropdownProps) => {
const { showModal, hideModal } = useGlobalModal();
// Handle controlled open state for Android
useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true) {
showModal(
<BottomSheetContent
title={title}
groups={groups}
onOptionSelect={onOptionSelect}
onClose={() => {
hideModal();
controlledOnOpenChange?.(false);
}}
/>,
{
snapPoints: ["90%"],
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
},
);
}
}, [controlledOpen]);
if (Platform.OS === "ios") {
return (
<Host style={expoUIConfig?.hostStyle}>
<ContextMenu>
<ContextMenu.Trigger>
<View className=''>
{trigger || <Button variant='bordered'>Show Menu</Button>}
</View>
</ContextMenu.Trigger>
<ContextMenu.Items>
{groups.flatMap((group, groupIndex) => {
// Check if this group has radio options
const radioOptions = group.options.filter(
(opt) => opt.type === "radio",
) as RadioOption[];
const toggleOptions = group.options.filter(
(opt) => opt.type === "toggle",
) as ToggleOption[];
const items = [];
// Add Picker for radio options ONLY if there's a group title
// Otherwise render as individual buttons
if (radioOptions.length > 0) {
if (group.title) {
// Use Picker for grouped options
items.push(
<Picker
key={`picker-${groupIndex}`}
label={group.title}
options={radioOptions.map((opt) => opt.label)}
variant='menu'
selectedIndex={radioOptions.findIndex(
(opt) => opt.selected,
)}
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
const selectedOption = radioOptions[index];
selectedOption?.onPress();
onOptionSelect?.(selectedOption?.value);
}}
/>,
);
} else {
// Render radio options as direct buttons
radioOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`radio-${groupIndex}-${optionIndex}`}
systemImage={
option.selected ? "checkmark.circle.fill" : "circle"
}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
}}
disabled={option.disabled}
>
{option.label}
</Button>,
);
});
}
}
// Add Buttons for toggle options
toggleOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`toggle-${groupIndex}-${optionIndex}`}
systemImage={
option.value ? "checkmark.circle.fill" : "circle"
}
onPress={() => {
option.onToggle();
onOptionSelect?.(option.value);
}}
disabled={option.disabled}
>
{option.label}
</Button>,
);
});
return items;
})}
</ContextMenu.Items>
</ContextMenu>
</Host>
);
}
// Android: Direct modal trigger
const handlePress = () => {
showModal(
<BottomSheetContent
title={title}
groups={groups}
onOptionSelect={onOptionSelect}
onClose={hideModal}
/>,
{
snapPoints: ["90%"],
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
},
);
};
return (
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
{trigger || <Text className='text-white'>Open Menu</Text>}
</TouchableOpacity>
);
};
// Memoize to prevent unnecessary re-renders when parent re-renders
export const PlatformDropdown = React.memo(
PlatformDropdownComponent,
(prevProps, nextProps) => {
// Custom comparison - only re-render if these props actually change
return (
prevProps.title === nextProps.title &&
prevProps.open === nextProps.open &&
prevProps.groups === nextProps.groups && // Reference equality (works because we memoize groups in caller)
prevProps.trigger === nextProps.trigger // Reference equality
);
},
);

View File

@@ -33,10 +33,9 @@ import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast"; import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265"; import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import { runtimeTicksToMinutes } from "@/utils/time"; import { runtimeTicksToMinutes } from "@/utils/time";
import type { Button } from "./Button";
import type { SelectedOptions } from "./ItemContent"; import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof Button> { interface Props extends React.ComponentProps<typeof TouchableOpacity> {
item: BaseItemDto; item: BaseItemDto;
selectedOptions: SelectedOptions; selectedOptions: SelectedOptions;
isOffline?: boolean; isOffline?: boolean;
@@ -51,7 +50,6 @@ export const PlayButton: React.FC<Props> = ({
selectedOptions, selectedOptions,
isOffline, isOffline,
colors, colors,
...props
}: Props) => { }: Props) => {
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient(); const client = useRemoteMediaClient();
@@ -165,7 +163,7 @@ export const PlayButton: React.FC<Props> = ({
api, api,
item, item,
deviceProfile: enableH265 ? chromecasth265 : chromecast, deviceProfile: enableH265 ? chromecasth265 : chromecast,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!, startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
userId: user.Id, userId: user.Id,
audioStreamIndex: selectedOptions.audioIndex, audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value, maxStreamingBitrate: selectedOptions.bitrate?.value,
@@ -364,6 +362,52 @@ export const PlayButton: React.FC<Props> = ({
* ********************* * *********************
*/ */
// if (Platform.OS === "ios")
// return (
// <Host
// style={{
// height: 50,
// flex: 1,
// flexShrink: 0,
// }}
// >
// <Button
// variant='glassProminent'
// onPress={onPress}
// color={effectiveColors.primary}
// modifiers={[fixedSize()]}
// >
// <View className='flex flex-row items-center space-x-2 h-full w-full justify-center -mb-3.5 '>
// <Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
// {runtimeTicksToMinutes(
// (item?.RunTimeTicks || 0) -
// (item?.UserData?.PlaybackPositionTicks || 0),
// )}
// {(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
// </Animated.Text>
// <Animated.Text style={animatedTextStyle}>
// <Ionicons name='play-circle' size={24} />
// </Animated.Text>
// {client && (
// <Animated.Text style={animatedTextStyle}>
// <Feather name='cast' size={22} />
// <CastButton tintColor='transparent' />
// </Animated.Text>
// )}
// {!client && settings?.openInVLC && (
// <Animated.Text style={animatedTextStyle}>
// <MaterialCommunityIcons
// name='vlc'
// size={18}
// color={animatedTextStyle.color}
// />
// </Animated.Text>
// )}
// </View>
// </Button>
// </Host>
// );
return ( return (
<TouchableOpacity <TouchableOpacity
disabled={!item} disabled={!item}
@@ -371,7 +415,6 @@ export const PlayButton: React.FC<Props> = ({
accessibilityHint='Tap to play the media' accessibilityHint='Tap to play the media'
onPress={onPress} onPress={onPress}
className={"relative"} className={"relative"}
{...props}
> >
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'> <View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
<Animated.View <Animated.View
@@ -399,7 +442,11 @@ export const PlayButton: React.FC<Props> = ({
> >
<View className='flex flex-row items-center space-x-2'> <View className='flex flex-row items-center space-x-2'>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}> <Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)} {runtimeTicksToMinutes(
(item?.RunTimeTicks || 0) -
(item?.UserData?.PlaybackPositionTicks || 0),
)}
{(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
</Animated.Text> </Animated.Text>
<Animated.Text style={animatedTextStyle}> <Animated.Text style={animatedTextStyle}>
<Ionicons name='play-circle' size={24} /> <Ionicons name='play-circle' size={24} />

View File

@@ -1,6 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react"; import type React from "react";
import { Platform, View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
@@ -14,25 +14,10 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
const allPlayed = items.every((item) => item.UserData?.Played); const allPlayed = items.every((item) => item.UserData?.Played);
const toggle = useMarkAsPlayed(items); const toggle = useMarkAsPlayed(items);
if (Platform.OS === "ios") {
return (
<View {...props}>
<RoundButton
color={allPlayed ? "purple" : "white"}
icon={allPlayed ? "checkmark" : "checkmark"}
onPress={async () => {
await toggle(!allPlayed);
}}
size={props.size}
/>
</View>
);
}
return ( return (
<View {...props}> <View {...props}>
<RoundButton <RoundButton
fillColor={allPlayed ? "primary" : undefined} color={allPlayed ? "purple" : "white"}
icon={allPlayed ? "checkmark" : "checkmark"} icon={allPlayed ? "checkmark" : "checkmark"}
onPress={async () => { onPress={async () => {
await toggle(!allPlayed); await toggle(!allPlayed);

View File

@@ -96,7 +96,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity <TouchableOpacity
onPress={handlePress} onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${ className={`rounded-full ${buttonSize} flex items-center justify-center ${
fillColor ? fillColorClass : "bg-neutral-800/80" fillColor ? fillColorClass : "bg-transparent"
}`} }`}
{...(viewProps as any)} {...(viewProps as any)}
> >

View File

@@ -1,12 +1,10 @@
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { tc } from "@/utils/textTools"; import { tc } from "@/utils/textTools";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo; source?: MediaSourceInfo;
@@ -21,6 +19,8 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
...props ...props
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false);
const subtitleStreams = useMemo(() => { const subtitleStreams = useMemo(() => {
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle"); return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
}, [source]); }, [source]);
@@ -30,64 +30,83 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
[subtitleStreams, selected], [subtitleStreams, selected],
); );
const optionGroups: OptionGroup[] = useMemo(() => {
const options = [
{
type: "radio" as const,
label: t("item_card.none"),
value: -1,
selected: selected === -1,
onPress: () => onChange(-1),
},
...(subtitleStreams?.map((subtitle, idx) => ({
type: "radio" as const,
label: subtitle.DisplayTitle || `Subtitle Stream ${idx + 1}`,
value: subtitle.Index,
selected: subtitle.Index === selected,
onPress: () => onChange(subtitle.Index ?? -1),
})) || []),
];
return [
{
options,
},
];
}, [subtitleStreams, selected, t, onChange]);
const handleOptionSelect = (optionId: string) => {
if (optionId === "none") {
onChange(-1);
} else {
const selectedStream = subtitleStreams?.find(
(subtitle, idx) => `${subtitle.Index || idx}` === optionId,
);
if (
selectedStream &&
selectedStream.Index !== undefined &&
selectedStream.Index !== null
) {
onChange(selectedStream.Index);
}
}
setOpen(false);
};
const trigger = (
<View className='flex flex-col' {...props}>
<Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
{t("item_card.subtitles")}
</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'
onPress={() => setOpen(true)}
>
<Text>
{selectedSubtitleSteam
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
: t("item_card.none")}
</Text>
</TouchableOpacity>
</View>
);
if (Platform.isTV || subtitleStreams?.length === 0) return null; if (Platform.isTV || subtitleStreams?.length === 0) return null;
return ( return (
<View <PlatformDropdown
className='flex col shrink justify-start place-self-start items-start' groups={optionGroups}
style={{ trigger={trigger}
minWidth: 60, title={t("item_card.subtitles")}
maxWidth: 200, open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
}} }}
> bottomSheetConfig={{
<DropdownMenu.Root> enablePanDownToClose: true,
<DropdownMenu.Trigger> }}
<View className='flex flex-col ' {...props}> />
<Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
{t("item_card.subtitles")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text className=' '>
{selectedSubtitleSteam
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
: t("item_card.none")}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Subtitle tracks</DropdownMenu.Label>
<DropdownMenu.Item
key={"-1"}
onSelect={() => {
onChange(-1);
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{subtitleStreams?.map((subtitle, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
if (subtitle.Index !== undefined && subtitle.Index !== null)
onChange(subtitle.Index);
}}
>
<DropdownMenu.ItemTitle>
{subtitle.DisplayTitle}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
); );
}; };

View File

@@ -33,12 +33,27 @@ export const TrackSheet: React.FC<Props> = ({
() => streams?.find((x) => x.Index === selected), () => streams?.find((x) => x.Index === selected),
[streams, selected], [streams, selected],
); );
const noneOption = useMemo(
() => ({ Index: -1, DisplayTitle: t("common.none") }),
[t],
);
// Creates a modified data array that includes a "None" option for subtitles
// We might want to possibly do this for other places, like audio?
const addNoneToSubtitles = useMemo(() => {
if (streamType === "Subtitle") {
const result = streams ? [noneOption, ...streams] : [noneOption];
return result;
}
return streams;
}, [streams, streamType, noneOption]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
if (isTv || (streams && streams.length === 0)) return null; if (isTv || (streams && streams.length === 0)) return null;
return ( return (
<View className='flex shrink' style={{ minWidth: 25 }} {...props}> <View className='flex shrink' style={{ minWidth: 60 }} {...props}>
<View className='flex flex-col'> <View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>{title}</Text> <Text className='opacity-50 mb-1 text-xs'>{title}</Text>
<TouchableOpacity <TouchableOpacity
@@ -46,7 +61,9 @@ export const TrackSheet: React.FC<Props> = ({
onPress={() => setOpen(true)} onPress={() => setOpen(true)}
> >
<Text numberOfLines={1}> <Text numberOfLines={1}>
{selectedSteam?.DisplayTitle || t("common.select", "Select")} {selected === -1 && streamType === "Subtitle"
? t("common.none")
: selectedSteam?.DisplayTitle || t("common.select", "Select")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -54,8 +71,14 @@ export const TrackSheet: React.FC<Props> = ({
open={open} open={open}
setOpen={setOpen} setOpen={setOpen}
title={title} title={title}
data={streams || []} data={addNoneToSubtitles || []}
values={selectedSteam ? [selectedSteam] : []} values={
selected === -1 && streamType === "Subtitle"
? [{ Index: -1, DisplayTitle: t("common.none") }]
: selectedSteam
? [selectedSteam]
: []
}
multiple={false} multiple={false}
searchFilter={(item, query) => { searchFilter={(item, query) => {
const label = (item as any).DisplayTitle || ""; const label = (item as any).DisplayTitle || "";

View File

@@ -10,45 +10,52 @@ import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Dimensions, Pressable, TouchableOpacity, View } from "react-native"; import {
Pressable,
TouchableOpacity,
useWindowDimensions,
View,
} from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, { import Animated, {
Easing, Easing,
interpolate,
runOnJS, runOnJS,
type SharedValue,
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { ItemImage } from "./common/ItemImage"; import { ItemImage } from "../common/ItemImage";
import { getItemNavigation } from "./common/TouchableItemRouter"; import { getItemNavigation } from "../common/TouchableItemRouter";
import type { SelectedOptions } from "./ItemContent"; import type { SelectedOptions } from "../ItemContent";
import { PlayButton } from "./PlayButton"; import { PlayButton } from "../PlayButton";
import { PlayedStatus } from "./PlayedStatus"; import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton";
interface AppleTVCarouselProps { interface AppleTVCarouselProps {
initialIndex?: number; initialIndex?: number;
onItemChange?: (index: number) => void; onItemChange?: (index: number) => void;
scrollOffset?: SharedValue<number>;
} }
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
// Layout Constants // Layout Constants
const CAROUSEL_HEIGHT = screenHeight / 1.45;
const GRADIENT_HEIGHT_TOP = 150; const GRADIENT_HEIGHT_TOP = 150;
const GRADIENT_HEIGHT_BOTTOM = 150; const GRADIENT_HEIGHT_BOTTOM = 150;
const LOGO_HEIGHT = 80; const LOGO_HEIGHT = 80;
// Position Constants // Position Constants
const LOGO_BOTTOM_POSITION = 210; const LOGO_BOTTOM_POSITION = 260;
const GENRES_BOTTOM_POSITION = 170; const GENRES_BOTTOM_POSITION = 220;
const CONTROLS_BOTTOM_POSITION = 100; const OVERVIEW_BOTTOM_POSITION = 165;
const DOTS_BOTTOM_POSITION = 60; const CONTROLS_BOTTOM_POSITION = 80;
const DOTS_BOTTOM_POSITION = 40;
// Size Constants // Size Constants
const DOT_HEIGHT = 6; const DOT_HEIGHT = 6;
@@ -58,13 +65,15 @@ const PLAY_BUTTON_SKELETON_HEIGHT = 50;
const PLAYED_STATUS_SKELETON_SIZE = 40; const PLAYED_STATUS_SKELETON_SIZE = 40;
const TEXT_SKELETON_HEIGHT = 20; const TEXT_SKELETON_HEIGHT = 20;
const TEXT_SKELETON_WIDTH = 250; const TEXT_SKELETON_WIDTH = 250;
const OVERVIEW_SKELETON_HEIGHT = 16;
const OVERVIEW_SKELETON_WIDTH = 400;
const _EMPTY_STATE_ICON_SIZE = 64; const _EMPTY_STATE_ICON_SIZE = 64;
// Spacing Constants // Spacing Constants
const HORIZONTAL_PADDING = 40; const HORIZONTAL_PADDING = 40;
const DOT_PADDING = 2; const DOT_PADDING = 2;
const DOT_GAP = 4; const DOT_GAP = 4;
const CONTROLS_GAP = 20; const CONTROLS_GAP = 10;
const _TEXT_MARGIN_TOP = 16; const _TEXT_MARGIN_TOP = 16;
// Border Radius Constants // Border Radius Constants
@@ -83,13 +92,16 @@ const VELOCITY_THRESHOLD = 400;
// Text Constants // Text Constants
const GENRES_FONT_SIZE = 16; const GENRES_FONT_SIZE = 16;
const OVERVIEW_FONT_SIZE = 14;
const _EMPTY_STATE_FONT_SIZE = 18; const _EMPTY_STATE_FONT_SIZE = 18;
const TEXT_SHADOW_RADIUS = 2; const TEXT_SHADOW_RADIUS = 2;
const MAX_GENRES_COUNT = 2; const MAX_GENRES_COUNT = 2;
const MAX_BUTTON_WIDTH = 300; const MAX_BUTTON_WIDTH = 300;
const OVERVIEW_MAX_LINES = 2;
const OVERVIEW_MAX_WIDTH = "80%";
// Opacity Constants // Opacity Constants
const OVERLAY_OPACITY = 0.4; const OVERLAY_OPACITY = 0.3;
const DOT_INACTIVE_OPACITY = 0.6; const DOT_INACTIVE_OPACITY = 0.6;
const TEXT_OPACITY = 0.9; const TEXT_OPACITY = 0.9;
@@ -147,14 +159,21 @@ const DotIndicator = ({
export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
initialIndex = 0, initialIndex = 0,
onItemChange, onItemChange,
scrollOffset,
}) => { }) => {
const { settings } = useSettings(); const { settings } = useSettings();
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const { isConnected, serverConnected } = useNetworkStatus(); const { isConnected, serverConnected } = useNetworkStatus();
const router = useRouter(); const router = useRouter();
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
const isLandscape = screenWidth >= screenHeight;
const carouselHeight = useMemo(
() => (isLandscape ? screenHeight * 0.9 : screenHeight / 1.45),
[isLandscape, screenHeight],
);
const [currentIndex, setCurrentIndex] = useState(initialIndex); const [currentIndex, setCurrentIndex] = useState(initialIndex);
const translateX = useSharedValue(-currentIndex * screenWidth); const translateX = useSharedValue(-initialIndex * screenWidth);
const isQueryEnabled = const isQueryEnabled =
!!api && !!user?.Id && isConnected && serverConnected === true; !!api && !!user?.Id && isConnected && serverConnected === true;
@@ -168,7 +187,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
userId: user.Id, userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes: ["Movie", "Series", "Episode"], includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Genres"], fields: ["Genres", "Overview"],
limit: 2, limit: 2,
}); });
return response.data.Items || []; return response.data.Items || [];
@@ -183,7 +202,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
if (!api || !user?.Id) return []; if (!api || !user?.Id) return [];
const response = await getTvShowsApi(api).getNextUp({ const response = await getTvShowsApi(api).getNextUp({
userId: user.Id, userId: user.Id,
fields: ["MediaSourceCount", "Genres"], fields: ["MediaSourceCount", "Genres", "Overview"],
limit: 2, limit: 2,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: false, enableResumable: false,
@@ -202,7 +221,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
const response = await getUserLibraryApi(api).getLatestMedia({ const response = await getUserLibraryApi(api).getLatestMedia({
userId: user.Id, userId: user.Id,
limit: 2, limit: 2,
fields: ["PrimaryImageAspectRatio", "Path", "Genres"], fields: ["PrimaryImageAspectRatio", "Path", "Genres", "Overview"],
imageTypeLimit: 1, imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
}); });
@@ -218,11 +237,21 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
const nextItems = nextUpData ?? []; const nextItems = nextUpData ?? [];
const recentItems = recentlyAddedData ?? []; const recentItems = recentlyAddedData ?? [];
return [ const allItems = [
...continueItems.slice(0, 2), ...continueItems.slice(0, 2),
...nextItems.slice(0, 2), ...nextItems.slice(0, 2),
...recentItems.slice(0, 2), ...recentItems.slice(0, 2),
]; ];
// Deduplicate by item ID to prevent duplicate keys
const seen = new Set<string>();
return allItems.filter((item) => {
if (item.Id && !seen.has(item.Id)) {
seen.add(item.Id);
return true;
}
return false;
});
}, [continueWatchingData, nextUpData, recentlyAddedData]); }, [continueWatchingData, nextUpData, recentlyAddedData]);
const isLoading = const isLoading =
@@ -281,7 +310,11 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
translateX.value = -newIndex * screenWidth; translateX.value = -newIndex * screenWidth;
return newIndex; return newIndex;
}); });
}, [hasItems, items, initialIndex, translateX]); }, [hasItems, items, initialIndex, screenWidth, translateX]);
useEffect(() => {
translateX.value = -currentIndex * screenWidth;
}, [currentIndex, screenWidth, translateX]);
useEffect(() => { useEffect(() => {
if (hasItems) { if (hasItems) {
@@ -301,7 +334,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
setCurrentIndex(index); setCurrentIndex(index);
onItemChange?.(index); onItemChange?.(index);
}, },
[hasItems, items, onItemChange, translateX], [hasItems, items, onItemChange, screenWidth, translateX],
); );
const navigateToItem = useCallback( const navigateToItem = useCallback(
@@ -348,6 +381,30 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
}; };
}); });
const togglePlayedStatus = useMarkAsPlayed(items);
const headerAnimatedStyle = useAnimatedStyle(() => {
if (!scrollOffset) return {};
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-carouselHeight, 0, carouselHeight],
[-carouselHeight / 2, 0, carouselHeight * 0.75],
),
},
{
scale: interpolate(
scrollOffset.value,
[-carouselHeight, 0, carouselHeight],
[2, 1, 1],
),
},
],
};
});
const renderDots = () => { const renderDots = () => {
if (!hasItems || items.length <= 1) return null; if (!hasItems || items.length <= 1) return null;
@@ -381,7 +438,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
<View <View
style={{ style={{
width: screenWidth, width: screenWidth,
height: CAROUSEL_HEIGHT, height: carouselHeight,
backgroundColor: "#000", backgroundColor: "#000",
}} }}
> >
@@ -473,6 +530,36 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
/> />
</View> </View>
{/* Overview Skeleton */}
<View
style={{
position: "absolute",
bottom: OVERVIEW_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
gap: 6,
}}
>
<View
style={{
height: OVERVIEW_SKELETON_HEIGHT,
width: OVERVIEW_SKELETON_WIDTH,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
}}
/>
<View
style={{
height: OVERVIEW_SKELETON_HEIGHT,
width: OVERVIEW_SKELETON_WIDTH * 0.7,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
}}
/>
</View>
{/* Controls Skeleton */} {/* Controls Skeleton */}
<View <View
style={{ style={{
@@ -549,20 +636,30 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
key={item.Id} key={item.Id}
style={{ style={{
width: screenWidth, width: screenWidth,
height: CAROUSEL_HEIGHT, height: carouselHeight,
position: "relative", position: "relative",
}} }}
> >
{/* Background Backdrop */} {/* Background Backdrop */}
<ItemImage <Animated.View
item={item} style={[
variant='Backdrop' {
style={{ width: "100%",
width: "100%", height: "100%",
height: "100%", position: "absolute",
position: "absolute", },
}} headerAnimatedStyle,
/> ]}
>
<ItemImage
item={item}
variant='Backdrop'
style={{
width: "100%",
height: "100%",
}}
/>
</Animated.View>
{/* Dark Overlay */} {/* Dark Overlay */}
<View <View
@@ -689,6 +786,39 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Overview Section - for Episodes and Movies */}
{(item.Type === "Episode" || item.Type === "Movie") &&
item.Overview && (
<View
style={{
position: "absolute",
bottom: OVERVIEW_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<TouchableOpacity onPress={() => navigateToItem(item)}>
<Animated.Text
numberOfLines={OVERVIEW_MAX_LINES}
style={{
color: `rgba(255, 255, 255, ${TEXT_OPACITY * 0.85})`,
fontSize: OVERVIEW_FONT_SIZE,
fontWeight: "400",
textAlign: "center",
maxWidth: OVERVIEW_MAX_WIDTH,
textShadowColor: TEXT_SHADOW_COLOR,
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: TEXT_SHADOW_RADIUS,
}}
>
{item.Overview}
</Animated.Text>
</TouchableOpacity>
</View>
)}
{/* Controls Section */} {/* Controls Section */}
<View <View
style={{ style={{
@@ -719,7 +849,10 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
</View> </View>
{/* Mark as Played */} {/* Mark as Played */}
<PlayedStatus items={[item]} size='large' /> <MarkAsPlayedLargeButton
isPlayed={item.UserData?.Played ?? false}
onToggle={togglePlayedStatus}
/>
</View> </View>
</View> </View>
</View> </View>
@@ -731,7 +864,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
return ( return (
<View <View
style={{ style={{
height: CAROUSEL_HEIGHT, height: carouselHeight,
backgroundColor: "#000", backgroundColor: "#000",
overflow: "hidden", overflow: "hidden",
}} }}
@@ -749,7 +882,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
return ( return (
<View <View
style={{ style={{
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1 height: carouselHeight, // Fixed height instead of flex: 1
backgroundColor: "#000", backgroundColor: "#000",
overflow: "hidden", overflow: "hidden",
}} }}
@@ -758,7 +891,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
<Animated.View <Animated.View
style={[ style={[
{ {
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1 height: carouselHeight, // Fixed height instead of flex: 1
flexDirection: "row", flexDirection: "row",
width: screenWidth * items.length, width: screenWidth * items.length,
}, },

View File

@@ -0,0 +1,51 @@
import { Button, Host } from "@expo/ui/swift-ui";
import { Ionicons } from "@expo/vector-icons";
import { Platform, View } from "react-native";
import { RoundButton } from "../RoundButton";
interface MarkAsPlayedLargeButtonProps {
isPlayed: boolean;
onToggle: (isPlayed: boolean) => void;
}
export const MarkAsPlayedLargeButton: React.FC<
MarkAsPlayedLargeButtonProps
> = ({ isPlayed, onToggle }) => {
if (Platform.OS === "ios")
return (
<Host
style={{
flex: 0,
width: 50,
height: 50,
justifyContent: "center",
alignItems: "center",
flexDirection: "row",
}}
>
<Button onPress={() => onToggle(isPlayed)} variant='glass'>
<View>
<Ionicons
name='checkmark'
size={24}
color='white'
style={{
marginTop: 6,
marginLeft: 1,
}}
/>
</View>
</Button>
</Host>
);
return (
<View>
<RoundButton
size='large'
icon={isPlayed ? "checkmark" : "checkmark"}
onPress={() => onToggle(isPlayed)}
/>
</View>
);
};

View File

@@ -1,125 +0,0 @@
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import {
type PropsWithChildren,
type ReactNode,
useEffect,
useState,
} from "react";
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import DisabledSetting from "@/components/settings/DisabledSetting";
interface Props<T> {
data: T[];
disabled?: boolean;
placeholderText?: string;
keyExtractor: (item: T) => string;
titleExtractor: (item: T) => string | undefined;
title: string | ReactNode;
label: string;
onSelected: (...item: T[]) => void;
multiple?: boolean;
}
const Dropdown = <T,>({
data,
disabled,
placeholderText,
keyExtractor,
titleExtractor,
title,
label,
onSelected,
multiple = false,
...props
}: PropsWithChildren<Props<T> & ViewProps>) => {
const isTv = Platform.isTV;
const [selected, setSelected] = useState<T[]>();
useEffect(() => {
if (selected !== undefined) {
onSelected(...selected);
}
}, [selected, onSelected]);
if (isTv) return null;
return (
<DisabledSetting disabled={disabled === true} showText={false} {...props}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{typeof title === "string" ? (
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
{selected?.length !== undefined
? selected.map(titleExtractor).join(",")
: placeholderText}
</Text>
</TouchableOpacity>
</View>
) : (
title
)}
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side='bottom'
align='center'
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>{label}</DropdownMenu.Label>
{data.map((item, _idx) =>
multiple ? (
<DropdownMenu.CheckboxItem
value={
selected?.some((s) => keyExtractor(s) === keyExtractor(item))
? "on"
: "off"
}
key={keyExtractor(item)}
onValueChange={(
next: "on" | "off",
_previous: "on" | "off",
) => {
setSelected((p) => {
const prev = p || [];
if (next === "on") {
return [...prev, item];
}
return [
...prev.filter(
(p) => keyExtractor(p) !== keyExtractor(item),
),
];
});
}}
>
<DropdownMenu.ItemTitle>
{titleExtractor(item)}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
) : (
<DropdownMenu.Item
key={keyExtractor(item)}
onSelect={() => setSelected([item])}
>
<DropdownMenu.ItemTitle>
{titleExtractor(item)}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
),
)}
</DropdownMenu.Content>
</DropdownMenu.Root>
</DisabledSetting>
);
};
export default Dropdown;

View File

@@ -55,7 +55,7 @@ export const HeaderBackButton: React.FC<Props> = ({
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={() => router.back()} onPress={() => router.back()}
className=' bg-neutral-800/80 rounded-full p-2' className=' rounded-full p-2'
{...touchableOpacityProps} {...touchableOpacityProps}
> >
<Ionicons <Ionicons

View File

@@ -3,17 +3,12 @@ import React, { useImperativeHandle, useRef } from "react";
import { View, type ViewStyle } from "react-native"; import { View, type ViewStyle } from "react-native";
import { Text } from "./Text"; import { Text } from "./Text";
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
export interface HorizontalScrollRef { export interface HorizontalScrollRef {
scrollToIndex: (index: number, viewOffset: number) => void; scrollToIndex: (index: number, viewOffset: number) => void;
} }
interface HorizontalScrollProps<T> interface HorizontalScrollProps<T>
extends PartialExcept< extends Omit<FlashListProps<T>, "renderItem" | "estimatedItemSize" | "data"> {
Omit<FlashListProps<T>, "renderItem">,
"estimatedItemSize"
> {
data?: T[] | null; data?: T[] | null;
renderItem: (item: T, index: number) => React.ReactNode; renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor?: (item: T, index: number) => string; keyExtractor?: (item: T, index: number) => string;
@@ -44,7 +39,7 @@ export const HorizontalScroll = <T,>(
...restProps ...restProps
} = props; } = props;
const flashListRef = useRef<FlashList<T>>(null); const flashListRef = useRef<React.ComponentRef<typeof FlashList<T>>>(null);
useImperativeHandle(ref!, () => ({ useImperativeHandle(ref!, () => ({
scrollToIndex: (index: number, viewOffset: number) => { scrollToIndex: (index: number, viewOffset: number) => {
@@ -78,7 +73,6 @@ export const HorizontalScroll = <T,>(
extraData={extraData} extraData={extraData}
renderItem={renderFlashListItem} renderItem={renderFlashListItem}
horizontal horizontal
estimatedItemSize={200}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: 16, paddingHorizontal: 16,

View File

@@ -59,6 +59,7 @@ export function InfiniteHorizontalScroll({
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({ const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey, queryKey,
queryFn, queryFn,
staleTime: 60 * 1000, // 1 minute
getNextPageParam: (lastPage, pages) => { getNextPageParam: (lastPage, pages) => {
if ( if (
!lastPage?.Items || !lastPage?.Items ||
@@ -119,7 +120,6 @@ export function InfiniteHorizontalScroll({
renderItem={({ item, index }) => ( renderItem={({ item, index }) => (
<View className='mr-2'>{renderItem(item, index)}</View> <View className='mr-2'>{renderItem(item, index)}</View>
)} )}
estimatedItemSize={height}
horizontal horizontal
onEndReached={() => { onEndReached={() => {
if (hasNextPage) { if (hasNextPage) {

View File

@@ -16,7 +16,10 @@ export function Input(props: InputProps) {
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
return Platform.isTV ? ( return Platform.isTV ? (
<TouchableOpacity onFocus={() => inputRef?.current?.focus?.()}> <TouchableOpacity
onPress={() => inputRef?.current?.focus?.()}
activeOpacity={1}
>
<TextInput <TextInput
ref={inputRef} ref={inputRef}
className={` className={`

View File

@@ -1,14 +1,8 @@
import { useRouter, useSegments } from "expo-router"; import { useRouter, useSegments } from "expo-router";
import type React from "react"; import type React from "react";
import { type PropsWithChildren, useCallback, useMemo } from "react"; import { type PropsWithChildren } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import * as ContextMenu from "zeego/context-menu";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import {
hasPermission,
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person"; import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import type { import type {
@@ -38,90 +32,33 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
}) => { }) => {
const router = useRouter(); const router = useRouter();
const segments = useSegments(); const segments = useSegments();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const from = (segments as string[])[2] || "(home)"; const from = (segments as string[])[2] || "(home)";
const autoApprove = useMemo(() => {
return (
jellyseerrUser &&
hasPermission(Permission.AUTO_APPROVE, jellyseerrUser.permissions, {
type: "or",
})
);
}, [jellyseerrApi, jellyseerrUser]);
const request = useCallback(() => {
if (!result) return;
requestMedia(mediaTitle, {
mediaId: result.id,
mediaType,
});
}, [jellyseerrApi, result]);
if (from === "(home)" || from === "(search)" || from === "(libraries)") if (from === "(home)" || from === "(search)" || from === "(libraries)")
return ( return (
<ContextMenu.Root> <TouchableOpacity
<ContextMenu.Trigger> onPress={() => {
<TouchableOpacity if (!result) return;
onPress={() => {
if (!result) return;
router.push({ router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
// @ts-expect-error // @ts-expect-error
params: { params: {
...result, ...result,
mediaTitle, mediaTitle,
releaseYear, releaseYear,
canRequest: canRequest.toString(), canRequest: canRequest.toString(),
posterSrc, posterSrc,
mediaType, mediaType,
}, },
}); });
}} }}
{...props} {...props}
> >
{children} {children}
</TouchableOpacity> </TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content
avoidCollisions
alignOffset={0}
collisionPadding={0}
loop={false}
key={"content"}
>
<ContextMenu.Label key='label-1'>Actions</ContextMenu.Label>
{canRequest && mediaType === MediaType.MOVIE && (
<ContextMenu.Item
key='item-1'
onSelect={() => {
if (autoApprove) {
request();
}
}}
shouldDismissMenuOnSelect
>
<ContextMenu.ItemTitle key='item-1-title'>
Request
</ContextMenu.ItemTitle>
<ContextMenu.ItemIcon
ios={{
name: "arrow.down.to.line",
pointSize: 18,
weight: "semibold",
scale: "medium",
hierarchicalColor: {
dark: "purple",
light: "purple",
},
}}
androidIconName='download'
/>
</ContextMenu.Item>
)}
</ContextMenu.Content>
</ContextMenu.Root>
); );
return null;
}; };

View File

@@ -9,7 +9,11 @@ interface ActiveDownloadsProps extends ViewProps {}
export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) { export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
const { processes } = useDownload(); const { processes } = useDownload();
if (processes?.length === 0)
// Filter out any invalid processes before rendering
const validProcesses = processes?.filter((p) => p?.item?.Id) || [];
if (validProcesses.length === 0)
return ( return (
<View {...props} className='bg-neutral-900 p-4 rounded-2xl'> <View {...props} className='bg-neutral-900 p-4 rounded-2xl'>
<Text className='text-lg font-bold'> <Text className='text-lg font-bold'>
@@ -26,9 +30,9 @@ export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
<Text className='text-lg font-bold mb-2'> <Text className='text-lg font-bold mb-2'>
{t("home.downloads.active_downloads")} {t("home.downloads.active_downloads")}
</Text> </Text>
<View className='space-y-2'> <View className='gap-y-2'>
{processes?.map((p: JobStatus) => ( {validProcesses.map((p: JobStatus) => (
<DownloadCard key={p.item.Id} process={p} /> <DownloadCard key={p.id} process={p} />
))} ))}
</View> </View>
</View> </View>

View File

@@ -6,7 +6,6 @@ import { t } from "i18next";
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Platform,
TouchableOpacity, TouchableOpacity,
type TouchableOpacityProps, type TouchableOpacityProps,
View, View,
@@ -14,49 +13,36 @@ import {
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";
import { JobStatus } from "@/providers/Downloads/types"; import { JobStatus } from "@/providers/Downloads/types";
import { estimateDownloadSize } from "@/utils/download";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { formatTimeString } from "@/utils/time"; import { formatTimeString } from "@/utils/time";
import { Button } from "../Button";
const bytesToMB = (bytes: number) => { const bytesToMB = (bytes: number) => {
return bytes / 1024 / 1024; return bytes / 1024 / 1024;
}; };
const formatBytes = (bytes: number): string => {
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
};
interface DownloadCardProps extends TouchableOpacityProps { interface DownloadCardProps extends TouchableOpacityProps {
process: JobStatus; process: JobStatus;
} }
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => { export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { startDownload, pauseDownload, resumeDownload, removeProcess } = const { cancelDownload } = useDownload();
useDownload();
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const handlePause = async (id: string) => {
try {
await pauseDownload(id);
toast.success(t("home.downloads.toasts.download_paused"));
} catch (error) {
console.error("Error pausing download:", error);
toast.error(t("home.downloads.toasts.could_not_pause_download"));
}
};
const handleResume = async (id: string) => {
try {
await resumeDownload(id);
toast.success(t("home.downloads.toasts.download_resumed"));
} catch (error) {
console.error("Error resuming download:", error);
toast.error(t("home.downloads.toasts.could_not_resume_download"));
}
};
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
try { try {
await removeProcess(id); await cancelDownload(id);
toast.success(t("home.downloads.toasts.download_deleted")); // cancelDownload already shows a toast, so don't show another one
queryClient.invalidateQueries({ queryKey: ["downloads"] }); queryClient.invalidateQueries({ queryKey: ["downloads"] });
} catch (error) { } catch (error) {
console.error("Error deleting download:", error); console.error("Error deleting download:", error);
@@ -64,31 +50,75 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
} }
}; };
const eta = (p: JobStatus) => { const eta = useMemo(() => {
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null; if (!process?.estimatedTotalSizeBytes || !process?.bytesDownloaded) {
return null;
}
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0); const secondsRemaining = calculateSmoothedETA(
if (bytesRemaining <= 0) return null; process.id,
process.bytesDownloaded,
process.estimatedTotalSizeBytes,
);
const secondsRemaining = bytesRemaining / p.speed; if (!secondsRemaining || secondsRemaining <= 0) {
return null;
}
return formatTimeString(secondsRemaining, "s"); return formatTimeString(secondsRemaining, "s");
}; }, [process?.id, process?.bytesDownloaded, process?.estimatedTotalSizeBytes]);
const estimatedSize = useMemo(() => {
if (process?.estimatedTotalSizeBytes)
return process.estimatedTotalSizeBytes;
// Calculate from bitrate + duration (only if bitrate value is defined)
if (process?.maxBitrate?.value && process?.item?.RunTimeTicks) {
return estimateDownloadSize(
process.maxBitrate.value,
process.item.RunTimeTicks,
);
}
return undefined;
}, [
process?.maxBitrate?.value,
process?.item?.RunTimeTicks,
process?.estimatedTotalSizeBytes,
]);
const isTranscoding = process?.isTranscoding || false;
const downloadedAmount = useMemo(() => {
if (!process?.bytesDownloaded) return null;
return formatBytes(process.bytesDownloaded);
}, [process?.bytesDownloaded]);
const base64Image = useMemo(() => { const base64Image = useMemo(() => {
return storage.getString(process.item.Id!); try {
}, []); const itemId = process?.item?.Id;
if (!itemId) return undefined;
return storage.getString(itemId);
} catch {
return undefined;
}
}, [process?.item?.Id]);
// Sanitize progress to ensure it's within valid bounds // Sanitize progress to ensure it's within valid bounds
const sanitizedProgress = useMemo(() => { const sanitizedProgress = useMemo(() => {
if ( if (
typeof process.progress !== "number" || typeof process?.progress !== "number" ||
Number.isNaN(process.progress) Number.isNaN(process.progress)
) { ) {
return 0; return 0;
} }
return Math.max(0, Math.min(100, process.progress)); return Math.max(0, Math.min(100, process.progress));
}, [process.progress]); }, [process?.progress]);
// Return null after all hooks have been called
if (!process || !process.item || !process.item.Id) {
return null;
}
return ( return (
<TouchableOpacity <TouchableOpacity
@@ -98,9 +128,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
> >
{process.status === "downloading" && ( {process.status === "downloading" && (
<View <View
className={` className={`bg-purple-600 h-1 absolute bottom-0 left-0 ${isTranscoding ? "animate-pulse" : ""}`}
bg-purple-600 h-1 absolute bottom-0 left-0
`}
style={{ style={{
width: width:
sanitizedProgress > 0 sanitizedProgress > 0
@@ -111,26 +139,10 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
)} )}
{/* Action buttons in bottom right corner */} {/* Action buttons in bottom right corner */}
<View className='absolute bottom-2 right-2 flex flex-row items-center space-x-2 z-10'> <View className='absolute bottom-2 right-2 flex flex-row items-center z-10'>
{process.status === "downloading" && Platform.OS !== "ios" && (
<TouchableOpacity
onPress={() => handlePause(process.id)}
className='p-1'
>
<Ionicons name='pause' size={20} color='white' />
</TouchableOpacity>
)}
{process.status === "paused" && Platform.OS !== "ios" && (
<TouchableOpacity
onPress={() => handleResume(process.id)}
className='p-1'
>
<Ionicons name='play' size={20} color='white' />
</TouchableOpacity>
)}
<TouchableOpacity <TouchableOpacity
onPress={() => handleDelete(process.id)} onPress={() => handleDelete(process.id)}
className='p-1' className='p-2 bg-neutral-800 rounded-full'
> >
<Ionicons name='close' size={20} color='red' /> <Ionicons name='close' size={20} color='red' />
</TouchableOpacity> </TouchableOpacity>
@@ -152,47 +164,53 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
/> />
</View> </View>
)} )}
<View className='shrink mb-1 flex-1'> <View className='shrink mb-1 flex-1 pr-12'>
<Text className='text-xs opacity-50'>{process.item.Type}</Text> <Text className='text-xs opacity-50'>{process.item.Type}</Text>
<Text className='font-semibold shrink'>{process.item.Name}</Text> <Text className='font-semibold shrink'>{process.item.Name}</Text>
<Text className='text-xs opacity-50'> <Text className='text-xs opacity-50'>
{process.item.ProductionYear} {process.item.ProductionYear}
</Text> </Text>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
{isTranscoding && (
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
<Text className='text-xs text-purple-400'>Transcoding</Text>
</View>
)}
{/* Row 1: Progress + Downloaded/Total */}
<View className='flex flex-row items-center gap-x-2 mt-1.5'>
{sanitizedProgress === 0 ? ( {sanitizedProgress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} /> <ActivityIndicator size={"small"} color={"white"} />
) : ( ) : (
<Text className='text-xs'>{sanitizedProgress.toFixed(0)}%</Text> <Text className='text-xs font-semibold'>
)} {sanitizedProgress.toFixed(0)}%
{process.speed && process.speed > 0 && (
<Text className='text-xs'>
{bytesToMB(process.speed).toFixed(2)} MB/s
</Text> </Text>
)} )}
{eta(process) && ( {downloadedAmount && (
<Text className='text-xs'> <Text className='text-xs opacity-75'>
{t("home.downloads.eta", { eta: eta(process) })} {downloadedAmount}
{estimatedSize
? ` / ${isTranscoding ? "~" : ""}${formatBytes(estimatedSize)}`
: ""}
</Text> </Text>
)} )}
</View> </View>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'> {/* Row 2: Speed + ETA */}
<Text className='text-xs capitalize'>{process.status}</Text> <View className='flex flex-row items-center gap-x-2 mt-0.5'>
{process.speed && process.speed > 0 && (
<Text className='text-xs text-purple-400'>
{bytesToMB(process.speed).toFixed(2)} MB/s
</Text>
)}
{eta && (
<Text className='text-xs text-green-400'>
{t("home.downloads.eta", { eta: eta })}
</Text>
)}
</View> </View>
</View> </View>
</View> </View>
{process.status === "completed" && (
<View className='flex flex-row mt-4 space-x-4'>
<Button
onPress={() => {
startDownload(process);
}}
className='w-full'
>
Download now
</Button>
</View>
)}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
); );

View File

@@ -13,14 +13,13 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
items, items,
...props ...props
}) => { }) => {
const { getDownloadedItemSize, getDownloadedItems } = useDownload(); const { getDownloadedItemSize, downloadedItems } = useDownload();
const downloadedFiles = getDownloadedItems();
const [size, setSize] = useState<string | undefined>(); const [size, setSize] = useState<string | undefined>();
const itemIds = useMemo(() => items.map((i) => i.Id), [items]); const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
useEffect(() => { useEffect(() => {
if (!downloadedFiles) return; if (!downloadedItems) return;
let s = 0; let s = 0;
@@ -32,7 +31,7 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
} }
} }
setSize(s.bytesToReadable()); setSize(s.bytesToReadable());
}, [itemIds]); }, [itemIds, downloadedItems, getDownloadedItemSize]);
const sizeText = useMemo(() => { const sizeText = useMemo(() => {
if (!size) return "..."; if (!size) return "...";

View File

@@ -28,7 +28,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
*/ */
const handleDeleteFile = useCallback(() => { const handleDeleteFile = useCallback(() => {
if (item.Id) { if (item.Id) {
deleteFile(item.Id, "Episode"); deleteFile(item.Id);
successHapticFeedback(); successHapticFeedback();
} }
}, [deleteFile, item.Id]); }, [deleteFile, item.Id]);

View File

@@ -29,15 +29,15 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const base64Image = useMemo(() => { const base64Image = useMemo(() => {
return storage.getString(item?.Id!); return item?.Id ? storage.getString(item.Id) : undefined;
}, []); }, [item?.Id]);
/** /**
* Handles deleting the file with haptic feedback. * Handles deleting the file with haptic feedback.
*/ */
const handleDeleteFile = useCallback(() => { const handleDeleteFile = useCallback(() => {
if (item.Id) { if (item.Id) {
deleteFile(item.Id, item.Type); deleteFile(item.Id);
} }
}, [deleteFile, item.Id]); }, [deleteFile, item.Id]);

View File

@@ -19,7 +19,13 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
return storage.getString(items[0].SeriesId!); return storage.getString(items[0].SeriesId!);
}, []); }, []);
const deleteSeries = useCallback(async () => deleteItems(items), [items]); const deleteSeries = useCallback(
async () =>
deleteItems(
items.map((item) => item.Id).filter((id) => id !== undefined),
),
[items],
);
const showActionSheet = useCallback(() => { const showActionSheet = useCallback(() => {
const options = ["Delete", "Cancel"]; const options = ["Delete", "Cancel"];

View File

@@ -109,11 +109,22 @@ export const FilterSheet = <T,>({
// to implement efficient "load more" functionality // to implement efficient "load more" functionality
useEffect(() => { useEffect(() => {
if (!_data || _data.length === 0) return; if (!_data || _data.length === 0) return;
const tmp = new Set(data);
const newData = [...data];
for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) { for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) {
tmp.add(_data[i]); const item = _data[i];
// Check if this item already exists in our data array
// some dups happened with re-renders during testing
const exists = newData.some((existingItem) =>
isEqual(existingItem, item),
);
if (!exists) {
newData.push(item);
}
} }
setData(Array.from(tmp));
setData(newData);
}, [offset, _data]); }, [offset, _data]);
useEffect(() => { useEffect(() => {
@@ -233,7 +244,7 @@ export const FilterSheet = <T,>({
{data.length < (_data?.length || 0) && ( {data.length < (_data?.length || 0) && (
<Button <Button
onPress={() => { onPress={() => {
setOffset(offset + 100); setOffset(offset + LIMIT);
}} }}
> >
Load more Load more

506
components/home/Home.tsx Normal file
View File

@@ -0,0 +1,506 @@
import { Feather, Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import { type QueryFunction, useQuery } from "@tanstack/react-query";
import { useNavigation, useRouter, useSegments } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Platform,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
type InfiniteScrollingCollectionListSection = {
type: "InfiniteScrollingCollectionList";
title?: string;
queryKey: (string | undefined | null)[];
queryFn: QueryFunction<BaseItemDto[], any, number>;
orientation?: "horizontal" | "vertical";
pageSize?: number;
};
type MediaListSectionType = {
type: "MediaListSection";
queryKey: (string | undefined)[];
queryFn: QueryFunction<BaseItemDto>;
};
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
export const Home = () => {
const router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const insets = useSafeAreaInsets();
const [loading, setLoading] = useState(false);
const { settings, refreshStreamyfinPluginSettings } = useSettings();
const navigation = useNavigation();
const scrollRef = useRef<ScrollView>(null);
const { downloadedItems, cleanCacheDirectory } = useDownload();
const prevIsConnected = useRef<boolean | null>(false);
const {
isConnected,
serverConnected,
loading: retryLoading,
retryCheck,
} = useNetworkStatus();
const invalidateCache = useInvalidatePlaybackProgressCache();
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
if (isConnected && !prevIsConnected.current) {
invalidateCache();
}
prevIsConnected.current = isConnected;
}, [isConnected, invalidateCache]);
const hasDownloads = useMemo(() => {
if (Platform.isTV) return false;
return downloadedItems.length > 0;
}, [downloadedItems]);
useEffect(() => {
if (Platform.isTV) {
navigation.setOptions({
headerLeft: () => null,
});
return;
}
navigation.setOptions({
headerLeft: () => (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className='ml-1.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather
name='download'
color={hasDownloads ? Colors.primary : "white"}
size={24}
/>
</TouchableOpacity>
),
});
}, [navigation, router, hasDownloads]);
useEffect(() => {
cleanCacheDirectory().catch((_e) =>
console.error("Something went wrong cleaning cache directory"),
);
}, []);
const segments = useSegments();
useEffect(() => {
const unsubscribe = eventBus.on("scrollToTop", () => {
if ((segments as string[])[2] === "(home)")
scrollRef.current?.scrollTo({
y: Platform.isTV ? -152 : -100,
animated: true,
});
});
return () => {
unsubscribe();
};
}, [segments]);
const {
data,
isError: e1,
isLoading: l1,
} = useQuery({
queryKey: ["home", "userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
const userViews = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
[data, settings?.hiddenLibraries],
);
const collections = useMemo(() => {
const allow = ["movies", "tvshows"];
return (
userViews?.filter(
(c) => c.CollectionType && allow.includes(c.CollectionType),
) || []
);
}, [userViews]);
const refetch = async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
};
const createCollectionConfig = useCallback(
(
title: string,
queryKey: string[],
includeItemTypes: BaseItemKind[],
parentId: string | undefined,
pageSize: number = 10,
): InfiniteScrollingCollectionListSection => ({
title,
queryKey,
queryFn: async ({ pageParam = 0 }) => {
if (!api) return [];
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
const allData =
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 100, // Fetch a larger set for pagination
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes,
parentId,
})
).data || [];
// Simulate pagination by slicing
return allData.slice(pageParam, pageParam + pageSize);
},
type: "InfiniteScrollingCollectionList",
pageSize,
}),
[api, user?.Id],
);
const defaultSections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" || c.CollectionType === "movies"
? []
: ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey: string[] = [
"home",
`recentlyAddedIn${c.CollectionType}`,
user.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id,
10,
);
});
const ss: Section[] = [
{
title: t("home.continue_watching"),
queryKey: ["home", "resumeItems"],
queryFn: async ({ pageParam = 0 }) =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Genres"],
startIndex: pageParam,
limit: 10,
})
).data.Items || [],
type: "InfiniteScrollingCollectionList",
orientation: "horizontal",
pageSize: 10,
},
{
title: t("home.next_up"),
queryKey: ["home", "nextUp-all"],
queryFn: async ({ pageParam = 0 }) =>
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount", "Genres"],
startIndex: pageParam,
limit: 10,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: false,
})
).data.Items || [],
type: "InfiniteScrollingCollectionList",
orientation: "horizontal",
pageSize: 10,
},
...latestMediaViews,
{
title: t("home.suggested_movies"),
queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async ({ pageParam = 0 }) =>
(
await getSuggestionsApi(api).getSuggestions({
userId: user?.Id,
startIndex: pageParam,
limit: 10,
mediaType: ["Video"],
type: ["Movie"],
})
).data.Items || [],
type: "InfiniteScrollingCollectionList",
orientation: "vertical",
pageSize: 10,
},
];
return ss;
}, [api, user?.Id, collections, t, createCollectionConfig]);
const customSections = useMemo(() => {
if (!api || !user?.Id || !settings?.home?.sections) return [];
const ss: Section[] = [];
settings.home.sections.forEach((section, index) => {
const id = section.title || `section-${index}`;
const pageSize = 10;
ss.push({
title: t(`${id}`),
queryKey: ["home", "custom", String(index), section.title ?? null],
queryFn: async ({ pageParam = 0 }) => {
if (section.items) {
const response = await getItemsApi(api).getItems({
userId: user?.Id,
startIndex: pageParam,
limit: section.items?.limit || pageSize,
recursive: true,
includeItemTypes: section.items?.includeItemTypes,
sortBy: section.items?.sortBy,
sortOrder: section.items?.sortOrder,
filters: section.items?.filters,
parentId: section.items?.parentId,
});
return response.data.Items || [];
}
if (section.nextUp) {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount", "Genres"],
startIndex: pageParam,
limit: section.nextUp?.limit || pageSize,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: section.nextUp?.enableResumable,
enableRewatching: section.nextUp?.enableRewatching,
});
return response.data.Items || [];
}
if (section.latest) {
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
const allData =
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
includeItemTypes: section.latest?.includeItemTypes,
limit: section.latest?.limit || 100, // Fetch larger set
isPlayed: section.latest?.isPlayed,
groupItems: section.latest?.groupItems,
})
).data || [];
// Simulate pagination by slicing
return allData.slice(pageParam, pageParam + pageSize);
}
if (section.custom) {
const response = await api.get<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,
});
});
return ss;
}, [api, user?.Id, settings?.home?.sections, t]);
const sections = settings?.home?.sections ? customSections : defaultSections;
if (!isConnected || serverConnected !== true) {
let title = "";
let subtitle = "";
if (!isConnected) {
title = t("home.no_internet");
subtitle = t("home.no_internet_message");
} else if (serverConnected === null) {
title = t("home.checking_server_connection");
subtitle = t("home.checking_server_connection_message");
} else if (!serverConnected) {
title = t("home.server_unreachable");
subtitle = t("home.server_unreachable_message");
}
return (
<View className='flex flex-col items-center justify-center h-full -mt-6 px-8'>
<Text className='text-3xl font-bold mb-2'>{title}</Text>
<Text className='text-center opacity-70'>{subtitle}</Text>
<View className='mt-4'>
{!Platform.isTV && (
<Button
color='purple'
onPress={() => router.push("/(auth)/downloads")}
justify='center'
iconRight={
<Ionicons name='arrow-forward' size={20} color='white' />
}
>
{t("home.go_to_downloads")}
</Button>
)}
<Button
color='black'
onPress={retryCheck}
justify='center'
className='mt-2'
iconRight={
retryLoading ? null : (
<Ionicons name='refresh' size={20} color='white' />
)
}
>
{retryLoading ? (
<ActivityIndicator size='small' color='white' />
) : (
t("home.retry")
)}
</Button>
</View>
</View>
);
}
if (e1)
return (
<View className='flex flex-col items-center justify-center h-full -mt-6'>
<Text className='text-3xl font-bold mb-2'>{t("home.oops")}</Text>
<Text className='text-center opacity-70'>
{t("home.error_message")}
</Text>
</View>
);
if (l1)
return (
<View className='justify-center items-center h-full'>
<Loader />
</View>
);
return (
<ScrollView
ref={scrollRef}
nestedScrollEnabled
contentInsetAdjustmentBehavior='automatic'
onScroll={(event) => {
setScrollY(event.nativeEvent.contentOffset.y - 500);
}}
scrollEventThrottle={16}
refreshControl={
<RefreshControl
refreshing={loading}
onRefresh={refetch}
tintColor='white'
colors={["white"]}
/>
}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
>
<View
className='flex flex-col space-y-4'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
{sections.map((section, index) => {
if (section.type === "InfiniteScrollingCollectionList") {
return (
<InfiniteScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
pageSize={section.pageSize}
/>
);
}
if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
scrollY={scrollY}
enableLazyLoading={true}
/>
);
}
return null;
})}
</View>
</ScrollView>
);
};

View File

@@ -1,5 +1,4 @@
import { Feather, Ionicons } from "@expo/vector-icons"; import { Feather, Ionicons } from "@expo/vector-icons";
import type { Api } from "@jellyfin/sdk";
import type { import type {
BaseItemDto, BaseItemDto,
BaseItemDtoQueryResult, BaseItemDtoQueryResult,
@@ -20,15 +19,17 @@ import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Platform, Platform,
RefreshControl,
ScrollView,
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import Animated, {
useAnimatedRef,
useScrollViewOffset,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection"; import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
@@ -38,14 +39,15 @@ import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
import { AppleTVCarousel } from "../AppleTVCarousel"; import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel";
type ScrollingCollectionListSection = { type InfiniteScrollingCollectionListSection = {
type: "ScrollingCollectionList"; type: "InfiniteScrollingCollectionList";
title?: string; title?: string;
queryKey: (string | undefined | null)[]; queryKey: (string | undefined | null)[];
queryFn: QueryFunction<BaseItemDto[]>; queryFn: QueryFunction<BaseItemDto[], any, number>;
orientation?: "horizontal" | "vertical"; orientation?: "horizontal" | "vertical";
pageSize?: number;
}; };
type MediaListSectionType = { type MediaListSectionType = {
@@ -54,26 +56,21 @@ type MediaListSectionType = {
queryFn: QueryFunction<BaseItemDto>; queryFn: QueryFunction<BaseItemDto>;
}; };
type Section = ScrollingCollectionListSection | MediaListSectionType; type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
export const HomeIndex = () => { export const HomeWithCarousel = () => {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false);
const { settings, refreshStreamyfinPluginSettings } = useSettings();
const navigation = useNavigation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [_loading, setLoading] = useState(false);
const scrollViewRef = useRef<ScrollView>(null); const { settings, refreshStreamyfinPluginSettings } = useSettings();
const headerOverlayOffset = Platform.isTV ? 0 : 60;
const { getDownloadedItems, cleanCacheDirectory } = useDownload(); const navigation = useNavigation();
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollViewOffset(animatedScrollRef);
const { downloadedItems, cleanCacheDirectory } = useDownload();
const prevIsConnected = useRef<boolean | null>(false); const prevIsConnected = useRef<boolean | null>(false);
const { const {
isConnected, isConnected,
@@ -82,15 +79,20 @@ export const HomeIndex = () => {
retryCheck, retryCheck,
} = useNetworkStatus(); } = useNetworkStatus();
const invalidateCache = useInvalidatePlaybackProgressCache(); const invalidateCache = useInvalidatePlaybackProgressCache();
const [scrollY, setScrollY] = useState(0);
useEffect(() => { useEffect(() => {
// Only invalidate cache when transitioning from offline to online
if (isConnected && !prevIsConnected.current) { if (isConnected && !prevIsConnected.current) {
invalidateCache(); invalidateCache();
} }
// Update the ref to the current state for the next render
prevIsConnected.current = isConnected; prevIsConnected.current = isConnected;
}, [isConnected, invalidateCache]); }, [isConnected, invalidateCache]);
const hasDownloads = useMemo(() => {
if (Platform.isTV) return false;
return downloadedItems.length > 0;
}, [downloadedItems]);
useEffect(() => { useEffect(() => {
if (Platform.isTV) { if (Platform.isTV) {
navigation.setOptions({ navigation.setOptions({
@@ -98,24 +100,24 @@ export const HomeIndex = () => {
}); });
return; return;
} }
const hasDownloads = getDownloadedItems().length > 0;
navigation.setOptions({ navigation.setOptions({
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
router.push("/(auth)/downloads"); router.push("/(auth)/downloads");
}} }}
className='p-2' className='ml-1.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather <Feather
name='download' name='download'
color={hasDownloads ? Colors.primary : "white"} color={hasDownloads ? Colors.primary : "white"}
size={22} size={24}
/> />
</TouchableOpacity> </TouchableOpacity>
), ),
}); });
}, [navigation, router]); }, [navigation, router, hasDownloads]);
useEffect(() => { useEffect(() => {
cleanCacheDirectory().catch((_e) => cleanCacheDirectory().catch((_e) =>
@@ -127,7 +129,7 @@ export const HomeIndex = () => {
useEffect(() => { useEffect(() => {
const unsubscribe = eventBus.on("scrollToTop", () => { const unsubscribe = eventBus.on("scrollToTop", () => {
if ((segments as string[])[2] === "(home)") if ((segments as string[])[2] === "(home)")
scrollViewRef.current?.scrollTo({ animatedScrollRef.current?.scrollTo({
y: Platform.isTV ? -152 : -100, y: Platform.isTV ? -152 : -100,
animated: true, animated: true,
}); });
@@ -173,7 +175,7 @@ export const HomeIndex = () => {
); );
}, [userViews]); }, [userViews]);
const refetch = async () => { const _refetch = async () => {
setLoading(true); setLoading(true);
await refreshStreamyfinPluginSettings(); await refreshStreamyfinPluginSettings();
await invalidateCache(); await invalidateCache();
@@ -186,42 +188,48 @@ export const HomeIndex = () => {
queryKey: string[], queryKey: string[],
includeItemTypes: BaseItemKind[], includeItemTypes: BaseItemKind[],
parentId: string | undefined, parentId: string | undefined,
): ScrollingCollectionListSection => ({ pageSize: number = 10,
): InfiniteScrollingCollectionListSection => ({
title, title,
queryKey, queryKey,
queryFn: async () => { queryFn: async ({ pageParam = 0 }) => {
if (!api) return []; if (!api) return [];
return ( // getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
const allData =
( (
await getUserLibraryApi(api).getLatestMedia({ await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id, userId: user?.Id,
limit: 20, limit: 100, // Fetch a larger set for pagination
fields: ["PrimaryImageAspectRatio", "Path", "Genres"], fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
imageTypeLimit: 1, imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes, includeItemTypes,
parentId, parentId,
}) })
).data || [] ).data || [];
);
// Simulate pagination by slicing
return allData.slice(pageParam, pageParam + pageSize);
}, },
type: "ScrollingCollectionList", type: "InfiniteScrollingCollectionList",
pageSize,
}), }),
[api, user?.Id], [api, user?.Id],
); );
// Always call useMemo() at the top-level, using computed dependencies for both "default"/custom sections
const defaultSections = useMemo(() => { const defaultSections = useMemo(() => {
if (!api || !user?.Id) return []; if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => { const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] = const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"]; c.CollectionType === "tvshows" || c.CollectionType === "movies"
? []
: ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name }); const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey = [ const queryKey: string[] = [
"home", "home",
`recentlyAddedIn${c.CollectionType}`, `recentlyAddedIn${c.CollectionType}`,
user?.Id!, user.Id!,
c.Id!, c.Id!,
]; ];
return createCollectionConfig( return createCollectionConfig(
@@ -229,6 +237,7 @@ export const HomeIndex = () => {
queryKey, queryKey,
includeItemTypes, includeItemTypes,
c.Id, c.Id,
10,
); );
}); });
@@ -236,79 +245,56 @@ export const HomeIndex = () => {
{ {
title: t("home.continue_watching"), title: t("home.continue_watching"),
queryKey: ["home", "resumeItems"], queryKey: ["home", "resumeItems"],
queryFn: async () => queryFn: async ({ pageParam = 0 }) =>
( (
await getItemsApi(api).getResumeItems({ await getItemsApi(api).getResumeItems({
userId: user.Id, userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes: ["Movie", "Series", "Episode"], includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Genres"], fields: ["Genres"],
startIndex: pageParam,
limit: 10,
}) })
).data.Items || [], ).data.Items || [],
type: "ScrollingCollectionList", type: "InfiniteScrollingCollectionList",
orientation: "horizontal", orientation: "horizontal",
pageSize: 10,
}, },
{ {
title: t("home.next_up"), title: t("home.next_up"),
queryKey: ["home", "nextUp-all"], queryKey: ["home", "nextUp-all"],
queryFn: async () => queryFn: async ({ pageParam = 0 }) =>
( (
await getTvShowsApi(api).getNextUp({ await getTvShowsApi(api).getNextUp({
userId: user?.Id, userId: user?.Id,
fields: ["MediaSourceCount", "Genres"], fields: ["MediaSourceCount", "Genres"],
limit: 20, startIndex: pageParam,
limit: 10,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: false, enableResumable: false,
}) })
).data.Items || [], ).data.Items || [],
type: "ScrollingCollectionList", type: "InfiniteScrollingCollectionList",
orientation: "horizontal", orientation: "horizontal",
pageSize: 10,
}, },
...latestMediaViews, ...latestMediaViews,
// ...(mediaListCollections?.map(
// (ml) =>
// ({
// title: ml.Name,
// queryKey: ["home", "mediaList", ml.Id!],
// queryFn: async () => ml,
// type: "MediaListSection",
// orientation: "vertical",
// } as Section)
// ) || []),
{ {
title: t("home.suggested_movies"), title: t("home.suggested_movies"),
queryKey: ["home", "suggestedMovies", user?.Id], queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async () => queryFn: async ({ pageParam = 0 }) =>
( (
await getSuggestionsApi(api).getSuggestions({ await getSuggestionsApi(api).getSuggestions({
userId: user?.Id, userId: user?.Id,
startIndex: pageParam,
limit: 10, limit: 10,
mediaType: ["Video"], mediaType: ["Video"],
type: ["Movie"], type: ["Movie"],
}) })
).data.Items || [], ).data.Items || [],
type: "ScrollingCollectionList", type: "InfiniteScrollingCollectionList",
orientation: "vertical", orientation: "vertical",
}, pageSize: 10,
{
title: t("home.suggested_episodes"),
queryKey: ["home", "suggestedEpisodes", user?.Id],
queryFn: async () => {
try {
const suggestions = await getSuggestions(api, user.Id);
const nextUpPromises = suggestions.map((series) =>
getNextUp(api, user.Id, series.Id),
);
const nextUpResults = await Promise.all(nextUpPromises);
return nextUpResults.filter((item) => item !== null) || [];
} catch (error) {
console.error("Error fetching data:", error);
return [];
}
},
type: "ScrollingCollectionList",
orientation: "horizontal",
}, },
]; ];
return ss; return ss;
@@ -317,16 +303,18 @@ export const HomeIndex = () => {
const customSections = useMemo(() => { const customSections = useMemo(() => {
if (!api || !user?.Id || !settings?.home?.sections) return []; if (!api || !user?.Id || !settings?.home?.sections) return [];
const ss: Section[] = []; const ss: Section[] = [];
for (const [index, section] of settings.home.sections.entries()) { settings.home.sections.forEach((section, index) => {
const id = section.title || `section-${index}`; const id = section.title || `section-${index}`;
const pageSize = 10;
ss.push({ ss.push({
title: t(`${id}`), title: t(`${id}`),
queryKey: ["home", "custom", String(index), section.title ?? null], queryKey: ["home", "custom", String(index), section.title ?? null],
queryFn: async () => { queryFn: async ({ pageParam = 0 }) => {
if (section.items) { if (section.items) {
const response = await getItemsApi(api).getItems({ const response = await getItemsApi(api).getItems({
userId: user?.Id, userId: user?.Id,
limit: section.items?.limit || 25, startIndex: pageParam,
limit: section.items?.limit || pageSize,
recursive: true, recursive: true,
includeItemTypes: section.items?.includeItemTypes, includeItemTypes: section.items?.includeItemTypes,
sortBy: section.items?.sortBy, sortBy: section.items?.sortBy,
@@ -340,7 +328,8 @@ export const HomeIndex = () => {
const response = await getTvShowsApi(api).getNextUp({ const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id, userId: user?.Id,
fields: ["MediaSourceCount", "Genres"], fields: ["MediaSourceCount", "Genres"],
limit: section.nextUp?.limit || 25, startIndex: pageParam,
limit: section.nextUp?.limit || pageSize,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: section.nextUp?.enableResumable, enableResumable: section.nextUp?.enableResumable,
enableRewatching: section.nextUp?.enableRewatching, enableRewatching: section.nextUp?.enableRewatching,
@@ -348,20 +337,31 @@ export const HomeIndex = () => {
return response.data.Items || []; return response.data.Items || [];
} }
if (section.latest) { if (section.latest) {
const response = await getUserLibraryApi(api).getLatestMedia({ // getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
userId: user?.Id, const allData =
includeItemTypes: section.latest?.includeItemTypes, (
limit: section.latest?.limit || 25, await getUserLibraryApi(api).getLatestMedia({
isPlayed: section.latest?.isPlayed, userId: user?.Id,
groupItems: section.latest?.groupItems, includeItemTypes: section.latest?.includeItemTypes,
}); limit: section.latest?.limit || 100, // Fetch larger set
return response.data || []; isPlayed: section.latest?.isPlayed,
groupItems: section.latest?.groupItems,
})
).data || [];
// Simulate pagination by slicing
return allData.slice(pageParam, pageParam + pageSize);
} }
if (section.custom) { if (section.custom) {
const response = await api.get<BaseItemDtoQueryResult>( const response = await api.get<BaseItemDtoQueryResult>(
section.custom.endpoint, section.custom.endpoint,
{ {
params: { ...(section.custom.query || {}), userId: user?.Id }, params: {
...(section.custom.query || {}),
userId: user?.Id,
startIndex: pageParam,
limit: pageSize,
},
headers: section.custom.headers || {}, headers: section.custom.headers || {},
}, },
); );
@@ -369,12 +369,13 @@ export const HomeIndex = () => {
} }
return []; return [];
}, },
type: "ScrollingCollectionList", type: "InfiniteScrollingCollectionList",
orientation: section?.orientation || "vertical", orientation: section?.orientation || "vertical",
pageSize,
}); });
} });
return ss; return ss;
}, [api, user?.Id, settings?.home?.sections]); }, [api, user?.Id, settings?.home?.sections, t]);
const sections = settings?.home?.sections ? customSections : defaultSections; const sections = settings?.home?.sections ? customSections : defaultSections;
@@ -383,15 +384,12 @@ export const HomeIndex = () => {
let subtitle = ""; let subtitle = "";
if (!isConnected) { if (!isConnected) {
// No network connection
title = t("home.no_internet"); title = t("home.no_internet");
subtitle = t("home.no_internet_message"); subtitle = t("home.no_internet_message");
} else if (serverConnected === null) { } else if (serverConnected === null) {
// Network is up, but server is being checked
title = t("home.checking_server_connection"); title = t("home.checking_server_connection");
subtitle = t("home.checking_server_connection_message"); subtitle = t("home.checking_server_connection_message");
} else if (!serverConnected) { } else if (!serverConnected) {
// Network is up, but server is unreachable
title = t("home.server_unreachable"); title = t("home.server_unreachable");
subtitle = t("home.server_unreachable_message"); subtitle = t("home.server_unreachable_message");
} }
@@ -454,42 +452,41 @@ export const HomeIndex = () => {
); );
return ( return (
<ScrollView <Animated.ScrollView
scrollToOverflowEnabled={true} scrollToOverflowEnabled={true}
ref={scrollViewRef} ref={animatedScrollRef}
nestedScrollEnabled nestedScrollEnabled
contentInsetAdjustmentBehavior='never' contentInsetAdjustmentBehavior='never'
refreshControl={ scrollEventThrottle={16}
<RefreshControl bounces={false}
refreshing={loading} overScrollMode='never'
onRefresh={refetch} style={{ marginTop: -headerOverlayOffset }}
tintColor='white' // For iOS contentContainerStyle={{ paddingTop: headerOverlayOffset }}
colors={["white"]} // For Android onScroll={(event) => {
progressViewOffset={200} // This offsets the refresh indicator to appear over the carousel setScrollY(event.nativeEvent.contentOffset.y);
/> }}
}
style={{ marginTop: Platform.isTV ? 0 : -100 }}
contentContainerStyle={{ paddingTop: Platform.isTV ? 0 : 100 }}
> >
<AppleTVCarousel initialIndex={0} /> <AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
<View <View
style={{ style={{
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
paddingBottom: 16, paddingBottom: 16,
paddingTop: 0,
}} }}
> >
<View className='flex flex-col space-y-4'> <View className='flex flex-col space-y-4'>
{sections.map((section, index) => { {sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") { if (section.type === "InfiniteScrollingCollectionList") {
return ( return (
<ScrollingCollectionList <InfiniteScrollingCollectionList
key={index} key={index}
title={section.title} title={section.title}
queryKey={section.queryKey} queryKey={section.queryKey}
queryFn={section.queryFn} queryFn={section.queryFn}
orientation={section.orientation} orientation={section.orientation}
hideIfEmpty hideIfEmpty
pageSize={section.pageSize}
/> />
); );
} }
@@ -499,6 +496,8 @@ export const HomeIndex = () => {
key={index} key={index}
queryKey={section.queryKey} queryKey={section.queryKey}
queryFn={section.queryFn} queryFn={section.queryFn}
scrollY={scrollY}
enableLazyLoading={true}
/> />
); );
} }
@@ -507,33 +506,6 @@ export const HomeIndex = () => {
</View> </View>
</View> </View>
<View className='h-24' /> <View className='h-24' />
</ScrollView> </Animated.ScrollView>
); );
}; };
// Function to get suggestions
async function getSuggestions(api: Api, userId: string | undefined) {
if (!userId) return [];
const response = await getSuggestionsApi(api).getSuggestions({
userId,
limit: 10,
mediaType: ["Unknown"],
type: ["Series"],
});
return response.data.Items ?? [];
}
// Function to get the next up TV show for a series
async function getNextUp(
api: Api,
userId: string | undefined,
seriesId: string | undefined,
) {
if (!userId || !seriesId) return null;
const response = await getTvShowsApi(api).getNextUp({
userId,
seriesId,
limit: 1,
});
return response.data.Items?.[0] ?? null;
}

View File

@@ -13,6 +13,7 @@ import {
} from "react-native"; } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import { Colors } from "../../constants/Colors";
import ContinueWatchingPoster from "../ContinueWatchingPoster"; import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText"; import { ItemCardText } from "../ItemCardText";
@@ -35,7 +36,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
queryFn, queryFn,
queryKey, queryKey,
hideIfEmpty = false, hideIfEmpty = false,
pageSize = 20, pageSize = 10,
...props ...props
}) => { }) => {
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
@@ -52,9 +53,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
return allPages.length * pageSize; return allPages.length * pageSize;
}, },
initialPageParam: 0, initialPageParam: 0,
staleTime: 0, staleTime: 60 * 1000, // 1 minute
refetchOnMount: true, refetchOnMount: false,
refetchOnWindowFocus: true, refetchOnWindowFocus: false,
refetchOnReconnect: true, refetchOnReconnect: true,
}); });
@@ -179,8 +180,13 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
))} ))}
{/* Loading indicator for next page */} {/* Loading indicator for next page */}
{isFetchingNextPage && ( {isFetchingNextPage && (
<View className='justify-center items-center w-16'> <View
<ActivityIndicator size='small' color='#6366f1' /> style={{
marginLeft: 8,
marginTop: orientation === "horizontal" ? 37 : 70,
}}
>
<ActivityIndicator size='small' color={Colors.primary} />
</View> </View>
)} )}
</View> </View>

View File

@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import { ScrollView, View, type ViewProps } from "react-native"; import { ScrollView, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import { useInView } from "@/hooks/useInView";
import ContinueWatchingPoster from "../ContinueWatchingPoster"; import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText"; import { ItemCardText } from "../ItemCardText";
@@ -21,6 +22,8 @@ interface Props extends ViewProps {
queryFn: QueryFunction<BaseItemDto[]>; queryFn: QueryFunction<BaseItemDto[]>;
hideIfEmpty?: boolean; hideIfEmpty?: boolean;
isOffline?: boolean; isOffline?: boolean;
scrollY?: number; // For lazy loading
enableLazyLoading?: boolean; // Enable/disable lazy loading
} }
export const ScrollingCollectionList: React.FC<Props> = ({ export const ScrollingCollectionList: React.FC<Props> = ({
@@ -31,33 +34,44 @@ export const ScrollingCollectionList: React.FC<Props> = ({
queryKey, queryKey,
hideIfEmpty = false, hideIfEmpty = false,
isOffline = false, isOffline = false,
scrollY = 0,
enableLazyLoading = false,
...props ...props
}) => { }) => {
const { ref, isInView, onLayout } = useInView(scrollY, {
enabled: enableLazyLoading,
});
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: queryKey, queryKey: queryKey,
queryFn, queryFn,
staleTime: 0, staleTime: 60 * 1000, // 1 minute
refetchOnMount: true, refetchOnMount: false,
refetchOnWindowFocus: true, refetchOnWindowFocus: false,
refetchOnReconnect: true, refetchOnReconnect: true,
enabled: enableLazyLoading ? isInView : true,
}); });
const { t } = useTranslation(); const { t } = useTranslation();
if (hideIfEmpty === true && data?.length === 0) return null; // Show skeleton if loading OR if lazy loading is enabled and not in view yet
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
if (hideIfEmpty === true && data?.length === 0 && !shouldShowSkeleton)
return null;
if (disabled || !title) return null; if (disabled || !title) return null;
return ( return (
<View {...props}> <View ref={ref} onLayout={onLayout} {...props}>
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'> <Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
{title} {title}
</Text> </Text>
{isLoading === false && data?.length === 0 && ( {!shouldShowSkeleton && data?.length === 0 && (
<View className='px-4'> <View className='px-4'>
<Text className='text-neutral-500'>{t("home.no_items")}</Text> <Text className='text-neutral-500'>{t("home.no_items")}</Text>
</View> </View>
)} )}
{isLoading ? ( {shouldShowSkeleton ? (
<View <View
className={` className={`
flex flex-row gap-2 px-4 flex flex-row gap-2 px-4

View File

@@ -23,7 +23,6 @@ const CastSlide: React.FC<
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
data={details?.credits.cast} data={details?.credits.cast}
ItemSeparatorComponent={() => <View className='w-2' />} ItemSeparatorComponent={() => <View className='w-2' />}
estimatedItemSize={15}
keyExtractor={(item) => item?.id?.toString()} keyExtractor={(item) => item?.id?.toString()}
contentContainerStyle={{ paddingHorizontal: 16 }} contentContainerStyle={{ paddingHorizontal: 16 }}
renderItem={({ item }) => ( renderItem={({ item }) => (

View File

@@ -143,7 +143,6 @@ const ParallaxSlideShow = <T,>({
renderItem={({ item, index }) => renderItem(item, index)} renderItem={({ item, index }) => renderItem(item, index)}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
numColumns={3} numColumns={3}
estimatedItemSize={214}
ItemSeparatorComponent={() => <View className='h-2 w-2' />} ItemSeparatorComponent={() => <View className='h-2 w-2' />}
/> />
</View> </View>

View File

@@ -10,8 +10,8 @@ import { forwardRef, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import Dropdown from "@/components/common/Dropdown";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import type { import type {
QualityProfile, QualityProfile,
@@ -48,8 +48,22 @@ const RequestModal = forwardRef<
userId: jellyseerrUser?.id, userId: jellyseerrUser?.id,
}); });
const [qualityProfileOpen, setQualityProfileOpen] = useState(false);
const [rootFolderOpen, setRootFolderOpen] = useState(false);
const [tagsOpen, setTagsOpen] = useState(false);
const [usersOpen, setUsersOpen] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
// Reset all dropdown states when modal closes
const handleDismiss = useCallback(() => {
setQualityProfileOpen(false);
setRootFolderOpen(false);
setTagsOpen(false);
setUsersOpen(false);
onDismiss?.();
}, [onDismiss]);
const { data: serviceSettings } = useQuery({ const { data: serviceSettings } = useQuery({
queryKey: ["jellyseerr", "request", type, "service"], queryKey: ["jellyseerr", "request", type, "service"],
queryFn: async () => queryFn: async () =>
@@ -130,14 +144,120 @@ const RequestModal = forwardRef<
}, [defaultServiceDetails]); }, [defaultServiceDetails]);
const seasonTitle = useMemo(() => { const seasonTitle = useMemo(() => {
if (requestBody?.seasons && requestBody?.seasons?.length > 1) { if (!requestBody?.seasons || requestBody.seasons.length === 0) {
return undefined;
}
if (requestBody.seasons.length > 1) {
return t("jellyseerr.season_all"); return t("jellyseerr.season_all");
} }
return t("jellyseerr.season_number", { return t("jellyseerr.season_number", {
season_number: requestBody?.seasons, season_number: requestBody.seasons[0],
}); });
}, [requestBody?.seasons]); }, [requestBody?.seasons]);
const pathTitleExtractor = (item: RootFolder) =>
`${item.path} (${item.freeSpace.bytesToReadable()})`;
const qualityProfileOptions = useMemo(
() => [
{
options:
defaultServiceDetails?.profiles.map((profile) => ({
type: "radio" as const,
label: profile.name,
value: profile.id.toString(),
selected:
(requestOverrides.profileId || defaultProfile?.id) ===
profile.id,
onPress: () =>
setRequestOverrides((prev) => ({
...prev,
profileId: profile.id,
})),
})) || [],
},
],
[
defaultServiceDetails?.profiles,
defaultProfile,
requestOverrides.profileId,
],
);
const rootFolderOptions = useMemo(
() => [
{
options:
defaultServiceDetails?.rootFolders.map((folder) => ({
type: "radio" as const,
label: pathTitleExtractor(folder),
value: folder.id.toString(),
selected:
(requestOverrides.rootFolder || defaultFolder?.path) ===
folder.path,
onPress: () =>
setRequestOverrides((prev) => ({
...prev,
rootFolder: folder.path,
})),
})) || [],
},
],
[
defaultServiceDetails?.rootFolders,
defaultFolder,
requestOverrides.rootFolder,
],
);
const tagsOptions = useMemo(
() => [
{
options:
defaultServiceDetails?.tags.map((tag) => ({
type: "toggle" as const,
label: tag.label,
value:
requestOverrides.tags?.includes(tag.id) ||
defaultTags.some((dt) => dt.id === tag.id),
onToggle: () =>
setRequestOverrides((prev) => {
const currentTags = prev.tags || defaultTags.map((t) => t.id);
const hasTag = currentTags.includes(tag.id);
return {
...prev,
tags: hasTag
? currentTags.filter((id) => id !== tag.id)
: [...currentTags, tag.id],
};
}),
})) || [],
},
],
[defaultServiceDetails?.tags, defaultTags, requestOverrides.tags],
);
const usersOptions = useMemo(
() => [
{
options:
users?.map((user) => ({
type: "radio" as const,
label: user.displayName,
value: user.id.toString(),
selected:
(requestOverrides.userId || jellyseerrUser?.id) === user.id,
onPress: () =>
setRequestOverrides((prev) => ({
...prev,
userId: user.id,
})),
})) || [],
},
],
[users, jellyseerrUser, requestOverrides.userId],
);
const request = useCallback(() => { const request = useCallback(() => {
const body = { const body = {
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k, is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
@@ -163,15 +283,12 @@ const RequestModal = forwardRef<
defaultTags, defaultTags,
]); ]);
const pathTitleExtractor = (item: RootFolder) =>
`${item.path} (${item.freeSpace.bytesToReadable()})`;
return ( return (
<BottomSheetModal <BottomSheetModal
ref={ref} ref={ref}
enableDynamicSizing enableDynamicSizing
enableDismissOnClose enableDismissOnClose
onDismiss={onDismiss} onDismiss={handleDismiss}
handleIndicatorStyle={{ handleIndicatorStyle={{
backgroundColor: "white", backgroundColor: "white",
}} }}
@@ -185,6 +302,7 @@ const RequestModal = forwardRef<
appearsOnIndex={0} appearsOnIndex={0}
/> />
)} )}
stackBehavior='push'
> >
<BottomSheetView> <BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'> <View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
@@ -199,70 +317,112 @@ const RequestModal = forwardRef<
<View className='flex flex-col space-y-2'> <View className='flex flex-col space-y-2'>
{defaultService && defaultServiceDetails && users && ( {defaultService && defaultServiceDetails && users && (
<> <>
<Dropdown <View className='flex flex-col'>
data={defaultServiceDetails.profiles} <Text className='opacity-50 mb-1 text-xs'>
titleExtractor={(item) => item.name} {t("jellyseerr.quality_profile")}
placeholderText={ </Text>
requestOverrides.profileName || defaultProfile.name <PlatformDropdown
} groups={qualityProfileOptions}
keyExtractor={(item) => item.id.toString()} trigger={
label={t("jellyseerr.quality_profile")} <View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
onSelected={(item) => <Text numberOfLines={1}>
item && {defaultServiceDetails.profiles.find(
setRequestOverrides((prev) => ({ (p) =>
...prev, p.id ===
profileId: item?.id, (requestOverrides.profileId ||
})) defaultProfile?.id),
} )?.name || defaultProfile?.name}
title={t("jellyseerr.quality_profile")} </Text>
/> </View>
<Dropdown }
data={defaultServiceDetails.rootFolders} title={t("jellyseerr.quality_profile")}
titleExtractor={pathTitleExtractor} open={qualityProfileOpen}
placeholderText={ onOpenChange={setQualityProfileOpen}
defaultFolder ? pathTitleExtractor(defaultFolder) : "" />
} </View>
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.root_folder")} <View className='flex flex-col'>
onSelected={(item) => <Text className='opacity-50 mb-1 text-xs'>
item && {t("jellyseerr.root_folder")}
setRequestOverrides((prev) => ({ </Text>
...prev, <PlatformDropdown
rootFolder: item.path, groups={rootFolderOptions}
})) trigger={
} <View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
title={t("jellyseerr.root_folder")} <Text numberOfLines={1}>
/> {defaultServiceDetails.rootFolders.find(
<Dropdown (f) =>
multiple f.path ===
data={defaultServiceDetails.tags} (requestOverrides.rootFolder ||
titleExtractor={(item) => item.label} defaultFolder?.path),
placeholderText={defaultTags.map((t) => t.label).join(",")} )
keyExtractor={(item) => item.id.toString()} ? pathTitleExtractor(
label={t("jellyseerr.tags")} defaultServiceDetails.rootFolders.find(
onSelected={(...selected) => (f) =>
setRequestOverrides((prev) => ({ f.path ===
...prev, (requestOverrides.rootFolder ||
tags: selected.map((i) => i.id), defaultFolder?.path),
})) )!,
} )
title={t("jellyseerr.tags")} : pathTitleExtractor(defaultFolder!)}
/> </Text>
<Dropdown </View>
data={users} }
titleExtractor={(item) => item.displayName} title={t("jellyseerr.root_folder")}
placeholderText={jellyseerrUser!.displayName} open={rootFolderOpen}
keyExtractor={(item) => item.id.toString() || ""} onOpenChange={setRootFolderOpen}
label={t("jellyseerr.request_as")} />
onSelected={(item) => </View>
item &&
setRequestOverrides((prev) => ({ <View className='flex flex-col'>
...prev, <Text className='opacity-50 mb-1 text-xs'>
userId: item?.id, {t("jellyseerr.tags")}
})) </Text>
} <PlatformDropdown
title={t("jellyseerr.request_as")} groups={tagsOptions}
/> trigger={
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text numberOfLines={1}>
{requestOverrides.tags
? defaultServiceDetails.tags
.filter((t) =>
requestOverrides.tags!.includes(t.id),
)
.map((t) => t.label)
.join(", ") ||
defaultTags.map((t) => t.label).join(", ")
: defaultTags.map((t) => t.label).join(", ")}
</Text>
</View>
}
title={t("jellyseerr.tags")}
open={tagsOpen}
onOpenChange={setTagsOpen}
/>
</View>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.request_as")}
</Text>
<PlatformDropdown
groups={usersOptions}
trigger={
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text numberOfLines={1}>
{users.find(
(u) =>
u.id ===
(requestOverrides.userId || jellyseerrUser?.id),
)?.displayName || jellyseerrUser!.displayName}
</Text>
</View>
}
title={t("jellyseerr.request_as")}
open={usersOpen}
onOpenChange={setUsersOpen}
/>
</View>
</> </>
)} )}
</View> </View>

View File

@@ -1,16 +1,15 @@
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import type { ContentStyle } from "@shopify/flash-list/src/FlashListProps";
import { t } from "i18next"; import { t } from "i18next";
import type React from "react"; import type React from "react";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { View, type ViewProps } from "react-native"; import { View, type ViewProps, type ViewStyle } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider"; import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
export interface SlideProps { export interface SlideProps {
slide: DiscoverSlider; slide: DiscoverSlider;
contentContainerStyle?: ContentStyle; contentContainerStyle?: ViewStyle;
} }
interface Props<T> extends SlideProps { interface Props<T> extends SlideProps {
@@ -45,7 +44,6 @@ const Slide = <T,>({
}} }}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
estimatedItemSize={250}
data={data} data={data}
onEndReachedThreshold={1} onEndReachedThreshold={1}
onEndReached={onEndReached} onEndReached={onEndReached}

View File

@@ -11,6 +11,7 @@ import {
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback } from "react"; import { useCallback } from "react";
import { View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { useInView } from "@/hooks/useInView";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { InfiniteHorizontalScroll } from "../common/InfiniteHorizontalScroll"; import { InfiniteHorizontalScroll } from "../common/InfiniteHorizontalScroll";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
@@ -21,20 +22,29 @@ import MoviePoster from "../posters/MoviePoster";
interface Props extends ViewProps { interface Props extends ViewProps {
queryKey: QueryKey; queryKey: QueryKey;
queryFn: QueryFunction<BaseItemDto>; queryFn: QueryFunction<BaseItemDto>;
scrollY?: number; // For lazy loading
enableLazyLoading?: boolean; // Enable/disable lazy loading
} }
export const MediaListSection: React.FC<Props> = ({ export const MediaListSection: React.FC<Props> = ({
queryFn, queryFn,
queryKey, queryKey,
scrollY = 0,
enableLazyLoading = false,
...props ...props
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { ref, isInView, onLayout } = useInView(scrollY, {
enabled: enableLazyLoading,
});
const { data: collection } = useQuery({ const { data: collection } = useQuery({
queryKey, queryKey,
queryFn, queryFn,
staleTime: 0, staleTime: 60 * 1000, // 1 minute
enabled: enableLazyLoading ? isInView : true,
}); });
const fetchItems = useCallback( const fetchItems = useCallback(
@@ -60,7 +70,7 @@ export const MediaListSection: React.FC<Props> = ({
if (!collection) return null; if (!collection) return null;
return ( return (
<View {...props}> <View ref={ref} onLayout={onLayout} {...props}>
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'> <Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
{collection.Name} {collection.Name}
</Text> </Text>

View File

@@ -0,0 +1,115 @@
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
import { Platform, View } from "react-native";
import { FilterButton } from "@/components/filters/FilterButton";
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
interface DiscoverFiltersProps {
searchFilterId: string;
orderFilterId: string;
jellyseerrOrderBy: JellyseerrSearchSort;
setJellyseerrOrderBy: (value: JellyseerrSearchSort) => void;
jellyseerrSortOrder: "asc" | "desc";
setJellyseerrSortOrder: (value: "asc" | "desc") => void;
t: (key: string) => string;
}
const sortOptions = Object.keys(JellyseerrSearchSort).filter((v) =>
Number.isNaN(Number(v)),
);
const orderOptions = ["asc", "desc"] as const;
export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
searchFilterId,
orderFilterId,
jellyseerrOrderBy,
setJellyseerrOrderBy,
jellyseerrSortOrder,
setJellyseerrSortOrder,
t,
}) => {
if (Platform.OS === "ios") {
return (
<Host
style={{
justifyContent: "center",
alignItems: "center",
overflow: "visible",
height: 40,
width: 50,
marginLeft: "auto",
}}
>
<ContextMenu>
<ContextMenu.Trigger>
<Button
variant='glass'
modifiers={[]}
systemImage='line.3.horizontal.decrease.circle'
></Button>
</ContextMenu.Trigger>
<ContextMenu.Items>
<Picker
label={t("library.filters.sort_by")}
options={sortOptions.map((item) =>
t(`home.settings.plugins.jellyseerr.order_by.${item}`),
)}
variant='menu'
selectedIndex={sortOptions.indexOf(
jellyseerrOrderBy as unknown as string,
)}
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
setJellyseerrOrderBy(
sortOptions[index] as unknown as JellyseerrSearchSort,
);
}}
/>
<Picker
label={t("library.filters.sort_order")}
options={orderOptions.map((item) => t(`library.filters.${item}`))}
variant='menu'
selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)}
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
setJellyseerrSortOrder(orderOptions[index]);
}}
/>
</ContextMenu.Items>
</ContextMenu>
</Host>
);
}
// Android UI
return (
<View className='flex flex-row justify-end items-center space-x-1'>
<FilterButton
id={searchFilterId}
queryKey='jellyseerr_search'
queryFn={async () =>
Object.keys(JellyseerrSearchSort).filter((v) =>
Number.isNaN(Number(v)),
)
}
set={(value) => setJellyseerrOrderBy(value[0])}
values={[jellyseerrOrderBy]}
title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
}
disableSearch={true}
/>
<FilterButton
id={orderFilterId}
queryKey='jellysearr_search'
queryFn={async () => ["asc", "desc"]}
set={(value) => setJellyseerrSortOrder(value[0])}
values={[jellyseerrSortOrder]}
title={t("library.filters.sort_order")}
renderItemLabel={(item) => t(`library.filters.${item}`)}
disableSearch={true}
/>
</View>
);
};

View File

@@ -34,7 +34,6 @@ export const SearchItemWrapper = <T,>({
}} }}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
keyExtractor={(_, index) => index.toString()} keyExtractor={(_, index) => index.toString()}
estimatedItemSize={250}
data={items} data={items}
onEndReachedThreshold={1} onEndReachedThreshold={1}
onEndReached={onEndReached} onEndReached={onEndReached}

View File

@@ -0,0 +1,76 @@
import { Button, Host } from "@expo/ui/swift-ui";
import { Platform, TouchableOpacity, View } from "react-native";
import { Tag } from "@/components/GenreTags";
type SearchType = "Library" | "Discover";
interface SearchTabButtonsProps {
searchType: SearchType;
setSearchType: (type: SearchType) => void;
t: (key: string) => string;
}
export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
searchType,
setSearchType,
t,
}) => {
if (Platform.OS === "ios") {
return (
<>
<Host
style={{
height: 40,
width: 80,
flexDirection: "row",
gap: 10,
justifyContent: "space-between",
}}
>
<Button
variant={searchType === "Library" ? "glassProminent" : "glass"}
onPress={() => setSearchType("Library")}
>
{t("search.library")}
</Button>
</Host>
<Host
style={{
height: 40,
width: 100,
flexDirection: "row",
gap: 10,
justifyContent: "space-between",
}}
>
<Button
variant={searchType === "Discover" ? "glassProminent" : "glass"}
onPress={() => setSearchType("Discover")}
>
{t("search.discover")}
</Button>
</Host>
</>
);
}
// Android UI
return (
<View className='flex flex-row gap-1 mr-1'>
<TouchableOpacity onPress={() => setSearchType("Library")}>
<Tag
text={t("search.library")}
textClass='p-1'
className={searchType === "Library" ? "bg-purple-600" : undefined}
/>
</TouchableOpacity>
<TouchableOpacity onPress={() => setSearchType("Discover")}>
<Tag
text={t("search.discover")}
textClass='p-1'
className={searchType === "Discover" ? "bg-purple-600" : undefined}
/>
</TouchableOpacity>
</View>
);
};

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