Compare commits

...

95 Commits

Author SHA1 Message Date
Fredrik Burmester
24d04c1003 fix: BOOT_COMPLETED runtime protection 2026-01-05 21:32:41 +01:00
Fredrik Burmester
7da52441ab chore: version 2026-01-05 21:32:41 +01:00
Fredrik Burmester
70268e6120 fix: use vlc build with 16kb page size 2026-01-05 21:32:41 +01:00
Fredrik Burmester
96fbb9fe1f fix: target version 35 2026-01-05 21:32:40 +01:00
Fredrik Burmester
3b104b91fc chore: version 2026-01-05 21:32:40 +01:00
Fredrik Burmester
e4134d6f9a fix: item content header button colors 2026-01-05 21:32:40 +01:00
Fredrik Burmester
5b2e7b3883 fix: lower build taget to solve 16kb page requirement
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 / 🔍 Lint & Test (check) (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 (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
2026-01-05 08:48:57 +01:00
Elias
1fde3c82a3 feat: add Dolby Vision badge (#1177) 2026-01-05 08:48:40 +01:00
Fredrik Burmester
054fb05651 feat: delete playlist
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
2026-01-04 23:01:03 +01:00
Fredrik Burmester
a2058a8009 fix: music downloading not playing + queue drag and drop
Some checks failed
🔒 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
🏗️ 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
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (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 (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2026-01-04 16:28:35 +01:00
Fredrik Burmester
d22827bc9b feat: add toggle to hide streamystats watchlist tab/functions 2026-01-04 14:31:55 +01:00
Fredrik Burmester
4121502bfe fix: remove song from playlist 2026-01-04 13:44:53 +01:00
Fredrik Burmester
b6e59aab01 chore: version 2026-01-04 12:54:28 +01:00
Fredrik Burmester
ab3465aec5 feat: cache and download music 2026-01-04 12:50:41 +01:00
Fredrik Burmester
b1da9f8777 feat: add actions to song rows 2026-01-04 00:09:21 +01:00
Fredrik Burmester
36d24176ae fix: music rows full width 2026-01-03 23:55:52 +01:00
Fredrik Burmester
bfdc2c053b fix: improve gestures 2026-01-03 23:51:27 +01:00
Fredrik Burmester
245c9597c4 feat: music bar geastures 2026-01-03 23:44:15 +01:00
Fredrik Burmester
966a8e8f24 feat: music info badges in music modal 2026-01-03 23:23:40 +01:00
Fredrik Burmester
f941c88457 fix: device profile for music for rntp ios/android 2026-01-03 23:08:46 +01:00
lance chant
bd4e5bb70a fix: jellyseer categories (#1233)
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
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2026-01-03 19:58:17 +01:00
Fredrik Burmester
9334263414 chore: update submodule 2026-01-03 19:49:44 +01:00
Fredrik Burmester
4ae3c44d02 fix: bug in playback speed 2026-01-03 19:49:39 +01:00
Fredrik Burmester
4fb3fb195c fix: ignore android build files 2026-01-03 19:49:18 +01:00
Fredrik Burmester
e8089cfd20 fix: key id fail 2026-01-03 19:39:04 +01:00
Fredrik Burmester
039bf9729a feat: hide volume or/and brightness in controls setting 2026-01-03 19:34:45 +01:00
Fredrik Burmester
3ff7c47b7f feat: playback speed options 2026-01-03 19:14:20 +01:00
Fredrik Burmester
1d8d92175a fix: correct control buttions top right for each player
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
2026-01-03 18:22:38 +01:00
Fredrik Burmester
60b0040681 docs: add claude code guidance file 2026-01-03 17:58:33 +01:00
Fredrik Burmester
9cd55cf544 feat: setting to hide the item page header session button 2026-01-03 17:56:31 +01:00
Emre Sanden
090e0cb170 feat: add button to toggle video orientation in player (#743)
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2026-01-03 17:52:45 +01:00
Fredrik Burmester
85d707ef45 fix: improve music page design, mostly headers 2026-01-03 17:45:28 +01:00
Fredrik Burmester
792eef20a9 fix(player): hide pip button when vlc player selected 2026-01-03 17:40:34 +01:00
Fredrik Burmester
6487c8b5a1 fix: show loading indicator when pressing song in music 2026-01-03 16:24:23 +01:00
Leo THIVILLON
baa96d222f fix(readme): Add Obtainium button (#1293)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2026-01-03 16:15:18 +01:00
Fredrik Burmester
74d86b5d12 feat: KSPlayer as an option for iOS + other improvements (#1266) 2026-01-03 13:05:50 +01:00
Cristea Florian Victor
d1795c9df8 fix(player): Fix skip credits seeking past video end causing pause (#1277)
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-12-21 10:09:57 +01:00
renovate[bot]
149609f46e chore(deps): Update actions/setup-node action to v6.1.0 (#1262)
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-12-09 22:54:57 +01:00
Simon Eklundh
cf269ba83e chore: Add version 0.47.1 to issue report template (#1251)
Some checks failed
🏷️🔀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
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (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
This update keeps the issue report template current by adding version 0.47.1 and removing outdated entries. It reduces confusion for users creating reports and improves triage accuracy for maintainers
2025-12-01 16:27:33 +01:00
Gauvain
24d5fdefdf ci: fix CodeQL checkout (#1170)
Some checks failed
🛡️ 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
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (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-26 09:56:23 +01:00
lostb1t
c05cef295e refactor: pass down items with sources to children (#1218)
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-17 21:15:59 +01:00
Fredrik Burmester
3c57829360 feat: prefer downloaded file (#1217)
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 / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (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
🌐 Translation Sync / sync-translations (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-16 21:47:51 +01:00
Fredrik Burmester
06349a4319 chore: remove unused files 2025-11-16 21:16:07 +01:00
Fredrik Burmester
55ac9ae9d4 chore: lint
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-16 16:41:15 +01:00
Fredrik Burmester
c8bdcc4df0 feat: add snap scroll to horizontal lists 2025-11-16 16:40:42 +01:00
lostb1t
e7013edd84 refactor: Move media sources preload higher up the tree (#1216) 2025-11-16 14:59:09 +01:00
Fredrik Burmester
991b45de06 fix: season episode scroll list snapping and scrolling 2025-11-16 14:36:31 +01:00
Fredrik Burmester
97fe899cb0 feat: approve jellyserr requests (#1214) 2025-11-16 14:24:20 +01:00
lostb1t
86d7642dca fix: show tech details when avaiable (#1213) 2025-11-16 14:22:05 +01:00
Fredrik Burmester
631a5ef94e chore: update + correct deps 2025-11-16 13:36:22 +01:00
renovate[bot]
8b8b928837 chore(deps): Pin dependencies (#1209)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-16 13:32:26 +01:00
lostb1t
56a3c62ed2 feat: move source and track selection to seperate sheet (#1176)
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2025-11-16 13:31:20 +01:00
Fredrik Burmester
82683407da chore: version 2025-11-16 11:25:17 +01:00
Fredrik Burmester
7b146e30bd fix: disable gestures from top and bottom of screen because of interference with notification shade pull down (#1206)
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-16 10:49:54 +01:00
Fredrik Burmester
5f48bec0f2 feat: vlc apple integration - pause on other media play + controls (#1211) 2025-11-16 10:49:35 +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
392 changed files with 104218 additions and 7185 deletions

View File

@@ -77,13 +77,8 @@ body:
label: Streamyfin Version
description: What version of Streamyfin are you running?
options:
- 0.47.1
- 0.30.2
- 0.29.0
- 0.28.0
- 0.27.0
- 0.26.1
- 0.26.0
- 0.25.0
- older
- TestFlight/Development build
validations:
@@ -116,4 +111,4 @@ body:
id: additional-info
attributes:
label: Additional information
description: Any additional context that might help us understand and reproduce the issue.
description: Any additional context that might help us understand and reproduce the issue.

View File

@@ -156,7 +156,7 @@ jobs:
build-ios-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
runs-on: macos-15
runs-on: macos-26
name: 🍎 Build iOS IPA (Phone)
permissions:
contents: read
@@ -191,6 +191,11 @@ jobs:
- name: 🛠️ Generate project files
run: bun run prebuild
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
with:
xcode-version: "26.0.1"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@main
with:
@@ -219,7 +224,7 @@ jobs:
# Disabled for now - uncomment when ready to build iOS TV
# build-ios-tv:
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
# runs-on: macos-15
# runs-on: macos-26
# name: 🍎 Build iOS IPA (TV)
# permissions:
# contents: read
@@ -254,6 +259,11 @@ jobs:
# - name: 🛠️ Generate project files
# run: bun run prebuild:tv
#
# - name: 🔧 Setup Xcode
# uses: maxim-lobanov/setup-xcode@v1
# with:
# xcode-version: '26.0.1'
#
# - name: 🏗️ Setup EAS
# uses: expo/expo-github-action@main
# with:

View File

@@ -25,19 +25,15 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
fetch-depth: 0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3

View File

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

View File

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

7
.gitignore vendored
View File

@@ -51,6 +51,7 @@ npm-debug.*
.ruby-lsp
.cursor/
.claude/
CLAUDE.md
# Environment and Configuration
expo-env.d.ts
@@ -64,4 +65,8 @@ credentials.json
streamyfin-4fec1-firebase-adminsdk.json
# Version and Backup Files
/version-backup-*
/version-backup-*
modules/background-downloader/android/build/*
/modules/sf-player/android/build
/modules/music-controls/android/build
/modules/mpv-player/android/build

177
.vscode/settings.json vendored
View File

@@ -1,178 +1,25 @@
{
// ==========================================
// FORMATTING & LINTING
// ==========================================
// Biome as default formatter
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.formatOnType": false,
// Language-specific formatters
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
"editor.defaultFormatter": "biomejs.biome"
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[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"
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"editor.formatOnSaveMode": "file"
}

119
CLAUDE.md Normal file
View File

@@ -0,0 +1,119 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration.
## Development Commands
**CRITICAL: Always use `bun` for package management. Never use `npm`, `yarn`, or `npx`.**
```bash
# Setup
bun i && bun run submodule-reload
# Development builds
bun run prebuild # Mobile prebuild
bun run ios # Run iOS
bun run android # Run Android
# TV builds (suffix with :tv)
bun run prebuild:tv
bun run ios:tv
bun run android:tv
# Code quality
bun run typecheck # TypeScript check
bun run check # BiomeJS check
bun run lint # BiomeJS lint + fix
bun run format # BiomeJS format
bun run test # Run all checks (typecheck, lint, format, doctor)
# iOS-specific
bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build errors
```
## Tech Stack
- **Runtime**: Bun
- **Framework**: React Native (Expo SDK 54)
- **Language**: TypeScript (strict mode)
- **State Management**: Jotai (global state atoms) + React Query (server state)
- **API**: Jellyfin SDK (`@jellyfin/sdk`)
- **Navigation**: Expo Router (file-based)
- **Linting/Formatting**: BiomeJS
- **Storage**: react-native-mmkv
## Architecture
### File Structure
- `app/` - Expo Router screens with file-based routing
- `components/` - Reusable UI components
- `providers/` - React Context providers
- `hooks/` - Custom React hooks
- `utils/` - Utilities including Jotai atoms
- `modules/` - Native modules (vlc-player, mpv-player, background-downloader)
- `translations/` - i18n translation files
### Key Patterns
**State Management**:
- Global state uses Jotai atoms in `utils/atoms/`
- `settingsAtom` in `utils/atoms/settings.ts` for app settings
- `apiAtom` and `userAtom` in `providers/JellyfinProvider.tsx` for auth state
- Server state uses React Query with `@tanstack/react-query`
**Jellyfin API Access**:
- Use `apiAtom` from `JellyfinProvider` for authenticated API calls
- Access user via `userAtom`
- Use Jellyfin SDK utilities from `@jellyfin/sdk/lib/utils/api`
**Navigation**:
- File-based routing in `app/` directory
- Tab navigation: `(home)`, `(search)`, `(favorites)`, `(libraries)`, `(watchlists)`
- Shared routes use parenthesized groups like `(home,libraries,search,favorites,watchlists)`
**Providers** (wrapping order in `app/_layout.tsx`):
1. JotaiProvider
2. QueryClientProvider
3. JellyfinProvider (auth, API)
4. NetworkStatusProvider
5. PlaySettingsProvider
6. WebSocketProvider
7. DownloadProvider
8. MusicPlayerProvider
### Native Modules
Located in `modules/`:
- `vlc-player` - VLC video player integration
- `mpv-player` - MPV video player integration (iOS)
- `background-downloader` - Background download functionality
- `sf-player` - Swift player module
### Path Aliases
Use `@/` prefix for imports (configured in `tsconfig.json`):
```typescript
import { useSettings } from "@/utils/atoms/settings";
import { apiAtom } from "@/providers/JellyfinProvider";
```
## Coding Standards
- Use TypeScript for all files (no .js)
- Use functional React components with hooks
- Use Jotai atoms for global state, React Query for server state
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
- Handle both mobile and TV navigation patterns
- Use existing atoms, hooks, and utilities before creating new ones
- Use Conventional Commits: `feat(scope):`, `fix(scope):`, `chore(scope):`
## Platform Considerations
- TV version uses `:tv` suffix for scripts
- Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"`
- Some features disabled on TV (e.g., notifications, Chromecast)

232
GLOBAL_MODAL_GUIDE.md Normal file
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

View File

@@ -70,6 +70,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To
<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 Streamyfin on Google Play Store" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
<a href="https://github.com/streamyfin/streamyfin/releases/latest"><img height=50 alt="Get Streamyfin on Github" src="./assets/Download_on_Github_.png"/></a>
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/streamyfin/streamyfin"><img height=50 alt="Add Streamyfin to Obtainium" src="./assets/Download_with_Obtainium.png"/></a>
</div>
### 🧪 Beta Testing
@@ -104,6 +105,7 @@ You can contribute translations directly on our [Crowdin project page](https://c
1. Use node `>20`
2. Install dependencies `bun i && bun run submodule-reload`
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
- If iOS builds fail with `missing Metal Toolchain` (KSPlayer shaders), run `npm run ios:install-metal-toolchain` once
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
4. run `npm run prebuild`
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app

View File

@@ -7,8 +7,8 @@ module.exports = ({ config }) => {
{ useDefaultExpandedMediaControls: true },
]);
// Add the background downloader plugin only for non-TV builds
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
// KSPlayer for iOS (GPU acceleration + native PiP)
config.plugins.push("./plugins/withKSPlayer.js");
}
// Only override googleServicesFile if env var is set

View File

@@ -2,12 +2,13 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.40.1",
"version": "0.51.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
"userInterfaceStyle": "dark",
"jsEngine": "hermes",
"newArchEnabled": true,
"assetBundlePatterns": ["**/*"],
"ios": {
"requireFullScreen": true,
@@ -28,16 +29,12 @@
},
"supportsTablet": true,
"bundleIdentifier": "com.fredrikburmester.streamyfin",
"icon": {
"dark": "./assets/images/icon-ios-plain.png",
"light": "./assets/images/icon-ios-light.png",
"tinted": "./assets/images/icon-ios-tinted.png"
},
"icon": "./assets/images/icon-ios-liquid-glass.icon",
"appleTeamId": "MWD5K362T8"
},
"android": {
"jsEngine": "hermes",
"versionCode": 73,
"versionCode": 91,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",
@@ -56,28 +53,16 @@
"@react-native-tvos/config-tv",
"expo-router",
"expo-font",
[
"react-native-video",
{
"enableNotificationControls": true,
"enableBackgroundAudio": true,
"androidExtensions": {
"useExoplayerRtsp": false,
"useExoplayerSmoothStreaming": false,
"useExoplayerHls": true,
"useExoplayerDash": false
}
}
],
"./plugins/withExcludeMedia3Dash.js",
[
"expo-build-properties",
{
"ios": {
"deploymentTarget": "15.6",
"useFrameworks": "static"
"deploymentTarget": "15.6"
},
"android": {
"compileSdkVersion": 35,
"buildArchs": ["arm64-v8a", "x86_64"],
"compileSdkVersion": 36,
"targetSdkVersion": 35,
"buildToolsVersion": "35.0.0",
"kotlinVersion": "2.0.21",
@@ -115,10 +100,6 @@
}
}
],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
[
"expo-splash-screen",
{
@@ -134,8 +115,12 @@
"color": "#9333EA"
}
],
"./plugins/with-runtime-framework-headers.js",
"react-native-bottom-tabs"
"expo-web-browser",
["./plugins/with-runtime-framework-headers.js"],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"]
],
"experiments": {
"typedRoutes": true
@@ -154,7 +139,6 @@
},
"updates": {
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
},
"newArchEnabled": false
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,212 @@
import type { Api } from "@jellyfin/sdk";
import type {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Stack, useLocalSearchParams } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useWindowDimensions, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
type FavoriteTypes =
| "Series"
| "Movie"
| "Episode"
| "Video"
| "BoxSet"
| "Playlist";
const favoriteTypes: readonly FavoriteTypes[] = [
"Series",
"Movie",
"Episode",
"Video",
"BoxSet",
"Playlist",
] as const;
function isFavoriteType(value: unknown): value is FavoriteTypes {
return (
typeof value === "string" &&
(favoriteTypes as readonly string[]).includes(value)
);
}
export default function FavoritesSeeAllScreen() {
const insets = useSafeAreaInsets();
const { width: screenWidth } = useWindowDimensions();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const searchParams = useLocalSearchParams<{
type?: string;
title?: string;
}>();
const typeParam = searchParams.type;
const titleParam = searchParams.title;
const itemType = useMemo(() => {
if (!isFavoriteType(typeParam)) return null;
return typeParam as BaseItemKind;
}, [typeParam]);
const headerTitle = useMemo(() => {
if (typeof titleParam === "string" && titleParam.trim().length > 0)
return titleParam;
return "";
}, [titleParam]);
const pageSize = 50;
const fetchItems = useCallback(
async ({ pageParam }: { pageParam: number }): Promise<BaseItemDto[]> => {
if (!api || !user?.Id || !itemType) return [];
const response = await getItemsApi(api as Api).getItems({
userId: user.Id,
sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"],
filters: ["IsFavorite"],
recursive: true,
fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false,
excludeLocationTypes: ["Virtual"],
enableTotalRecordCount: true,
startIndex: pageParam,
limit: pageSize,
includeItemTypes: [itemType],
});
return response.data.Items || [];
},
[api, itemType, user?.Id],
);
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
useInfiniteQuery({
queryKey: ["favorites", "see-all", itemType],
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
getNextPageParam: (lastPage, pages) => {
if (!lastPage || lastPage.length < pageSize) return undefined;
return pages.reduce((acc, page) => acc + page.length, 0);
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!itemType,
});
const flatData = useMemo(() => data?.pages.flat() ?? [], [data]);
const nrOfCols = useMemo(() => {
if (screenWidth < 350) return 2;
if (screenWidth < 600) return 3;
if (screenWidth < 900) return 5;
return 6;
}, [screenWidth]);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<TouchableItemRouter
item={item}
style={{
width: "100%",
}}
>
<View
style={{
alignSelf:
index % nrOfCols === 0
? "flex-end"
: (index + 1) % nrOfCols === 0
? "flex-start"
: "center",
width: "89%",
}}
>
<ItemPoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
),
[nrOfCols],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const handleEndReached = useCallback(() => {
if (hasNextPage) {
fetchNextPage();
}
}, [fetchNextPage, hasNextPage]);
return (
<>
<Stack.Screen
options={{
headerTitle: headerTitle,
headerBlurEffect: "none",
headerTransparent: true,
headerShadowVisible: false,
}}
/>
{!itemType ? (
<View className='flex-1 items-center justify-center px-6'>
<Text className='text-neutral-500'>
{t("favorites.noData", { defaultValue: "No items found." })}
</Text>
</View>
) : isLoading ? (
<View className='justify-center items-center h-full'>
<Loader />
</View>
) : (
<FlashList
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={handleEndReached}
onEndReachedThreshold={0.8}
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full py-12'>
<Text className='font-bold text-xl text-neutral-500'>
{t("home.no_items", { defaultValue: "No items" })}
</Text>
</View>
}
ListFooterComponent={
isFetching ? (
<View style={{ paddingVertical: 16 }}>
<Loader />
</View>
) : null
}
/>
)}
</>
);
}

View File

@@ -30,7 +30,6 @@ export default function IndexLayout() {
{!Platform.isTV && (
<>
<Chromecast.Chromecast background='transparent' />
{user?.Policy?.IsAdministrator && <SessionsButton />}
<SettingsButton />
</>
@@ -42,49 +41,291 @@ export default function IndexLayout() {
<Stack.Screen
name='downloads/index'
options={{
headerShown: true,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
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
name='downloads/[seriesId]'
options={{
headerShown: true,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
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
name='sessions/index'
options={{
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
name='settings'
options={{
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
name='settings/marlin-search/page'
name='settings/playback-controls/page'
options={{
title: "",
title: t("home.settings.playback_controls.title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<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/jellyseerr/page'
name='settings/audio-subtitles/page'
options={{
title: "",
title: t("home.settings.audio_subtitles.title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<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/hide-libraries/page'
name='settings/appearance/page'
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/music/page'
options={{
title: t("home.settings.music.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/plugins/streamystats/page'
options={{
title: "Streamystats",
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/kefinTweaks/page'
options={{
title: "KefinTweaks",
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
name='settings/logs/page'
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
@@ -92,6 +333,11 @@ export default function IndexLayout() {
options={{
headerShown: false,
title: "",
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
presentation: "modal",
}}
/>
@@ -102,6 +348,11 @@ export default function IndexLayout() {
name='collections/[collectionId]'
options={{
title: "",
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",

View File

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

View File

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

View File

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

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

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,177 @@
import { Ionicons } from "@expo/vector-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, View } from "react-native";
import { Switch } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { useSettings } from "@/utils/atoms/settings";
const CACHE_SIZE_OPTIONS = [
{ label: "100 MB", value: 100 },
{ label: "250 MB", value: 250 },
{ label: "500 MB", value: 500 },
{ label: "1 GB", value: 1024 },
{ label: "2 GB", value: 2048 },
];
const LOOKAHEAD_COUNT_OPTIONS = [
{ label: "1 song", value: 1 },
{ label: "2 songs", value: 2 },
{ label: "3 songs", value: 3 },
{ label: "5 songs", value: 5 },
];
export default function MusicSettingsPage() {
const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();
const cacheSizeOptions = useMemo(
() => [
{
options: CACHE_SIZE_OPTIONS.map((option) => ({
type: "radio" as const,
label: option.label,
value: String(option.value),
selected: option.value === settings?.audioMaxCacheSizeMB,
onPress: () => updateSettings({ audioMaxCacheSizeMB: option.value }),
})),
},
],
[settings?.audioMaxCacheSizeMB, updateSettings],
);
const currentCacheSizeLabel =
CACHE_SIZE_OPTIONS.find((o) => o.value === settings?.audioMaxCacheSizeMB)
?.label ?? `${settings?.audioMaxCacheSizeMB} MB`;
const lookaheadCountOptions = useMemo(
() => [
{
options: LOOKAHEAD_COUNT_OPTIONS.map((option) => ({
type: "radio" as const,
label: option.label,
value: String(option.value),
selected: option.value === settings?.audioLookaheadCount,
onPress: () => updateSettings({ audioLookaheadCount: option.value }),
})),
},
],
[settings?.audioLookaheadCount, updateSettings],
);
const currentLookaheadLabel =
LOOKAHEAD_COUNT_OPTIONS.find(
(o) => o.value === settings?.audioLookaheadCount,
)?.label ?? `${settings?.audioLookaheadCount} songs`;
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<ListGroup
title={t("home.settings.music.playback_title")}
description={
<Text className='text-[#8E8D91] text-xs'>
{t("home.settings.music.playback_description")}
</Text>
}
>
<ListItem
title={t("home.settings.music.prefer_downloaded")}
disabled={pluginSettings?.preferLocalAudio?.locked}
>
<Switch
value={settings.preferLocalAudio}
disabled={pluginSettings?.preferLocalAudio?.locked}
onValueChange={(value) =>
updateSettings({ preferLocalAudio: value })
}
/>
</ListItem>
</ListGroup>
<View className='mt-4'>
<ListGroup
title={t("home.settings.music.caching_title")}
description={
<Text className='text-[#8E8D91] text-xs'>
{t("home.settings.music.caching_description")}
</Text>
}
>
<ListItem
title={t("home.settings.music.lookahead_enabled")}
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
>
<Switch
value={settings.audioLookaheadEnabled}
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
onValueChange={(value) =>
updateSettings({ audioLookaheadEnabled: value })
}
/>
</ListItem>
<ListItem
title={t("home.settings.music.lookahead_count")}
disabled={
pluginSettings?.audioLookaheadCount?.locked ||
!settings.audioLookaheadEnabled
}
>
<PlatformDropdown
groups={lookaheadCountOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{currentLookaheadLabel}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.music.lookahead_count")}
/>
</ListItem>
<ListItem
title={t("home.settings.music.max_cache_size")}
disabled={pluginSettings?.audioMaxCacheSizeMB?.locked}
>
<PlatformDropdown
groups={cacheSizeOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{currentCacheSizeLabel}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.music.max_cache_size")}
/>
</ListItem>
</ListGroup>
</View>
</View>
</ScrollView>
);
}

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,27 @@
import { ScrollView } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets();
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<DisabledSetting
disabled={pluginSettings?.useKefinTweaks?.locked === true}
className='px-4'
>
<KefinTweaksSettings />
</DisabledSetting>
</ScrollView>
);
}

View File

@@ -0,0 +1,142 @@
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, pluginSettings?.marlinServerUrl?.locked, t]);
if (!settings) return null;
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<DisabledSetting disabled={disabled} className='px-4'>
<ListGroup>
<DisabledSetting
disabled={
pluginSettings?.searchEngine?.locked === true ||
!!pluginSettings?.streamyStatsServerUrl?.value
}
showText={!pluginSettings?.marlinServerUrl?.locked}
>
<ListItem
title={t(
"home.settings.plugins.marlin_search.enable_marlin_search",
)}
onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<Switch
value={settings.searchEngine === "Marlin"}
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
onValueChange={(value) => {
updateSettings({
searchEngine: value ? "Marlin" : "Jellyfin",
});
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
/>
</ListItem>
</DisabledSetting>
</ListGroup>
<DisabledSetting
disabled={pluginSettings?.marlinServerUrl?.locked === true}
showText={!pluginSettings?.searchEngine?.locked}
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
>
<View
className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}
>
<Text className='mr-4'>
{t("home.settings.plugins.marlin_search.url")}
</Text>
<TextInput
editable={settings.searchEngine === "Marlin"}
className='text-white'
placeholder={t(
"home.settings.plugins.marlin_search.server_url_placeholder",
)}
value={value}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={(text) => setValue(text)}
/>
</View>
</DisabledSetting>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
<Text className='text-blue-500' onPress={handleOpenLink}>
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
</Text>
</Text>
</DisabledSetting>
</ScrollView>
);
}

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

@@ -0,0 +1,262 @@
import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Linking,
ScrollView,
Switch,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const { t } = useTranslation();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const {
settings,
updateSettings,
pluginSettings,
refreshStreamyfinPluginSettings,
} = useSettings();
const queryClient = useQueryClient();
// Local state for all editable fields
const [url, setUrl] = useState<string>(settings?.streamyStatsServerUrl || "");
const [useForSearch, setUseForSearch] = useState<boolean>(
settings?.searchEngine === "Streamystats",
);
const [movieRecs, setMovieRecs] = useState<boolean>(
settings?.streamyStatsMovieRecommendations ?? false,
);
const [seriesRecs, setSeriesRecs] = useState<boolean>(
settings?.streamyStatsSeriesRecommendations ?? false,
);
const [promotedWatchlists, setPromotedWatchlists] = useState<boolean>(
settings?.streamyStatsPromotedWatchlists ?? false,
);
const [hideWatchlistsTab, setHideWatchlistsTab] = useState<boolean>(
settings?.hideWatchlistsTab ?? false,
);
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
const isStreamystatsEnabled = !!url;
const onSave = useCallback(() => {
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
updateSettings({
streamyStatsServerUrl: cleanUrl,
searchEngine: useForSearch ? "Streamystats" : "Jellyfin",
streamyStatsMovieRecommendations: movieRecs,
streamyStatsSeriesRecommendations: seriesRecs,
streamyStatsPromotedWatchlists: promotedWatchlists,
hideWatchlistsTab: hideWatchlistsTab,
});
queryClient.invalidateQueries({ queryKey: ["search"] });
queryClient.invalidateQueries({ queryKey: ["streamystats"] });
toast.success(t("home.settings.plugins.streamystats.toasts.saved"));
}, [
url,
useForSearch,
movieRecs,
seriesRecs,
promotedWatchlists,
hideWatchlistsTab,
updateSettings,
queryClient,
t,
]);
// Set up header save button
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={onSave}>
<Text className='text-blue-500 font-medium'>
{t("home.settings.plugins.streamystats.save")}
</Text>
</TouchableOpacity>
),
});
}, [navigation, onSave, t]);
const handleClearStreamystats = useCallback(() => {
setUrl("");
setUseForSearch(false);
setMovieRecs(false);
setSeriesRecs(false);
setPromotedWatchlists(false);
setHideWatchlistsTab(false);
updateSettings({
streamyStatsServerUrl: "",
searchEngine: "Jellyfin",
streamyStatsMovieRecommendations: false,
streamyStatsSeriesRecommendations: false,
streamyStatsPromotedWatchlists: false,
hideWatchlistsTab: false,
});
queryClient.invalidateQueries({ queryKey: ["streamystats"] });
queryClient.invalidateQueries({ queryKey: ["search"] });
toast.success(t("home.settings.plugins.streamystats.toasts.disabled"));
}, [updateSettings, queryClient, t]);
const handleOpenLink = () => {
Linking.openURL("https://github.com/fredrikburmester/streamystats");
};
const handleRefreshFromServer = useCallback(async () => {
const newPluginSettings = await refreshStreamyfinPluginSettings(true);
// Update local state with new values
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
setUrl(newUrl);
if (newUrl) {
setUseForSearch(true);
}
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
}, [refreshStreamyfinPluginSettings, t]);
if (!settings) return null;
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View className='px-4'>
<ListGroup className='flex-1'>
<ListItem
title={t("home.settings.plugins.streamystats.url")}
disabledByAdmin={isUrlLocked}
>
<TextInput
editable={!isUrlLocked}
className='text-white text-right flex-1'
placeholder={t(
"home.settings.plugins.streamystats.server_url_placeholder",
)}
value={url}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={setUrl}
/>
</ListItem>
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.plugins.streamystats.streamystats_search_hint")}{" "}
<Text className='text-blue-500' onPress={handleOpenLink}>
{t(
"home.settings.plugins.streamystats.read_more_about_streamystats",
)}
</Text>
</Text>
<ListGroup
title={t("home.settings.plugins.streamystats.features_title")}
className='mt-4'
>
<ListItem
title={t("home.settings.plugins.streamystats.enable_search")}
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
>
<Switch
value={useForSearch}
disabled={!isStreamystatsEnabled}
onValueChange={setUseForSearch}
/>
</ListItem>
<ListItem
title={t(
"home.settings.plugins.streamystats.enable_movie_recommendations",
)}
disabledByAdmin={
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
}
>
<Switch
value={movieRecs}
onValueChange={setMovieRecs}
disabled={!isStreamystatsEnabled}
/>
</ListItem>
<ListItem
title={t(
"home.settings.plugins.streamystats.enable_series_recommendations",
)}
disabledByAdmin={
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
}
>
<Switch
value={seriesRecs}
onValueChange={setSeriesRecs}
disabled={!isStreamystatsEnabled}
/>
</ListItem>
<ListItem
title={t(
"home.settings.plugins.streamystats.enable_promoted_watchlists",
)}
disabledByAdmin={
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
}
>
<Switch
value={promotedWatchlists}
onValueChange={setPromotedWatchlists}
disabled={!isStreamystatsEnabled}
/>
</ListItem>
<ListItem
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
>
<Switch
value={hideWatchlistsTab}
onValueChange={setHideWatchlistsTab}
disabled={!isStreamystatsEnabled}
/>
</ListItem>
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.plugins.streamystats.home_sections_hint")}
</Text>
<TouchableOpacity
onPress={handleRefreshFromServer}
className='mt-6 py-3 rounded-xl bg-neutral-800'
>
<Text className='text-center text-blue-500'>
{t("home.settings.plugins.streamystats.refresh_from_server")}
</Text>
</TouchableOpacity>
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
{!isUrlLocked && isStreamystatsEnabled && (
<TouchableOpacity
onPress={handleClearStreamystats}
className='mt-3 mb-4 py-3 rounded-xl bg-neutral-800'
>
<Text className='text-center text-red-500'>
{t("home.settings.plugins.streamystats.disable_streamystats")}
</Text>
</TouchableOpacity>
)}
</View>
</ScrollView>
);
}

View File

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

View File

@@ -1,3 +1,4 @@
import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
import { useLocalSearchParams } from "expo-router";
import type React from "react";
import { useEffect } from "react";
@@ -20,7 +21,16 @@ const Page: React.FC = () => {
const { offline } = useLocalSearchParams() as { offline?: string };
const isOffline = offline === "true";
const { data: item, isError } = useItemQuery(id, isOffline);
// Exclude MediaSources/MediaStreams from initial fetch for faster loading
// (especially important for plugins like Gelato)
const { data: item, isError } = useItemQuery(id, isOffline, undefined, [
ItemFields.MediaSources,
ItemFields.MediaSourceCount,
ItemFields.MediaStreams,
]);
// Lazily preload item with full media sources in background
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, []);
const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => {
@@ -90,7 +100,13 @@ const Page: React.FC = () => {
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
</Animated.View>
{item && <ItemContent item={item} isOffline={isOffline} />}
{item && (
<ItemContent
item={item}
isOffline={isOffline}
itemWithSources={itemWithSources}
/>
)}
</View>
);
};

View File

@@ -21,19 +21,18 @@ export default function page() {
companyId: string;
name: string;
image: string;
type: DiscoverSliderType;
type: DiscoverSliderType; //This gets converted to a string because it's a url param
};
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
queryKey: ["jellyseerr", "company", type, companyId],
queryFn: async ({ pageParam }) => {
const params: any = {
page: Number(pageParam),
};
return jellyseerrApi?.discover(
`${
type === DiscoverSliderType.NETWORKS
Number(type) === DiscoverSliderType.NETWORKS
? Endpoints.DISCOVER_TV_NETWORK
: Endpoints.DISCOVER_MOVIES_STUDIO
}/${companyId}`,
@@ -86,6 +85,7 @@ export default function page() {
fetchNextPage();
}
}}
isLoading={isLoading}
logo={
<Image
id={companyId}

View File

@@ -14,36 +14,43 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
import RequestModal from "@/components/jellyseerr/RequestModal";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { JellyserrRatings } from "@/components/Ratings";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import {
type IssueType,
IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import {
MediaRequestStatus,
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {
hasPermission,
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import RequestModal from "@/components/jellyseerr/RequestModal";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
const Page: React.FC = () => {
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
@@ -60,11 +67,12 @@ const Page: React.FC = () => {
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
const navigation = useNavigation();
const { jellyseerrApi, requestMedia } = useJellyseerr();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>();
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
const advancedReqModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -92,6 +100,46 @@ const Page: React.FC = () => {
const [canRequest, hasAdvancedRequestPermission] =
useJellyseerrCanRequest(details);
const canManageRequests = useMemo(() => {
if (!jellyseerrUser) return false;
return hasPermission(
Permission.MANAGE_REQUESTS,
jellyseerrUser.permissions,
);
}, [jellyseerrUser]);
const pendingRequest = useMemo(() => {
return details?.mediaInfo?.requests?.find(
(r: MediaRequest) => r.status === MediaRequestStatus.PENDING,
);
}, [details]);
const handleApproveRequest = useCallback(async () => {
if (!pendingRequest?.id) return;
try {
await jellyseerrApi?.approveRequest(pendingRequest.id);
toast.success(t("jellyseerr.toasts.request_approved"));
refetch();
} catch (error) {
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
console.error("Failed to approve request:", error);
}
}, [jellyseerrApi, pendingRequest, refetch, t]);
const handleDeclineRequest = useCallback(async () => {
if (!pendingRequest?.id) return;
try {
await jellyseerrApi?.declineRequest(pendingRequest.id);
toast.success(t("jellyseerr.toasts.request_declined"));
refetch();
} catch (error) {
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
console.error("Failed to decline request:", error);
}
}, [jellyseerrApi, pendingRequest, refetch, t]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
@@ -115,6 +163,10 @@ const Page: React.FC = () => {
}
}, [jellyseerrApi, details, result, issueType, issueMessage]);
const handleIssueModalDismiss = useCallback(() => {
setIssueTypeDropdownOpen(false);
}, []);
const setRequestBody = useCallback(
(body: MediaRequestBody) => {
_setRequestBody(body);
@@ -128,9 +180,11 @@ const Page: React.FC = () => {
mediaId: Number(result.id!),
mediaType: mediaType!,
tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
?.map?.((s) => s.seasonNumber),
...(mediaType === MediaType.TV && {
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
?.map?.((s) => s.seasonNumber),
}),
};
if (hasAdvancedRequestPermission) {
@@ -156,11 +210,31 @@ const Page: React.FC = () => {
[details],
);
const issueTypeOptionGroups = useMemo(
() => [
{
title: t("jellyseerr.types"),
options: Object.entries(IssueTypeName)
.reverse()
.map(([key, value]) => ({
type: "radio" as const,
label: value,
value: key,
selected: key === String(issueType),
onPress: () => setIssueType(key as unknown as IssueType),
})),
},
],
[issueType, t],
);
useEffect(() => {
if (details) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
<TouchableOpacity
className={`rounded-full pl-1.5 ${Platform.OS === "android" ? "" : "bg-neutral-800/80"}`}
>
<ItemActions item={details} />
</TouchableOpacity>
),
@@ -309,6 +383,60 @@ const Page: React.FC = () => {
</View>
)
)}
{canManageRequests && pendingRequest && (
<View className='flex flex-col space-y-2 mt-4'>
<View className='flex flex-row items-center space-x-2'>
<Ionicons name='person-outline' size={16} color='#9CA3AF' />
<Text className='text-sm text-neutral-400'>
{t("jellyseerr.requested_by", {
user:
pendingRequest.requestedBy?.displayName ||
pendingRequest.requestedBy?.username ||
pendingRequest.requestedBy?.jellyfinUsername ||
t("jellyseerr.unknown_user"),
})}
</Text>
</View>
<View className='flex flex-row space-x-2'>
<Button
className='flex-1 bg-green-600/50 border-green-400 ring-green-400 text-green-100'
color='transparent'
onPress={handleApproveRequest}
iconLeft={
<Ionicons
name='checkmark-outline'
size={20}
color='white'
/>
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>{t("jellyseerr.approve")}</Text>
</Button>
<Button
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
color='transparent'
onPress={handleDeclineRequest}
iconLeft={
<Ionicons
name='close-outline'
size={20}
color='white'
/>
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>{t("jellyseerr.decline")}</Text>
</Button>
</View>
</View>
)}
<OverviewText text={result.overview} className='mt-4' />
</View>
@@ -355,6 +483,8 @@ const Page: React.FC = () => {
backgroundColor: "#171717",
}}
backdropComponent={renderBackdrop}
stackBehavior='push'
onDismiss={handleIssueModalDismiss}
>
<BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
@@ -364,50 +494,25 @@ const Page: React.FC = () => {
</Text>
</View>
<View className='flex flex-col space-y-2 items-start'>
<View className='flex flex-col'>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.issue_type")}
<View className='flex flex-col w-full'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.issue_type")}
</Text>
<PlatformDropdown
groups={issueTypeOptionGroups}
trigger={
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side='bottom'
align='center'
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>
{t("jellyseerr.types")}
</DropdownMenu.Label>
{Object.entries(IssueTypeName)
.reverse()
.map(([key, value], _idx) => (
<DropdownMenu.Item
key={value}
onSelect={() =>
setIssueType(key as unknown as IssueType)
}
>
<DropdownMenu.ItemTitle>
{value}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
}
title={t("jellyseerr.types")}
open={issueTypeDropdownOpen}
onOpenChange={setIssueTypeDropdownOpen}
/>
</View>
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>

View File

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

View File

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

View File

@@ -0,0 +1,300 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Dimensions,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
import {
downloadTrack,
isPermanentlyDownloaded,
} from "@/providers/AudioStorage";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const ARTWORK_SIZE = SCREEN_WIDTH * 0.5;
export default function AlbumDetailScreen() {
const { albumId } = useLocalSearchParams<{ albumId: string }>();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const navigation = useNavigation();
const { t } = useTranslation();
const { playQueue } = useMusicPlayer();
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
setSelectedTrack(track);
setTrackOptionsOpen(true);
}, []);
const handleAddToPlaylist = useCallback(() => {
setPlaylistPickerOpen(true);
}, []);
const handleCreateNewPlaylist = useCallback(() => {
setCreatePlaylistOpen(true);
}, []);
const { data: album, isLoading: loadingAlbum } = useQuery({
queryKey: ["music-album", albumId, user?.Id],
queryFn: async () => {
const response = await getUserLibraryApi(api!).getItem({
userId: user?.Id,
itemId: albumId!,
});
return response.data;
},
enabled: !!api && !!user?.Id && !!albumId,
});
const { data: tracks, isLoading: loadingTracks } = useQuery({
queryKey: ["music-album-tracks", albumId, user?.Id],
queryFn: async () => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: albumId,
sortBy: ["IndexNumber"],
sortOrder: ["Ascending"],
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && !!albumId,
});
useEffect(() => {
navigation.setOptions({
title: album?.Name ?? "",
headerTransparent: true,
headerStyle: { backgroundColor: "transparent" },
headerShadowVisible: false,
});
}, [album?.Name, navigation]);
const imageUrl = useMemo(
() => (album ? getPrimaryImageUrl({ api, item: album }) : null),
[api, album],
);
const totalDuration = useMemo(() => {
if (!tracks) return "";
const totalTicks = tracks.reduce(
(acc, track) => acc + (track.RunTimeTicks || 0),
0,
);
return runtimeTicksToMinutes(totalTicks);
}, [tracks]);
const handlePlayAll = useCallback(() => {
if (tracks && tracks.length > 0) {
playQueue(tracks, 0);
}
}, [playQueue, tracks]);
const handleShuffle = useCallback(() => {
if (tracks && tracks.length > 0) {
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
playQueue(shuffled, 0);
}
}, [playQueue, tracks]);
// Check if all tracks are already permanently downloaded
const allTracksDownloaded = useMemo(() => {
if (!tracks || tracks.length === 0) return false;
return tracks.every((track) => isPermanentlyDownloaded(track.Id));
}, [tracks]);
const handleDownloadAlbum = useCallback(async () => {
if (!tracks || !api || !user?.Id || isDownloading) return;
setIsDownloading(true);
try {
for (const track of tracks) {
if (!track.Id || isPermanentlyDownloaded(track.Id)) continue;
const result = await getAudioStreamUrl(api, user.Id, track.Id);
if (result?.url && !result.isTranscoding) {
await downloadTrack(track.Id, result.url, {
permanent: true,
container: result.mediaSource?.Container || undefined,
});
}
}
} catch {
// Silent fail
}
setIsDownloading(false);
}, [tracks, api, user?.Id, isDownloading]);
const isLoading = loadingAlbum || loadingTracks;
// Only show loading if we have no cached data to display
if (isLoading && !album) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
if (!album) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Text className='text-neutral-500'>{t("music.album_not_found")}</Text>
</View>
);
}
return (
<FlashList
data={tracks || []}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
}}
ListHeaderComponent={
<View
className='items-center px-4 pb-6 bg-black'
style={{ paddingTop: insets.top + 60 }}
>
{/* Album artwork */}
<View
style={{
width: ARTWORK_SIZE,
height: ARTWORK_SIZE,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
shadowColor: "#000",
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.3,
shadowRadius: 12,
elevation: 8,
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Ionicons name='disc' size={60} color='#666' />
</View>
)}
</View>
{/* Album info */}
<Text className='text-white text-xl font-bold mt-4 text-center'>
{album.Name}
</Text>
<Text className='text-purple-400 text-base mt-1'>
{album.AlbumArtist || album.Artists?.join(", ")}
</Text>
<Text className='text-neutral-500 text-sm mt-1'>
{album.ProductionYear && `${album.ProductionYear}`}
{tracks?.length} tracks {totalDuration}
</Text>
{/* Play buttons */}
<View className='flex flex-row mt-4 items-center'>
<TouchableOpacity
onPress={handlePlayAll}
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mr-3'
>
<Ionicons name='play' size={20} color='white' />
<Text className='text-white font-medium ml-2'>
{t("music.play")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleShuffle}
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full mr-3'
>
<Ionicons name='shuffle' size={20} color='white' />
<Text className='text-white font-medium ml-2'>
{t("music.shuffle")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleDownloadAlbum}
disabled={allTracksDownloaded || isDownloading}
className='flex items-center justify-center bg-neutral-800 p-3 rounded-full'
>
{isDownloading ? (
<ActivityIndicator size={20} color='white' />
) : (
<Ionicons
name={
allTracksDownloaded
? "checkmark-circle"
: "download-outline"
}
size={20}
color={allTracksDownloaded ? "#22c55e" : "white"}
/>
)}
</TouchableOpacity>
</View>
</View>
}
renderItem={({ item, index }) => (
<MusicTrackItem
track={item}
index={index + 1}
queue={tracks}
showArtwork={false}
onOptionsPress={handleTrackOptionsPress}
/>
)}
keyExtractor={(item) => item.Id!}
ListFooterComponent={
<>
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={selectedTrack}
onAddToPlaylist={handleAddToPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={selectedTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={selectedTrack?.Id}
/>
</>
}
/>
);
}

View File

@@ -0,0 +1,273 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Dimensions, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const ARTWORK_SIZE = SCREEN_WIDTH * 0.4;
export default function ArtistDetailScreen() {
const { artistId } = useLocalSearchParams<{ artistId: string }>();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const navigation = useNavigation();
const { t } = useTranslation();
const { playQueue } = useMusicPlayer();
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
setSelectedTrack(track);
setTrackOptionsOpen(true);
}, []);
const handleAddToPlaylist = useCallback(() => {
setPlaylistPickerOpen(true);
}, []);
const handleCreateNewPlaylist = useCallback(() => {
setCreatePlaylistOpen(true);
}, []);
const { data: artist, isLoading: loadingArtist } = useQuery({
queryKey: ["music-artist", artistId, user?.Id],
queryFn: async () => {
const response = await getUserLibraryApi(api!).getItem({
userId: user?.Id,
itemId: artistId!,
});
return response.data;
},
enabled: !!api && !!user?.Id && !!artistId,
});
const { data: albums, isLoading: loadingAlbums } = useQuery({
queryKey: ["music-artist-albums", artistId, user?.Id],
queryFn: async () => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
artistIds: [artistId!],
includeItemTypes: ["MusicAlbum"],
sortBy: ["ProductionYear", "SortName"],
sortOrder: ["Descending", "Ascending"],
recursive: true,
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && !!artistId,
});
const { data: topTracks, isLoading: loadingTracks } = useQuery({
queryKey: ["music-artist-top-tracks", artistId, user?.Id],
queryFn: async () => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
artistIds: [artistId!],
includeItemTypes: ["Audio"],
sortBy: ["PlayCount"],
sortOrder: ["Descending"],
limit: 10,
recursive: true,
filters: ["IsPlayed"],
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && !!artistId,
});
useEffect(() => {
navigation.setOptions({
title: artist?.Name ?? "",
headerTransparent: true,
headerStyle: { backgroundColor: "transparent" },
headerShadowVisible: false,
});
}, [artist?.Name, navigation]);
const imageUrl = useMemo(
() => (artist ? getPrimaryImageUrl({ api, item: artist }) : null),
[api, artist],
);
const handlePlayAllTracks = useCallback(() => {
if (topTracks && topTracks.length > 0) {
playQueue(topTracks, 0);
}
}, [playQueue, topTracks]);
const isLoading = loadingArtist || loadingAlbums || loadingTracks;
// Only show loading if we have no cached data to display
if (isLoading && !artist) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
if (!artist) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Text className='text-neutral-500'>{t("music.artist_not_found")}</Text>
</View>
);
}
const sections = [];
// Top tracks section
if (topTracks && topTracks.length > 0) {
sections.push({
id: "top-tracks",
title: t("music.top_tracks"),
type: "tracks" as const,
data: topTracks,
});
}
// Albums section
if (albums && albums.length > 0) {
sections.push({
id: "albums",
title: t("music.tabs.albums"),
type: "albums" as const,
data: albums,
});
}
return (
<FlashList
data={sections}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
}}
ListHeaderComponent={
<View
className='items-center px-4 pb-6 bg-black'
style={{ paddingTop: insets.top + 50 }}
>
{/* Artist image */}
<View
style={{
width: ARTWORK_SIZE,
height: ARTWORK_SIZE,
borderRadius: ARTWORK_SIZE / 2,
overflow: "hidden",
backgroundColor: "#1a1a1a",
shadowColor: "#000",
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.3,
shadowRadius: 12,
elevation: 8,
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Ionicons name='person' size={60} color='#666' />
</View>
)}
</View>
{/* Artist info */}
<Text className='text-white text-2xl font-bold mt-4 text-center'>
{artist.Name}
</Text>
<Text className='text-neutral-500 text-sm mt-1'>
{albums?.length || 0} {t("music.tabs.albums").toLowerCase()}
</Text>
{/* Play button */}
{topTracks && topTracks.length > 0 && (
<TouchableOpacity
onPress={handlePlayAllTracks}
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mt-4'
>
<Ionicons name='play' size={20} color='white' />
<Text className='text-white font-medium ml-2'>
{t("music.play_top_tracks")}
</Text>
</TouchableOpacity>
)}
</View>
}
renderItem={({ item: section }) => (
<View className='mb-6'>
<Text className='text-lg font-bold px-4 mb-3'>{section.title}</Text>
{section.type === "albums" ? (
<HorizontalScroll
data={section.data}
height={200}
keyExtractor={(item) => item.Id!}
renderItem={(item) => <MusicAlbumCard album={item} />}
/>
) : (
section.data
.slice(0, 5)
.map((track, index) => (
<MusicTrackItem
key={track.Id}
track={track}
index={index + 1}
queue={section.data}
onOptionsPress={handleTrackOptionsPress}
/>
))
)}
</View>
)}
keyExtractor={(item) => item.id}
ListFooterComponent={
<>
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={selectedTrack}
onAddToPlaylist={handleAddToPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={selectedTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={selectedTrack?.Id}
/>
</>
}
/>
);
}

View File

@@ -0,0 +1,321 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Dimensions,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
import { PlaylistOptionsSheet } from "@/components/music/PlaylistOptionsSheet";
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
import { useRemoveFromPlaylist } from "@/hooks/usePlaylistMutations";
import { downloadTrack, getLocalPath } from "@/providers/AudioStorage";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const ARTWORK_SIZE = SCREEN_WIDTH * 0.5;
export default function PlaylistDetailScreen() {
const { playlistId } = useLocalSearchParams<{ playlistId: string }>();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const navigation = useNavigation();
const { t } = useTranslation();
const { playQueue } = useMusicPlayer();
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
const [playlistOptionsOpen, setPlaylistOptionsOpen] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const removeFromPlaylist = useRemoveFromPlaylist();
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
setSelectedTrack(track);
setTrackOptionsOpen(true);
}, []);
const handleAddToPlaylist = useCallback(() => {
setPlaylistPickerOpen(true);
}, []);
const handleCreateNewPlaylist = useCallback(() => {
setCreatePlaylistOpen(true);
}, []);
const handleRemoveFromPlaylist = useCallback(() => {
if (selectedTrack?.Id && playlistId) {
removeFromPlaylist.mutate({
playlistId,
entryIds: [selectedTrack.PlaylistItemId ?? selectedTrack.Id],
});
}
}, [selectedTrack, playlistId, removeFromPlaylist]);
const { data: playlist, isLoading: loadingPlaylist } = useQuery({
queryKey: ["music-playlist", playlistId, user?.Id],
queryFn: async () => {
const response = await getUserLibraryApi(api!).getItem({
userId: user?.Id,
itemId: playlistId!,
});
return response.data;
},
enabled: !!api && !!user?.Id && !!playlistId,
});
const { data: tracks, isLoading: loadingTracks } = useQuery({
queryKey: ["music-playlist-tracks", playlistId, user?.Id],
queryFn: async () => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: playlistId,
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && !!playlistId,
});
useEffect(() => {
navigation.setOptions({
title: playlist?.Name ?? "",
headerTransparent: true,
headerStyle: { backgroundColor: "transparent" },
headerShadowVisible: false,
headerRight: () => (
<TouchableOpacity
onPress={() => setPlaylistOptionsOpen(true)}
className='p-1.5'
>
<Ionicons name='ellipsis-horizontal' size={24} color='white' />
</TouchableOpacity>
),
});
}, [playlist?.Name, navigation]);
const imageUrl = useMemo(
() => (playlist ? getPrimaryImageUrl({ api, item: playlist }) : null),
[api, playlist],
);
const totalDuration = useMemo(() => {
if (!tracks) return "";
const totalTicks = tracks.reduce(
(acc, track) => acc + (track.RunTimeTicks || 0),
0,
);
return runtimeTicksToMinutes(totalTicks);
}, [tracks]);
const handlePlayAll = useCallback(() => {
if (tracks && tracks.length > 0) {
playQueue(tracks, 0);
}
}, [playQueue, tracks]);
const handleShuffle = useCallback(() => {
if (tracks && tracks.length > 0) {
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
playQueue(shuffled, 0);
}
}, [playQueue, tracks]);
// Check if all tracks are already downloaded
const allTracksDownloaded = useMemo(() => {
if (!tracks || tracks.length === 0) return false;
return tracks.every((track) => !!getLocalPath(track.Id));
}, [tracks]);
const handleDownloadPlaylist = useCallback(async () => {
if (!tracks || !api || !user?.Id || isDownloading) return;
setIsDownloading(true);
try {
for (const track of tracks) {
if (!track.Id || getLocalPath(track.Id)) continue;
const result = await getAudioStreamUrl(api, user.Id, track.Id);
if (result?.url && !result.isTranscoding) {
await downloadTrack(track.Id, result.url, {
permanent: true,
container: result.mediaSource?.Container || undefined,
});
}
}
} catch {
// Silent fail
}
setIsDownloading(false);
}, [tracks, api, user?.Id, isDownloading]);
const isLoading = loadingPlaylist || loadingTracks;
// Only show loading if we have no cached data to display
if (isLoading && !playlist) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
if (!playlist) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Text className='text-neutral-500'>
{t("music.playlist_not_found")}
</Text>
</View>
);
}
return (
<FlashList
data={tracks || []}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
}}
ListHeaderComponent={
<View
className='items-center px-4 pb-6 bg-black'
style={{ paddingTop: insets.top + 50 }}
>
{/* Playlist artwork */}
<View
style={{
width: ARTWORK_SIZE,
height: ARTWORK_SIZE,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
shadowColor: "#000",
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.3,
shadowRadius: 12,
elevation: 8,
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Ionicons name='list' size={60} color='#666' />
</View>
)}
</View>
{/* Playlist info */}
<Text className='text-white text-xl font-bold mt-4 text-center'>
{playlist.Name}
</Text>
<Text className='text-neutral-500 text-sm mt-1'>
{tracks?.length} tracks {totalDuration}
</Text>
{/* Play buttons */}
<View className='flex flex-row mt-4 items-center'>
<TouchableOpacity
onPress={handlePlayAll}
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mr-3'
>
<Ionicons name='play' size={20} color='white' />
<Text className='text-white font-medium ml-2'>
{t("music.play")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleShuffle}
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full mr-3'
>
<Ionicons name='shuffle' size={20} color='white' />
<Text className='text-white font-medium ml-2'>
{t("music.shuffle")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleDownloadPlaylist}
disabled={allTracksDownloaded || isDownloading}
className='flex items-center justify-center bg-neutral-800 p-3 rounded-full'
>
{isDownloading ? (
<ActivityIndicator size={20} color='white' />
) : (
<Ionicons
name={
allTracksDownloaded
? "checkmark-circle"
: "download-outline"
}
size={20}
color={allTracksDownloaded ? "#22c55e" : "white"}
/>
)}
</TouchableOpacity>
</View>
</View>
}
renderItem={({ item, index }) => (
<MusicTrackItem
track={item}
index={index + 1}
queue={tracks}
onOptionsPress={handleTrackOptionsPress}
/>
)}
keyExtractor={(item) => item.Id!}
ListFooterComponent={
<>
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={selectedTrack}
onAddToPlaylist={handleAddToPlaylist}
playlistId={playlistId}
onRemoveFromPlaylist={handleRemoveFromPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={selectedTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={selectedTrack?.Id}
/>
<PlaylistOptionsSheet
open={playlistOptionsOpen}
setOpen={setPlaylistOptionsOpen}
playlist={playlist}
/>
</>
}
/>
);
}

View File

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

View File

@@ -2,6 +2,7 @@ import type {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
ItemFilter,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getFilterApi,
@@ -27,7 +28,11 @@ import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
FilterByOption,
FilterByPreferenceAtom,
filterByAtom,
genreFilterAtom,
getFilterByPreference,
getSortByPreference,
getSortOrderPreference,
SortByOption,
@@ -39,8 +44,10 @@ import {
sortOrderOptions,
sortOrderPreferenceAtom,
tagsFilterAtom,
useFilterOptions,
yearFilterAtom,
} from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings";
const Page = () => {
const searchParams = useLocalSearchParams();
@@ -54,9 +61,13 @@ const Page = () => {
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [sortBy, _setSortBy] = useAtom(sortByAtom);
const [filterBy, _setFilterBy] = useAtom(filterByAtom);
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
const [sortOrderPreference, setOderByPreference] = useAtom(
const [filterByPreference, setFilterByPreference] = useAtom(
FilterByPreferenceAtom,
);
const [sortOrderPreference, setOrderByPreference] = useAtom(
sortOrderPreferenceAtom,
);
@@ -77,12 +88,20 @@ const Page = () => {
} else {
_setSortBy([SortByOption.SortName]);
}
const fp = getFilterByPreference(libraryId, filterByPreference);
if (fp) {
_setFilterBy([fp]);
} else {
_setFilterBy([]);
}
}, [
libraryId,
sortOrderPreference,
sortByPreference,
_setSortOrder,
_setSortBy,
filterByPreference,
_setFilterBy,
]);
const setSortBy = useCallback(
@@ -100,14 +119,28 @@ const Page = () => {
(sortOrder: SortOrderOption[]) => {
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
if (sortOrder[0] !== sop) {
setOderByPreference({
setOrderByPreference({
...sortOrderPreference,
[libraryId]: sortOrder[0],
});
}
_setSortOrder(sortOrder);
},
[libraryId, sortOrderPreference, setOderByPreference, _setSortOrder],
[libraryId, sortOrderPreference, setOrderByPreference, _setSortOrder],
);
const setFilter = useCallback(
(filterBy: FilterByOption[]) => {
const fp = getFilterByPreference(libraryId, filterByPreference);
if (filterBy[0] !== fp) {
setFilterByPreference({
...filterByPreference,
[libraryId]: filterBy[0],
});
}
_setFilterBy(filterBy);
},
[libraryId, filterByPreference, setFilterByPreference, _setFilterBy],
);
const nrOfCols = useMemo(() => {
@@ -168,6 +201,7 @@ const Page = () => {
sortBy: [sortBy[0], "SortName", "ProductionYear"],
sortOrder: [sortOrder[0]],
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
filters: filterBy as ItemFilter[],
// true is needed for merged versions
recursive: true,
imageTypeLimit: 1,
@@ -190,6 +224,7 @@ const Page = () => {
selectedTags,
sortBy,
sortOrder,
filterBy,
],
);
@@ -203,6 +238,7 @@ const Page = () => {
selectedTags,
sortBy,
sortOrder,
filterBy,
],
queryFn: fetchItems,
getNextPageParam: (lastPage, pages) => {
@@ -268,148 +304,167 @@ const Page = () => {
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const generalFilters = useFilterOptions();
const settings = useSettings();
const ListHeaderComponent = useCallback(
() => (
<View className=''>
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
display: "flex",
paddingHorizontal: 15,
paddingVertical: 16,
flexDirection: "row",
}}
data={[
{
key: "reset",
component: <ResetFiltersButton />,
},
{
key: "genre",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='genreFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Genres || [];
}}
set={setSelectedGenres}
values={selectedGenres}
title={t("library.filters.genres")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "year",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='yearFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Years || [];
}}
set={setSelectedYears}
values={selectedYears}
title={t("library.filters.years")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)}
/>
),
},
{
key: "tags",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='tagsFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Tags || [];
}}
set={setSelectedTags}
values={selectedTags}
title={t("library.filters.tags")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortBy",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='sortBy'
queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy}
values={sortBy}
title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
sortOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortOrder",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='sortOrder'
queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder}
values={sortOrder}
title={t("library.filters.sort_order")}
renderItemLabel={(item) =>
sortOrderOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
]}
renderItem={({ item }) => item.component}
keyExtractor={(item) => item.key}
/>
</View>
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
display: "flex",
paddingHorizontal: 15,
paddingVertical: 16,
flexDirection: "row",
}}
data={[
{
key: "reset",
component: <ResetFiltersButton />,
},
{
key: "genre",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='genreFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Genres || [];
}}
set={setSelectedGenres}
values={selectedGenres}
title={t("library.filters.genres")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "year",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='yearFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Years || [];
}}
set={setSelectedYears}
values={selectedYears}
title={t("library.filters.years")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)}
/>
),
},
{
key: "tags",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='tagsFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Tags || [];
}}
set={setSelectedTags}
values={selectedTags}
title={t("library.filters.tags")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortBy",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='sortBy'
queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy}
values={sortBy}
title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
sortOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortOrder",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='sortOrder'
queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder}
values={sortOrder}
title={t("library.filters.sort_order")}
renderItemLabel={(item) =>
sortOrderOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "filterOptions",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='filters'
queryFn={async () => generalFilters.map((s) => s.key)}
set={setFilter}
values={filterBy}
title={t("library.filters.filter_by")}
renderItemLabel={(item) =>
generalFilters.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
]}
renderItem={({ item }) => item.component}
keyExtractor={(item) => item.key}
/>
),
[
libraryId,
@@ -426,6 +481,9 @@ const Page = () => {
sortOrder,
setSortOrder,
isFetching,
filterBy,
setFilter,
settings,
],
);
@@ -453,7 +511,6 @@ const Page = () => {
renderItem={renderItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
estimatedItemSize={244}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {

View File

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

View File

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

View File

@@ -0,0 +1,87 @@
import {
createMaterialTopTabNavigator,
MaterialTopTabNavigationEventMap,
MaterialTopTabNavigationOptions,
} from "@react-navigation/material-top-tabs";
import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
import { useTranslation } from "react-i18next";
const { Navigator } = createMaterialTopTabNavigator();
const TAB_LABEL_FONT_SIZE = 13;
const TAB_ITEM_HORIZONTAL_PADDING = 18;
const TAB_ITEM_MIN_WIDTH = 110;
export const Tab = withLayoutContext<
MaterialTopTabNavigationOptions,
typeof Navigator,
TabNavigationState<ParamListBase>,
MaterialTopTabNavigationEventMap
>(Navigator);
const Layout = () => {
const { libraryId } = useLocalSearchParams<{ libraryId: string }>();
const { t } = useTranslation();
return (
<>
<Stack.Screen
options={{
title: t("music.title"),
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Tab
initialRouteName='suggestions'
keyboardDismissMode='none'
screenOptions={{
tabBarBounces: true,
tabBarLabelStyle: {
fontSize: TAB_LABEL_FONT_SIZE,
fontWeight: "600",
flexWrap: "nowrap",
},
tabBarItemStyle: {
width: "auto",
minWidth: TAB_ITEM_MIN_WIDTH,
paddingHorizontal: TAB_ITEM_HORIZONTAL_PADDING,
},
tabBarStyle: { backgroundColor: "black" },
animationEnabled: true,
lazy: true,
swipeEnabled: true,
tabBarIndicatorStyle: { backgroundColor: "#9334E9" },
tabBarScrollEnabled: true,
}}
>
<Tab.Screen
name='suggestions'
initialParams={{ libraryId }}
options={{ title: t("music.tabs.suggestions") }}
/>
<Tab.Screen
name='albums'
initialParams={{ libraryId }}
options={{ title: t("music.tabs.albums") }}
/>
<Tab.Screen
name='artists'
initialParams={{ libraryId }}
options={{ title: t("music.tabs.artists") }}
/>
<Tab.Screen
name='playlists'
initialParams={{ libraryId }}
options={{ title: t("music.tabs.playlists") }}
/>
</Tab>
</>
);
};
export default Layout;

View File

@@ -0,0 +1,138 @@
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useRoute } from "@react-navigation/native";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Dimensions, RefreshControl, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
const ITEMS_PER_PAGE = 40;
export default function AlbumsScreen() {
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
const route = useRoute<any>();
const libraryId =
(Array.isArray(localParams.libraryId)
? localParams.libraryId[0]
: localParams.libraryId) ?? route?.params?.libraryId;
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const {
data,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
} = useInfiniteQuery({
queryKey: ["music-albums", libraryId, user?.Id],
queryFn: async ({ pageParam = 0 }) => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: libraryId,
includeItemTypes: ["MusicAlbum"],
sortBy: ["SortName"],
sortOrder: ["Ascending"],
limit: ITEMS_PER_PAGE,
startIndex: pageParam,
recursive: true,
});
return {
items: response.data.Items || [],
totalCount: response.data.TotalRecordCount || 0,
startIndex: pageParam,
};
},
getNextPageParam: (lastPage) => {
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
return nextStart < lastPage.totalCount ? nextStart : undefined;
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!libraryId,
});
const albums = useMemo(() => {
return data?.pages.flatMap((page) => page.items) || [];
}, [data]);
const numColumns = 2;
const screenWidth = Dimensions.get("window").width;
const gap = 12;
const padding = 16;
const itemWidth =
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
const handleEndReached = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isLoading) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
if (albums.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Text className='text-neutral-500'>{t("music.no_albums")}</Text>
</View>
);
}
return (
<View className='flex-1 bg-black'>
<FlashList
data={albums}
numColumns={numColumns}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
paddingTop: 16,
paddingHorizontal: padding,
}}
refreshControl={
<RefreshControl
refreshing={false}
onRefresh={refetch}
tintColor='#9334E9'
/>
}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
renderItem={({ item, index }) => (
<View
style={{
width: itemWidth,
marginRight: index % numColumns === 0 ? gap : 0,
marginBottom: gap,
}}
>
<MusicAlbumCard album={item} width={itemWidth} />
</View>
)}
keyExtractor={(item) => item.Id!}
ListFooterComponent={
isFetchingNextPage ? (
<View className='py-4'>
<Loader />
</View>
) : null
}
/>
</View>
);
}

View File

@@ -0,0 +1,175 @@
import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api";
import { useRoute } from "@react-navigation/native";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Dimensions, RefreshControl, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { MusicArtistCard } from "@/components/music/MusicArtistCard";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
// Web uses Limit=100
const ITEMS_PER_PAGE = 100;
export default function ArtistsScreen() {
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
const route = useRoute<any>();
const libraryId =
(Array.isArray(localParams.libraryId)
? localParams.libraryId[0]
: localParams.libraryId) ?? route?.params?.libraryId;
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const isReady = Boolean(api && user?.Id && libraryId);
const {
data,
isLoading,
isError,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
} = useInfiniteQuery({
queryKey: ["music-artists", libraryId, user?.Id],
queryFn: async ({ pageParam = 0 }) => {
const response = await getArtistsApi(api!).getArtists({
userId: user?.Id,
parentId: libraryId,
sortBy: ["SortName"],
sortOrder: ["Ascending"],
fields: ["PrimaryImageAspectRatio", "SortName"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
limit: ITEMS_PER_PAGE,
startIndex: pageParam,
});
return {
items: response.data.Items || [],
totalCount: response.data.TotalRecordCount || 0,
startIndex: pageParam,
};
},
getNextPageParam: (lastPage) => {
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
return nextStart < lastPage.totalCount ? nextStart : undefined;
},
initialPageParam: 0,
enabled: isReady,
});
const artists = useMemo(() => {
return data?.pages.flatMap((page) => page.items) || [];
}, [data]);
const numColumns = 3;
const screenWidth = Dimensions.get("window").width;
const gap = 12;
const padding = 16;
const itemWidth =
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
const handleEndReached = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
if (!api || !user?.Id) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
if (!libraryId) {
return (
<View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'>
Missing music library id.
</Text>
</View>
);
}
// Only show loading if we have no cached data to display
if (isLoading && artists.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
// Only show error if we have no cached data to display
// This allows offline access to previously cached artists
if (isError && artists.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'>
Failed to load artists: {(error as Error)?.message || "Unknown error"}
</Text>
</View>
);
}
if (artists.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Text className='text-neutral-500'>{t("music.no_artists")}</Text>
</View>
);
}
return (
<View className='flex-1 bg-black'>
<FlashList
data={artists}
numColumns={numColumns}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
paddingTop: 16,
paddingHorizontal: padding,
}}
refreshControl={
<RefreshControl
refreshing={false}
onRefresh={refetch}
tintColor='#9334E9'
/>
}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
renderItem={({ item, index }) => (
<View
style={{
width: itemWidth,
marginRight: index % numColumns !== numColumns - 1 ? gap : 0,
marginBottom: gap,
}}
>
<MusicArtistCard artist={item} size={itemWidth} />
</View>
)}
keyExtractor={(item) => item.Id!}
ListFooterComponent={
isFetchingNextPage ? (
<View className='py-4'>
<Loader />
</View>
) : null
}
/>
</View>
);
}

View File

@@ -0,0 +1,215 @@
import { Ionicons } from "@expo/vector-icons";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useNavigation, useRoute } from "@react-navigation/native";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Dimensions,
RefreshControl,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { MusicPlaylistCard } from "@/components/music/MusicPlaylistCard";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
const ITEMS_PER_PAGE = 40;
export default function PlaylistsScreen() {
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
const route = useRoute<any>();
const navigation = useNavigation();
const libraryId =
(Array.isArray(localParams.libraryId)
? localParams.libraryId[0]
: localParams.libraryId) ?? route?.params?.libraryId;
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [createModalOpen, setCreateModalOpen] = useState(false);
const isReady = Boolean(api && user?.Id && libraryId);
useLayoutEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity
onPress={() => setCreateModalOpen(true)}
className='mr-4'
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name='add' size={28} color='white' />
</TouchableOpacity>
),
});
}, [navigation]);
const {
data,
isLoading,
isError,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
} = useInfiniteQuery({
queryKey: ["music-playlists", libraryId, user?.Id],
queryFn: async ({ pageParam = 0 }) => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
includeItemTypes: ["Playlist"],
sortBy: ["SortName"],
sortOrder: ["Ascending"],
limit: ITEMS_PER_PAGE,
startIndex: pageParam,
recursive: true,
mediaTypes: ["Audio"],
});
return {
items: response.data.Items || [],
totalCount: response.data.TotalRecordCount || 0,
startIndex: pageParam,
};
},
getNextPageParam: (lastPage) => {
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
return nextStart < lastPage.totalCount ? nextStart : undefined;
},
initialPageParam: 0,
enabled: isReady,
});
const playlists = useMemo(() => {
return data?.pages.flatMap((page) => page.items) || [];
}, [data]);
const numColumns = 2;
const screenWidth = Dimensions.get("window").width;
const gap = 12;
const padding = 16;
const itemWidth =
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
const handleEndReached = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
if (!api || !user?.Id) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
if (!libraryId) {
return (
<View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'>
Missing music library id.
</Text>
</View>
);
}
// Only show loading if we have no cached data to display
if (isLoading && playlists.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
// Only show error if we have no cached data to display
// This allows offline access to previously cached playlists
if (isError && playlists.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'>
Failed to load playlists:{" "}
{(error as Error)?.message || "Unknown error"}
</Text>
</View>
);
}
if (playlists.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Text className='text-neutral-500 mb-4'>{t("music.no_playlists")}</Text>
<TouchableOpacity
onPress={() => setCreateModalOpen(true)}
className='flex-row items-center bg-purple-600 px-6 py-3 rounded-full'
>
<Ionicons name='add' size={20} color='white' />
<Text className='text-white font-semibold ml-2'>
{t("music.playlists.create_playlist")}
</Text>
</TouchableOpacity>
<CreatePlaylistModal
open={createModalOpen}
setOpen={setCreateModalOpen}
/>
</View>
);
}
return (
<View className='flex-1 bg-black'>
<FlashList
data={playlists}
numColumns={numColumns}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
paddingTop: 16,
paddingHorizontal: padding,
}}
refreshControl={
<RefreshControl
refreshing={false}
onRefresh={refetch}
tintColor='#9334E9'
/>
}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
renderItem={({ item, index }) => (
<View
style={{
width: itemWidth,
marginRight: index % numColumns === 0 ? gap : 0,
marginBottom: gap,
}}
>
<MusicPlaylistCard playlist={item} width={itemWidth} />
</View>
)}
keyExtractor={(item) => item.Id!}
ListFooterComponent={
isFetchingNextPage ? (
<View className='py-4'>
<Loader />
</View>
) : null
}
/>
<CreatePlaylistModal
open={createModalOpen}
setOpen={setCreateModalOpen}
/>
</View>
);
}

View File

@@ -0,0 +1,333 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useRoute } from "@react-navigation/native";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { RefreshControl, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { writeDebugLog } from "@/utils/log";
export default function SuggestionsScreen() {
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
const route = useRoute<any>();
const libraryId =
(Array.isArray(localParams.libraryId)
? localParams.libraryId[0]
: localParams.libraryId) ?? route?.params?.libraryId;
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
setSelectedTrack(track);
setTrackOptionsOpen(true);
}, []);
const handleAddToPlaylist = useCallback(() => {
setPlaylistPickerOpen(true);
}, []);
const handleCreateNewPlaylist = useCallback(() => {
setCreatePlaylistOpen(true);
}, []);
const isReady = Boolean(api && user?.Id && libraryId);
writeDebugLog("Music suggestions params", {
libraryId,
localParams,
routeParams: route?.params,
isReady,
});
// Latest audio - uses the same endpoint as web: /Users/{userId}/Items/Latest
// This returns the most recently added albums
const {
data: latestAlbums,
isLoading: loadingLatest,
isError: isLatestError,
error: latestError,
refetch: refetchLatest,
} = useQuery({
queryKey: ["music-latest", libraryId, user?.Id],
queryFn: async () => {
// Prefer the exact endpoint the Web client calls (HAR):
// /Users/{userId}/Items/Latest?IncludeItemTypes=Audio&ParentId=...
// IMPORTANT: must use api.get(...) (not axiosInstance.get(fullUrl)) so the auth header is attached.
const res = await api!.get<BaseItemDto[]>(
`/Users/${user!.Id}/Items/Latest`,
{
params: {
IncludeItemTypes: "Audio",
Limit: 20,
Fields: "PrimaryImageAspectRatio",
ParentId: libraryId,
ImageTypeLimit: 1,
EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
EnableTotalRecordCount: false,
},
},
);
if (Array.isArray(res.data) && res.data.length > 0) {
return res.data;
}
// Fallback: ask for albums directly via /Items (more reliable across server variants)
const fallback = await getItemsApi(api!).getItems({
userId: user!.Id,
parentId: libraryId,
includeItemTypes: ["MusicAlbum"],
sortBy: ["DateCreated"],
sortOrder: ["Descending"],
limit: 20,
recursive: true,
fields: ["PrimaryImageAspectRatio", "SortName"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
enableTotalRecordCount: false,
});
return fallback.data.Items || [];
},
enabled: isReady,
});
// Recently played - matches web: SortBy=DatePlayed, Filters=IsPlayed
const {
data: recentlyPlayed,
isLoading: loadingRecentlyPlayed,
isError: isRecentlyPlayedError,
error: recentlyPlayedError,
refetch: refetchRecentlyPlayed,
} = useQuery({
queryKey: ["music-recently-played", libraryId, user?.Id],
queryFn: async () => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: libraryId,
includeItemTypes: ["Audio"],
sortBy: ["DatePlayed"],
sortOrder: ["Descending"],
limit: 10,
recursive: true,
fields: ["PrimaryImageAspectRatio", "SortName"],
filters: ["IsPlayed"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
enableTotalRecordCount: false,
});
return response.data.Items || [];
},
enabled: isReady,
});
// Frequently played - matches web: SortBy=PlayCount, Filters=IsPlayed
const {
data: frequentlyPlayed,
isLoading: loadingFrequent,
isError: isFrequentError,
error: frequentError,
refetch: refetchFrequent,
} = useQuery({
queryKey: ["music-frequently-played", libraryId, user?.Id],
queryFn: async () => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: libraryId,
includeItemTypes: ["Audio"],
sortBy: ["PlayCount"],
sortOrder: ["Descending"],
limit: 10,
recursive: true,
fields: ["PrimaryImageAspectRatio", "SortName"],
filters: ["IsPlayed"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
enableTotalRecordCount: false,
});
return response.data.Items || [];
},
enabled: isReady,
});
const isLoading = loadingLatest || loadingRecentlyPlayed || loadingFrequent;
const handleRefresh = useCallback(() => {
refetchLatest();
refetchRecentlyPlayed();
refetchFrequent();
}, [refetchLatest, refetchRecentlyPlayed, refetchFrequent]);
const sections = useMemo(() => {
const result: {
title: string;
data: BaseItemDto[];
type: "albums" | "tracks";
}[] = [];
// Latest albums section
if (latestAlbums && latestAlbums.length > 0) {
result.push({
title: t("music.recently_added"),
data: latestAlbums,
type: "albums",
});
}
// Recently played tracks
if (recentlyPlayed && recentlyPlayed.length > 0) {
result.push({
title: t("music.recently_played"),
data: recentlyPlayed,
type: "tracks",
});
}
// Frequently played tracks
if (frequentlyPlayed && frequentlyPlayed.length > 0) {
result.push({
title: t("music.frequently_played"),
data: frequentlyPlayed,
type: "tracks",
});
}
return result;
}, [latestAlbums, frequentlyPlayed, recentlyPlayed, t]);
if (!api || !user?.Id) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
if (!libraryId) {
return (
<View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'>
Missing music library id.
</Text>
</View>
);
}
// Only show loading if we have no cached data to display
if (isLoading && sections.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
// Only show error if we have no cached data to display
// This allows offline access to previously cached suggestions
if (
(isLatestError || isRecentlyPlayedError || isFrequentError) &&
sections.length === 0
) {
const msg =
(latestError as Error | undefined)?.message ||
(recentlyPlayedError as Error | undefined)?.message ||
(frequentError as Error | undefined)?.message ||
"Unknown error";
return (
<View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'>
Failed to load music: {msg}
</Text>
</View>
);
}
if (sections.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Text className='text-neutral-500'>{t("music.no_suggestions")}</Text>
</View>
);
}
return (
<View className='flex-1 bg-black'>
<FlashList
data={sections}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
paddingTop: 16,
}}
refreshControl={
<RefreshControl
refreshing={false}
onRefresh={handleRefresh}
tintColor='#9334E9'
/>
}
renderItem={({ item: section }) => (
<View className='mb-6'>
<Text className='text-lg font-bold px-4 mb-3'>{section.title}</Text>
{section.type === "albums" ? (
<HorizontalScroll
data={section.data}
height={200}
keyExtractor={(item) => item.Id!}
renderItem={(item) => <MusicAlbumCard album={item} />}
/>
) : (
section.data
.slice(0, 5)
.map((track, index, _tracks) => (
<MusicTrackItem
key={track.Id}
track={track}
index={index + 1}
queue={section.data}
onOptionsPress={handleTrackOptionsPress}
/>
))
)}
</View>
)}
keyExtractor={(item) => item.title}
/>
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={selectedTrack}
onAddToPlaylist={handleAddToPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={selectedTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={selectedTrack?.Id}
/>
</View>
);
}

View File

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

View File

@@ -24,8 +24,6 @@ import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { Tag } from "@/components/GenreTags";
import { ItemCardText } from "@/components/ItemCardText";
import {
JellyseerrSearchSort,
@@ -33,12 +31,15 @@ import {
} from "@/components/jellyseerr/JellyseerrIndexPage";
import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster";
import { DiscoverFilters } from "@/components/search/DiscoverFilters";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
import { createStreamystatsApi } from "@/utils/streamystats";
type SearchType = "Library" | "Discover";
@@ -117,6 +118,54 @@ export default function search() {
return (searchApi.data.Items as BaseItemDto[]) || [];
}
if (searchEngine === "Streamystats") {
if (!settings?.streamyStatsServerUrl || !api.accessToken) {
return [];
}
const streamyStatsApi = createStreamystatsApi({
serverUrl: settings.streamyStatsServerUrl,
jellyfinToken: api.accessToken,
});
const typeMap: Record<BaseItemKind, string> = {
Movie: "movies",
Series: "series",
Episode: "episodes",
Person: "actors",
BoxSet: "movies",
Audio: "audio",
} as Record<BaseItemKind, string>;
const searchType = types.length === 1 ? typeMap[types[0]] : "media";
const response = await streamyStatsApi.searchIds(
query,
searchType as "movies" | "series" | "episodes" | "actors" | "media",
10,
);
const allIds: string[] = [
...(response.data.movies || []),
...(response.data.series || []),
...(response.data.episodes || []),
...(response.data.actors || []),
...(response.data.audio || []),
];
if (!allIds.length) {
return [];
}
const itemsResponse = await getItemsApi(api).getItems({
ids: allIds,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
});
return (itemsResponse.data.Items as BaseItemDto[]) || [];
}
// Marlin search
if (!settings?.marlinServerUrl) {
return [];
}
@@ -141,12 +190,11 @@ export default function search() {
});
return (response2.data.Items as BaseItemDto[]) || [];
} catch (error) {
console.error("Error during search:", error);
return []; // Ensure an empty array is returned in case of an error
} catch (_error) {
return [];
}
},
[api, searchEngine, settings],
[api, searchEngine, settings, user?.Id],
);
type HeaderSearchBarRef = {
@@ -284,67 +332,30 @@ export default function search() {
)}
<View
className='flex flex-col'
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}}
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
{jellyseerrApi && (
<ScrollView
horizontal
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
>
<TouchableOpacity onPress={() => setSearchType("Library")}>
<Tag
text={t("search.library")}
textClass='p-1'
className={
searchType === "Library" ? "bg-purple-600" : undefined
}
/>
</TouchableOpacity>
<TouchableOpacity onPress={() => setSearchType("Discover")}>
<Tag
text={t("search.discover")}
textClass='p-1'
className={
searchType === "Discover" ? "bg-purple-600" : undefined
}
/>
</TouchableOpacity>
<View className='pl-4 pr-4 flex flex-row'>
<SearchTabButtons
searchType={searchType}
setSearchType={setSearchType}
t={t}
/>
{searchType === "Discover" &&
!loading &&
noResults &&
debouncedSearch.length > 0 && (
<View className='flex flex-row justify-end items-center space-x-1'>
<FilterButton
id={searchFilterId}
queryKey='jellyseerr_search'
queryFn={async () =>
Object.keys(JellyseerrSearchSort).filter((v) =>
Number.isNaN(Number(v)),
)
}
set={(value) => setJellyseerrOrderBy(value[0])}
values={[jellyseerrOrderBy]}
title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
}
disableSearch={true}
/>
<FilterButton
id={orderFilterId}
queryKey='jellysearr_search'
queryFn={async () => ["asc", "desc"]}
set={(value) => setJellyseerrSortOrder(value[0])}
values={[jellyseerrSortOrder]}
title={t("library.filters.sort_order")}
renderItemLabel={(item) => t(`library.filters.${item}`)}
disableSearch={true}
/>
</View>
<DiscoverFilters
searchFilterId={searchFilterId}
orderFilterId={orderFilterId}
jellyseerrOrderBy={jellyseerrOrderBy}
setJellyseerrOrderBy={setJellyseerrOrderBy}
jellyseerrSortOrder={jellyseerrSortOrder}
setJellyseerrSortOrder={setJellyseerrSortOrder}
t={t}
/>
)}
</ScrollView>
</View>
)}
<View className='mt-2'>

View File

@@ -0,0 +1,297 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { FlashList } from "@shopify/flash-list";
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Alert,
RefreshControl,
TouchableOpacity,
useWindowDimensions,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
import { ItemPoster } from "@/components/posters/ItemPoster";
import { useOrientation } from "@/hooks/useOrientation";
import {
useDeleteWatchlist,
useRemoveFromWatchlist,
} from "@/hooks/useWatchlistMutations";
import {
useWatchlistDetailQuery,
useWatchlistItemsQuery,
} from "@/hooks/useWatchlists";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { userAtom } from "@/providers/JellyfinProvider";
export default function WatchlistDetailScreen() {
const { t } = useTranslation();
const router = useRouter();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
const user = useAtomValue(userAtom);
const { width: screenWidth } = useWindowDimensions();
const { orientation } = useOrientation();
const watchlistIdNum = watchlistId
? Number.parseInt(watchlistId, 10)
: undefined;
const nrOfCols = useMemo(() => {
if (screenWidth < 300) return 2;
if (screenWidth < 500) return 3;
if (screenWidth < 800) return 5;
if (screenWidth < 1000) return 6;
if (screenWidth < 1500) return 7;
return 6;
}, [screenWidth]);
const {
data: watchlist,
isLoading: watchlistLoading,
refetch: refetchWatchlist,
} = useWatchlistDetailQuery(watchlistIdNum);
const {
data: items,
isLoading: itemsLoading,
refetch: refetchItems,
} = useWatchlistItemsQuery(watchlistIdNum);
const deleteWatchlist = useDeleteWatchlist();
const removeFromWatchlist = useRemoveFromWatchlist();
const [refreshing, setRefreshing] = useState(false);
const isOwner = useMemo(
() => watchlist?.userId === user?.Id,
[watchlist?.userId, user?.Id],
);
// Set up header
useEffect(() => {
navigation.setOptions({
headerTitle: watchlist?.name || "",
headerLeft: () => <HeaderBackButton />,
headerRight: isOwner
? () => (
<View className='flex-row gap-2'>
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/(tabs)/(watchlists)/edit/${watchlistId}`)
}
className='p-2'
>
<Ionicons name='pencil' size={20} color='white' />
</TouchableOpacity>
<TouchableOpacity onPress={handleDelete} className='p-2'>
<Ionicons name='trash-outline' size={20} color='#ef4444' />
</TouchableOpacity>
</View>
)
: undefined,
});
}, [navigation, watchlist?.name, isOwner, watchlistId]);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await Promise.all([refetchWatchlist(), refetchItems()]);
setRefreshing(false);
}, [refetchWatchlist, refetchItems]);
const handleDelete = useCallback(() => {
Alert.alert(
t("watchlists.delete_confirm_title"),
t("watchlists.delete_confirm_message", { name: watchlist?.name }),
[
{ text: t("watchlists.cancel_button"), style: "cancel" },
{
text: t("watchlists.delete_button"),
style: "destructive",
onPress: async () => {
if (watchlistIdNum) {
await deleteWatchlist.mutateAsync(watchlistIdNum);
router.back();
}
},
},
],
);
}, [deleteWatchlist, watchlistIdNum, watchlist?.name, router, t]);
const handleRemoveItem = useCallback(
(item: BaseItemDto) => {
if (!watchlistIdNum || !item.Id) return;
Alert.alert(
t("watchlists.remove_item_title"),
t("watchlists.remove_item_message", { name: item.Name }),
[
{ text: t("watchlists.cancel_button"), style: "cancel" },
{
text: t("watchlists.remove_button"),
style: "destructive",
onPress: async () => {
await removeFromWatchlist.mutateAsync({
watchlistId: watchlistIdNum,
itemId: item.Id!,
watchlistName: watchlist?.name,
});
},
},
],
);
},
[removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<TouchableItemRouter
key={item.Id}
style={{
width: "100%",
marginBottom: 4,
}}
item={item}
onLongPress={isOwner ? () => handleRemoveItem(item) : undefined}
>
<View
style={{
alignSelf:
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP
? index % nrOfCols === 0
? "flex-end"
: (index + 1) % nrOfCols === 0
? "flex-start"
: "center"
: "center",
width: "89%",
}}
>
<ItemPoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
),
[isOwner, handleRemoveItem, orientation, nrOfCols],
);
const ListHeader = useMemo(
() =>
watchlist ? (
<View className='px-4 pt-4 pb-6 mb-4 border-b border-neutral-800'>
{watchlist.description && (
<Text className='text-neutral-400 mb-2'>
{watchlist.description}
</Text>
)}
<View className='flex-row items-center gap-4'>
<View className='flex-row items-center gap-1'>
<Ionicons name='film-outline' size={14} color='#9ca3af' />
<Text className='text-neutral-400 text-sm'>
{items?.length ?? 0}{" "}
{(items?.length ?? 0) === 1
? t("watchlists.item")
: t("watchlists.items")}
</Text>
</View>
<View className='flex-row items-center gap-1'>
<Ionicons
name={
watchlist.isPublic ? "globe-outline" : "lock-closed-outline"
}
size={14}
color='#9ca3af'
/>
<Text className='text-neutral-400 text-sm'>
{watchlist.isPublic
? t("watchlists.public")
: t("watchlists.private")}
</Text>
</View>
{!isOwner && (
<Text className='text-neutral-500 text-sm'>
{t("watchlists.by_owner")}
</Text>
)}
</View>
</View>
) : null,
[watchlist, items?.length, isOwner, t],
);
const EmptyComponent = useMemo(
() => (
<View className='flex-1 items-center justify-center px-8 py-16'>
<Ionicons name='film-outline' size={48} color='#4b5563' />
<Text className='text-neutral-400 text-center mt-4'>
{t("watchlists.empty_watchlist")}
</Text>
{isOwner && (
<Text className='text-neutral-500 text-center mt-2 text-sm'>
{t("watchlists.empty_watchlist_hint")}
</Text>
)}
</View>
),
[isOwner, t],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
if (watchlistLoading || itemsLoading) {
return (
<View className='flex-1 items-center justify-center'>
<ActivityIndicator size='large' />
</View>
);
}
if (!watchlist) {
return (
<View className='flex-1 items-center justify-center px-8'>
<Text className='text-lg text-neutral-400'>
{t("watchlists.not_found")}
</Text>
</View>
);
}
return (
<FlashList
key={orientation}
data={items ?? []}
numColumns={nrOfCols}
contentInsetAdjustmentBehavior='automatic'
ListHeaderComponent={ListHeader}
ListEmptyComponent={EmptyComponent}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
renderItem={renderItem}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
);
}

View File

@@ -0,0 +1,74 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity } from "react-native";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
export default function WatchlistsLayout() {
const { t } = useTranslation();
const router = useRouter();
const streamystatsEnabled = useStreamystatsEnabled();
return (
<Stack>
<Stack.Screen
name='index'
options={{
headerShown: !Platform.isTV,
headerTitle: t("watchlists.title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerRight: streamystatsEnabled
? () => (
<TouchableOpacity
onPress={() =>
router.push("/(auth)/(tabs)/(watchlists)/create")
}
className='p-1.5'
>
<Ionicons name='add' size={24} color='white' />
</TouchableOpacity>
)
: undefined,
}}
/>
<Stack.Screen
name='[watchlistId]'
options={{
title: "",
headerShown: true,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
<Stack.Screen
name='create'
options={{
title: t("watchlists.create_title"),
presentation: "modal",
headerShown: true,
headerStyle: { backgroundColor: "#171717" },
headerTintColor: "white",
contentStyle: { backgroundColor: "#171717" },
}}
/>
<Stack.Screen
name='edit/[watchlistId]'
options={{
title: t("watchlists.edit_title"),
presentation: "modal",
headerShown: true,
headerStyle: { backgroundColor: "#171717" },
headerTintColor: "white",
contentStyle: { backgroundColor: "#171717" },
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}
</Stack>
);
}

View File

@@ -0,0 +1,221 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
ScrollView,
Switch,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useCreateWatchlist } from "@/hooks/useWatchlistMutations";
import type {
StreamystatsWatchlistAllowedItemType,
StreamystatsWatchlistSortOrder,
} from "@/utils/streamystats/types";
const ITEM_TYPES: Array<{
value: StreamystatsWatchlistAllowedItemType;
label: string;
}> = [
{ value: null, label: "All Types" },
{ value: "Movie", label: "Movies Only" },
{ value: "Series", label: "Series Only" },
{ value: "Episode", label: "Episodes Only" },
];
const SORT_OPTIONS: Array<{
value: StreamystatsWatchlistSortOrder;
label: string;
}> = [
{ value: "custom", label: "Custom Order" },
{ value: "name", label: "Name" },
{ value: "dateAdded", label: "Date Added" },
{ value: "releaseDate", label: "Release Date" },
];
export default function CreateWatchlistScreen() {
const { t } = useTranslation();
const router = useRouter();
const insets = useSafeAreaInsets();
const createWatchlist = useCreateWatchlist();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [isPublic, setIsPublic] = useState(false);
const [allowedItemType, setAllowedItemType] =
useState<StreamystatsWatchlistAllowedItemType>(null);
const [defaultSortOrder, setDefaultSortOrder] =
useState<StreamystatsWatchlistSortOrder>("custom");
const handleCreate = useCallback(async () => {
if (!name.trim()) return;
try {
await createWatchlist.mutateAsync({
name: name.trim(),
description: description.trim() || undefined,
isPublic,
allowedItemType,
defaultSortOrder,
});
router.back();
} catch {
// Error handled by mutation
}
}, [
name,
description,
isPublic,
allowedItemType,
defaultSortOrder,
createWatchlist,
router,
]);
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
className='flex-1'
style={{ backgroundColor: "#171717" }}
>
<ScrollView
className='flex-1'
contentContainerStyle={{
paddingBottom: insets.bottom + 20,
}}
keyboardShouldPersistTaps='handled'
>
{/* Name */}
<View className='px-4 py-4'>
<Text className='text-sm font-medium text-neutral-400 mb-2'>
{t("watchlists.name_label")} *
</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder={t("watchlists.name_placeholder")}
placeholderTextColor='#6b7280'
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
autoFocus
/>
</View>
{/* Description */}
<View className='px-4 py-4'>
<Text className='text-sm font-medium text-neutral-400 mb-2'>
{t("watchlists.description_label")}
</Text>
<TextInput
value={description}
onChangeText={setDescription}
placeholder={t("watchlists.description_placeholder")}
placeholderTextColor='#6b7280'
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
multiline
numberOfLines={3}
textAlignVertical='top'
style={{ minHeight: 80 }}
/>
</View>
{/* Public Toggle */}
<View className='px-4 py-4 flex-row items-center justify-between'>
<View className='flex-1 mr-4'>
<Text className='text-base font-medium text-white'>
{t("watchlists.is_public_label")}
</Text>
<Text className='text-sm text-neutral-400 mt-1'>
{t("watchlists.is_public_description")}
</Text>
</View>
<Switch
value={isPublic}
onValueChange={setIsPublic}
trackColor={{ false: "#374151", true: "#7c3aed" }}
thumbColor={isPublic ? "#a78bfa" : "#9ca3af"}
/>
</View>
{/* Content Type */}
<View className='px-4 py-4'>
<Text className='text-sm font-medium text-neutral-400 mb-2'>
{t("watchlists.allowed_type_label")}
</Text>
<View className='flex-row flex-wrap gap-2'>
{ITEM_TYPES.map((type) => (
<TouchableOpacity
key={type.value ?? "all"}
onPress={() => setAllowedItemType(type.value)}
className={`px-4 py-2 rounded-lg ${allowedItemType === type.value ? "bg-purple-600" : "bg-neutral-800"}`}
>
<Text
className={
allowedItemType === type.value
? "text-white font-medium"
: "text-neutral-300"
}
>
{type.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* Sort Order */}
<View className='px-4 py-4'>
<Text className='text-sm font-medium text-neutral-400 mb-2'>
{t("watchlists.sort_order_label")}
</Text>
<View className='flex-row flex-wrap gap-2'>
{SORT_OPTIONS.map((sort) => (
<TouchableOpacity
key={sort.value}
onPress={() => setDefaultSortOrder(sort.value)}
className={`px-4 py-2 rounded-lg ${defaultSortOrder === sort.value ? "bg-purple-600" : "bg-neutral-800"}`}
>
<Text
className={
defaultSortOrder === sort.value
? "text-white font-medium"
: "text-neutral-300"
}
>
{sort.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* Create Button */}
<View className='px-4 pt-4'>
<Button
onPress={handleCreate}
disabled={!name.trim() || createWatchlist.isPending}
className={`py-3 ${!name.trim() ? "opacity-50" : ""}`}
>
{createWatchlist.isPending ? (
<ActivityIndicator color='white' />
) : (
<View className='flex-row items-center'>
<Ionicons name='add' size={20} color='white' />
<Text className='text-white font-semibold text-base'>
{t("watchlists.create_button")}
</Text>
</View>
)}
</Button>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}

View File

@@ -0,0 +1,273 @@
import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
ScrollView,
Switch,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useUpdateWatchlist } from "@/hooks/useWatchlistMutations";
import { useWatchlistDetailQuery } from "@/hooks/useWatchlists";
import type {
StreamystatsWatchlistAllowedItemType,
StreamystatsWatchlistSortOrder,
} from "@/utils/streamystats/types";
const ITEM_TYPES: Array<{
value: StreamystatsWatchlistAllowedItemType;
label: string;
}> = [
{ value: null, label: "All Types" },
{ value: "Movie", label: "Movies Only" },
{ value: "Series", label: "Series Only" },
{ value: "Episode", label: "Episodes Only" },
];
const SORT_OPTIONS: Array<{
value: StreamystatsWatchlistSortOrder;
label: string;
}> = [
{ value: "custom", label: "Custom Order" },
{ value: "name", label: "Name" },
{ value: "dateAdded", label: "Date Added" },
{ value: "releaseDate", label: "Release Date" },
];
export default function EditWatchlistScreen() {
const { t } = useTranslation();
const router = useRouter();
const insets = useSafeAreaInsets();
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
const watchlistIdNum = watchlistId
? Number.parseInt(watchlistId, 10)
: undefined;
const { data: watchlist, isLoading } =
useWatchlistDetailQuery(watchlistIdNum);
const updateWatchlist = useUpdateWatchlist();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [isPublic, setIsPublic] = useState(false);
const [allowedItemType, setAllowedItemType] =
useState<StreamystatsWatchlistAllowedItemType>(null);
const [defaultSortOrder, setDefaultSortOrder] =
useState<StreamystatsWatchlistSortOrder>("custom");
// Initialize form with watchlist data
useEffect(() => {
if (watchlist) {
setName(watchlist.name);
setDescription(watchlist.description ?? "");
setIsPublic(watchlist.isPublic);
setAllowedItemType(
(watchlist.allowedItemType as StreamystatsWatchlistAllowedItemType) ??
null,
);
setDefaultSortOrder(
(watchlist.defaultSortOrder as StreamystatsWatchlistSortOrder) ??
"custom",
);
}
}, [watchlist]);
const handleSave = useCallback(async () => {
if (!name.trim() || !watchlistIdNum) return;
try {
await updateWatchlist.mutateAsync({
watchlistId: watchlistIdNum,
data: {
name: name.trim(),
description: description.trim() || undefined,
isPublic,
allowedItemType,
defaultSortOrder,
},
});
router.back();
} catch {
// Error handled by mutation
}
}, [
name,
description,
isPublic,
allowedItemType,
defaultSortOrder,
watchlistIdNum,
updateWatchlist,
router,
]);
if (isLoading) {
return (
<View
className='flex-1 items-center justify-center'
style={{ backgroundColor: "#171717" }}
>
<ActivityIndicator size='large' />
</View>
);
}
if (!watchlist) {
return (
<View
className='flex-1 items-center justify-center px-8'
style={{ backgroundColor: "#171717" }}
>
<Text className='text-lg text-neutral-400'>
{t("watchlists.not_found")}
</Text>
</View>
);
}
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
className='flex-1'
style={{ backgroundColor: "#171717" }}
>
<ScrollView
className='flex-1'
contentContainerStyle={{
paddingBottom: insets.bottom + 20,
}}
keyboardShouldPersistTaps='handled'
>
{/* Name */}
<View className='px-4 py-4'>
<Text className='text-sm font-medium text-neutral-400 mb-2'>
{t("watchlists.name_label")} *
</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder={t("watchlists.name_placeholder")}
placeholderTextColor='#6b7280'
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
/>
</View>
{/* Description */}
<View className='px-4 py-4'>
<Text className='text-sm font-medium text-neutral-400 mb-2'>
{t("watchlists.description_label")}
</Text>
<TextInput
value={description}
onChangeText={setDescription}
placeholder={t("watchlists.description_placeholder")}
placeholderTextColor='#6b7280'
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
multiline
numberOfLines={3}
textAlignVertical='top'
style={{ minHeight: 80 }}
/>
</View>
{/* Public Toggle */}
<View className='px-4 py-4 flex-row items-center justify-between'>
<View className='flex-1 mr-4'>
<Text className='text-base font-medium text-white'>
{t("watchlists.is_public_label")}
</Text>
<Text className='text-sm text-neutral-400 mt-1'>
{t("watchlists.is_public_description")}
</Text>
</View>
<Switch
value={isPublic}
onValueChange={setIsPublic}
trackColor={{ false: "#374151", true: "#7c3aed" }}
thumbColor={isPublic ? "#a78bfa" : "#9ca3af"}
/>
</View>
{/* Content Type */}
<View className='px-4 py-4'>
<Text className='text-sm font-medium text-neutral-400 mb-2'>
{t("watchlists.allowed_type_label")}
</Text>
<View className='flex-row flex-wrap gap-2'>
{ITEM_TYPES.map((type) => (
<TouchableOpacity
key={type.value ?? "all"}
onPress={() => setAllowedItemType(type.value)}
className={`px-4 py-2 rounded-lg ${allowedItemType === type.value ? "bg-purple-600" : "bg-neutral-800"}`}
>
<Text
className={
allowedItemType === type.value
? "text-white font-medium"
: "text-neutral-300"
}
>
{type.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* Sort Order */}
<View className='px-4 py-4'>
<Text className='text-sm font-medium text-neutral-400 mb-2'>
{t("watchlists.sort_order_label")}
</Text>
<View className='flex-row flex-wrap gap-2'>
{SORT_OPTIONS.map((sort) => (
<TouchableOpacity
key={sort.value}
onPress={() => setDefaultSortOrder(sort.value)}
className={`px-4 py-2 rounded-lg ${defaultSortOrder === sort.value ? "bg-purple-600" : "bg-neutral-800"}`}
>
<Text
className={
defaultSortOrder === sort.value
? "text-white font-medium"
: "text-neutral-300"
}
>
{sort.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* Save Button */}
<View className='px-4 pt-4'>
<Button
onPress={handleSave}
disabled={!name.trim() || updateWatchlist.isPending}
className={`py-3 ${!name.trim() ? "opacity-50" : ""}`}
>
{updateWatchlist.isPending ? (
<ActivityIndicator color='white' />
) : (
<View className='flex-row items-center'>
<Ionicons name='checkmark' size={20} color='white' />
<Text className='text-white font-semibold text-base'>
{t("watchlists.save_button")}
</Text>
</View>
)}
</Button>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}

View File

@@ -0,0 +1,239 @@
import { Ionicons } from "@expo/vector-icons";
import { FlashList } from "@shopify/flash-list";
import { useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, RefreshControl, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import {
useStreamystatsEnabled,
useWatchlistsQuery,
} from "@/hooks/useWatchlists";
import { userAtom } from "@/providers/JellyfinProvider";
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
interface WatchlistCardProps {
watchlist: StreamystatsWatchlist;
isOwner: boolean;
onPress: () => void;
}
const WatchlistCard: React.FC<WatchlistCardProps> = ({
watchlist,
isOwner,
onPress,
}) => {
const { t } = useTranslation();
return (
<TouchableOpacity
onPress={onPress}
className='bg-neutral-900 rounded-xl p-4 mx-4 mb-3'
activeOpacity={0.7}
>
<View className='flex-row items-center justify-between mb-2'>
<Text className='text-lg font-semibold flex-1' numberOfLines={1}>
{watchlist.name}
</Text>
<View className='flex-row items-center gap-2'>
{isOwner && (
<View className='bg-purple-600/20 px-2 py-1 rounded'>
<Text className='text-purple-400 text-xs'>
{t("watchlists.you")}
</Text>
</View>
)}
<Ionicons
name={watchlist.isPublic ? "globe-outline" : "lock-closed-outline"}
size={16}
color='#9ca3af'
/>
</View>
</View>
{watchlist.description && (
<Text className='text-neutral-400 text-sm mb-2' numberOfLines={2}>
{watchlist.description}
</Text>
)}
<View className='flex-row items-center gap-4'>
<View className='flex-row items-center gap-1'>
<Ionicons name='film-outline' size={14} color='#9ca3af' />
<Text className='text-neutral-400 text-sm'>
{watchlist.itemCount ?? 0}{" "}
{(watchlist.itemCount ?? 0) === 1
? t("watchlists.item")
: t("watchlists.items")}
</Text>
</View>
{watchlist.allowedItemType && (
<View className='bg-neutral-800 px-2 py-0.5 rounded'>
<Text className='text-neutral-400 text-xs'>
{watchlist.allowedItemType}
</Text>
</View>
)}
</View>
</TouchableOpacity>
);
};
const EmptyState: React.FC<{ onCreatePress: () => void }> = ({
onCreatePress: _onCreatePress,
}) => {
const { t } = useTranslation();
return (
<View className='flex-1 items-center justify-center px-8'>
<Ionicons name='list-outline' size={64} color='#4b5563' />
<Text className='text-xl font-semibold mt-4 text-center'>
{t("watchlists.empty_title")}
</Text>
<Text className='text-neutral-400 text-center mt-2 mb-6'>
{t("watchlists.empty_description")}
</Text>
</View>
);
};
const NotConfiguredState: React.FC = () => {
const { t } = useTranslation();
const router = useRouter();
return (
<View className='flex-1 items-center justify-center px-8'>
<Ionicons name='settings-outline' size={64} color='#4b5563' />
<Text className='text-xl font-semibold mt-4 text-center'>
{t("watchlists.not_configured_title")}
</Text>
<Text className='text-neutral-400 text-center mt-2 mb-6'>
{t("watchlists.not_configured_description")}
</Text>
<Button
onPress={() =>
router.push(
"/(auth)/(tabs)/(home)/settings/plugins/streamystats/page",
)
}
className='px-6'
>
<Text className='font-semibold'>{t("watchlists.go_to_settings")}</Text>
</Button>
</View>
);
};
export default function WatchlistsScreen() {
const { t } = useTranslation();
const router = useRouter();
const insets = useSafeAreaInsets();
const user = useAtomValue(userAtom);
const streamystatsEnabled = useStreamystatsEnabled();
const { data: watchlists, isLoading, refetch } = useWatchlistsQuery();
const [refreshing, setRefreshing] = useState(false);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await refetch();
setRefreshing(false);
}, [refetch]);
const handleCreatePress = useCallback(() => {
router.push("/(auth)/(tabs)/(watchlists)/create");
}, [router]);
const handleWatchlistPress = useCallback(
(watchlistId: number) => {
router.push(`/(auth)/(tabs)/(watchlists)/${watchlistId}`);
},
[router],
);
// Separate watchlists into "mine" and "public"
const { myWatchlists, publicWatchlists } = useMemo(() => {
if (!watchlists) return { myWatchlists: [], publicWatchlists: [] };
const mine: StreamystatsWatchlist[] = [];
const pub: StreamystatsWatchlist[] = [];
for (const w of watchlists) {
if (w.userId === user?.Id) {
mine.push(w);
} else {
pub.push(w);
}
}
return { myWatchlists: mine, publicWatchlists: pub };
}, [watchlists, user?.Id]);
// Combine into sections for FlashList
const sections = useMemo(() => {
const result: Array<
| { type: "header"; title: string }
| { type: "watchlist"; data: StreamystatsWatchlist; isOwner: boolean }
> = [];
if (myWatchlists.length > 0) {
result.push({ type: "header", title: t("watchlists.my_watchlists") });
for (const w of myWatchlists) {
result.push({ type: "watchlist", data: w, isOwner: true });
}
}
if (publicWatchlists.length > 0) {
result.push({ type: "header", title: t("watchlists.public_watchlists") });
for (const w of publicWatchlists) {
result.push({ type: "watchlist", data: w, isOwner: false });
}
}
return result;
}, [myWatchlists, publicWatchlists, t]);
if (!streamystatsEnabled) {
return <NotConfiguredState />;
}
if (!isLoading && (!watchlists || watchlists.length === 0)) {
return <EmptyState onCreatePress={handleCreatePress} />;
}
return (
<FlashList
data={sections}
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingTop: Platform.OS === "android" ? 10 : 0,
paddingBottom: 100,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
renderItem={({ item }) => {
if (item.type === "header") {
return (
<Text className='text-lg font-bold px-4 pt-4 pb-2'>
{item.title}
</Text>
);
}
return (
<WatchlistCard
watchlist={item.data}
isOwner={item.isOwner}
onPress={() => handleWatchlistPress(item.data.id)}
/>
);
}}
getItemType={(item) => item.type}
/>
);
}

View File

@@ -10,8 +10,10 @@ import type {
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { Platform, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
import { Colors } from "@/constants/Colors";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
@@ -47,7 +49,7 @@ export default function TabLayout() {
);
return (
<>
<View style={{ flex: 1 }}>
<SystemBars hidden={false} style='light' />
<NativeTabs
sidebarAdaptable={false}
@@ -55,6 +57,7 @@ export default function TabLayout() {
backgroundColor: "#121212",
}}
tabBarActiveTintColor={Colors.primary}
activeIndicatorColor={"#392c3b"}
scrollEdgeAppearance='default'
>
<NativeTabs.Screen redirect name='index' />
@@ -70,10 +73,7 @@ export default function TabLayout() {
tabBarIcon:
Platform.OS === "android"
? (_e) => require("@/assets/icons/house.fill.png")
: ({ focused }) =>
focused
? { sfSymbol: "house.fill" }
: { sfSymbol: "house" },
: (_e) => ({ sfSymbol: "house.fill" }),
}}
/>
<NativeTabs.Screen
@@ -84,14 +84,12 @@ export default function TabLayout() {
})}
name='(search)'
options={{
role: "search",
title: t("tabs.search"),
tabBarIcon:
Platform.OS === "android"
? (_e) => require("@/assets/icons/magnifyingglass.png")
: ({ focused }) =>
focused
? { sfSymbol: "magnifyingglass" }
: { sfSymbol: "magnifyingglass" },
: (_e) => ({ sfSymbol: "magnifyingglass" }),
}}
/>
<NativeTabs.Screen
@@ -100,14 +98,20 @@ export default function TabLayout() {
title: t("tabs.favorites"),
tabBarIcon:
Platform.OS === "android"
? ({ focused }) =>
focused
? require("@/assets/icons/heart.fill.png")
: require("@/assets/icons/heart.png")
: ({ focused }) =>
focused
? { sfSymbol: "heart.fill" }
: { sfSymbol: "heart" },
? (_e) => require("@/assets/icons/heart.fill.png")
: (_e) => ({ sfSymbol: "heart.fill" }),
}}
/>
<NativeTabs.Screen
name='(watchlists)'
options={{
title: t("watchlists.title"),
tabBarItemHidden:
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab,
tabBarIcon:
Platform.OS === "android"
? (_e) => require("@/assets/icons/list.png")
: (_e) => ({ sfSymbol: "list.bullet.rectangle" }),
}}
/>
<NativeTabs.Screen
@@ -117,10 +121,7 @@ export default function TabLayout() {
tabBarIcon:
Platform.OS === "android"
? (_e) => require("@/assets/icons/server.rack.png")
: ({ focused }) =>
focused
? { sfSymbol: "rectangle.stack.fill" }
: { sfSymbol: "rectangle.stack" },
: (_e) => ({ sfSymbol: "rectangle.stack.fill" }),
}}
/>
<NativeTabs.Screen
@@ -131,13 +132,12 @@ export default function TabLayout() {
tabBarIcon:
Platform.OS === "android"
? (_e) => require("@/assets/icons/list.png")
: ({ focused }) =>
focused
? { sfSymbol: "list.dash.fill" }
: { sfSymbol: "list.dash" },
: (_e) => ({ sfSymbol: "list.dash.fill" }),
}}
/>
</NativeTabs>
</>
<MiniPlayerBar />
<MusicPlaybackEngine />
</View>
);
}

633
app/(auth)/now-playing.tsx Normal file
View File

@@ -0,0 +1,633 @@
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Dimensions,
Platform,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import DraggableFlatList, {
type RenderItemParams,
ScaleDecorator,
} from "react-native-draggable-flatlist";
import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Badge } from "@/components/Badge";
import { Text } from "@/components/common/Text";
import { apiAtom } from "@/providers/JellyfinProvider";
import {
type RepeatMode,
useMusicPlayer,
} from "@/providers/MusicPlayerProvider";
import { formatBitrate } from "@/utils/bitrate";
import { formatDuration } from "@/utils/time";
const formatFileSize = (bytes?: number | null) => {
if (!bytes) return null;
const sizes = ["B", "KB", "MB", "GB"];
if (bytes === 0) return "0 B";
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
};
const formatSampleRate = (sampleRate?: number | null) => {
if (!sampleRate) return null;
return `${(sampleRate / 1000).toFixed(1)} kHz`;
};
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const ARTWORK_SIZE = SCREEN_WIDTH - 80;
type ViewMode = "player" | "queue";
export default function NowPlayingScreen() {
const [api] = useAtom(apiAtom);
const router = useRouter();
const insets = useSafeAreaInsets();
const [viewMode, setViewMode] = useState<ViewMode>("player");
const {
currentTrack,
queue,
queueIndex,
isPlaying,
isLoading,
progress,
duration,
repeatMode,
shuffleEnabled,
mediaSource,
isTranscoding,
togglePlayPause,
next,
previous,
seek,
setRepeatMode,
toggleShuffle,
jumpToIndex,
removeFromQueue,
reorderQueue,
stop,
} = useMusicPlayer();
const sliderProgress = useSharedValue(0);
const sliderMin = useSharedValue(0);
const sliderMax = useSharedValue(1);
useEffect(() => {
sliderProgress.value = progress;
}, [progress, sliderProgress]);
useEffect(() => {
sliderMax.value = duration > 0 ? duration : 1;
}, [duration, sliderMax]);
const imageUrl = useMemo(() => {
if (!api || !currentTrack) return null;
const albumId = currentTrack.AlbumId || currentTrack.ParentId;
if (albumId) {
return `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=600&maxWidth=600`;
}
return `${api.basePath}/Items/${currentTrack.Id}/Images/Primary?maxHeight=600&maxWidth=600`;
}, [api, currentTrack]);
const progressText = useMemo(() => {
const progressTicks = progress * 10000000;
return formatDuration(progressTicks);
}, [progress]);
const durationText = useMemo(() => {
const durationTicks = duration * 10000000;
return formatDuration(durationTicks);
}, [duration]);
const handleSliderComplete = useCallback(
(value: number) => {
seek(value);
},
[seek],
);
const handleClose = useCallback(() => {
router.back();
}, [router]);
const _handleStop = useCallback(() => {
stop();
router.back();
}, [stop, router]);
const cycleRepeatMode = useCallback(() => {
const modes: RepeatMode[] = ["off", "all", "one"];
const currentIndex = modes.indexOf(repeatMode);
const nextMode = modes[(currentIndex + 1) % modes.length];
setRepeatMode(nextMode);
}, [repeatMode, setRepeatMode]);
const getRepeatIcon = (): string => {
switch (repeatMode) {
case "one":
return "repeat";
case "all":
return "repeat";
default:
return "repeat";
}
};
const canGoNext = queueIndex < queue.length - 1 || repeatMode === "all";
const canGoPrevious = queueIndex > 0 || progress > 3 || repeatMode === "all";
if (!currentTrack) {
return (
<View
className='flex-1 bg-[#121212] items-center justify-center'
style={{
paddingTop: Platform.OS === "android" ? insets.top : 0,
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}}
>
<Text className='text-neutral-500'>No track playing</Text>
</View>
);
}
return (
<View
className='flex-1 bg-[#121212]'
style={{
paddingTop: Platform.OS === "android" ? insets.top : 0,
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}}
>
{/* Header */}
<View className='flex-row items-center justify-between px-4 pt-3 pb-2'>
<TouchableOpacity
onPress={handleClose}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='p-2'
>
<Ionicons name='chevron-down' size={28} color='white' />
</TouchableOpacity>
<View className='flex-row'>
<TouchableOpacity
onPress={() => setViewMode("player")}
className='px-3 py-1'
>
<Text
className={
viewMode === "player"
? "text-white font-semibold"
: "text-neutral-500"
}
>
Now Playing
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setViewMode("queue")}
className='px-3 py-1'
>
<Text
className={
viewMode === "queue"
? "text-white font-semibold"
: "text-neutral-500"
}
>
Queue ({queue.length})
</Text>
</TouchableOpacity>
</View>
<View style={{ width: 16 }} />
</View>
{viewMode === "player" ? (
<PlayerView
api={api}
currentTrack={currentTrack}
imageUrl={imageUrl}
sliderProgress={sliderProgress}
sliderMin={sliderMin}
sliderMax={sliderMax}
progressText={progressText}
durationText={durationText}
isPlaying={isPlaying}
isLoading={isLoading}
repeatMode={repeatMode}
shuffleEnabled={shuffleEnabled}
canGoNext={canGoNext}
canGoPrevious={canGoPrevious}
onSliderComplete={handleSliderComplete}
onTogglePlayPause={togglePlayPause}
onNext={next}
onPrevious={previous}
onCycleRepeat={cycleRepeatMode}
onToggleShuffle={toggleShuffle}
getRepeatIcon={getRepeatIcon}
queue={queue}
queueIndex={queueIndex}
mediaSource={mediaSource}
isTranscoding={isTranscoding}
/>
) : (
<QueueView
api={api}
queue={queue}
queueIndex={queueIndex}
onJumpToIndex={jumpToIndex}
onRemoveFromQueue={removeFromQueue}
onReorderQueue={reorderQueue}
/>
)}
</View>
);
}
interface PlayerViewProps {
api: any;
currentTrack: BaseItemDto;
imageUrl: string | null;
sliderProgress: any;
sliderMin: any;
sliderMax: any;
progressText: string;
durationText: string;
isPlaying: boolean;
isLoading: boolean;
repeatMode: RepeatMode;
shuffleEnabled: boolean;
canGoNext: boolean;
canGoPrevious: boolean;
onSliderComplete: (value: number) => void;
onTogglePlayPause: () => void;
onNext: () => void;
onPrevious: () => void;
onCycleRepeat: () => void;
onToggleShuffle: () => void;
getRepeatIcon: () => string;
queue: BaseItemDto[];
queueIndex: number;
mediaSource: MediaSourceInfo | null;
isTranscoding: boolean;
}
const PlayerView: React.FC<PlayerViewProps> = ({
currentTrack,
imageUrl,
sliderProgress,
sliderMin,
sliderMax,
progressText,
durationText,
isPlaying,
isLoading,
repeatMode,
shuffleEnabled,
canGoNext,
canGoPrevious,
onSliderComplete,
onTogglePlayPause,
onNext,
onPrevious,
onCycleRepeat,
onToggleShuffle,
getRepeatIcon,
queue,
queueIndex,
mediaSource,
isTranscoding,
}) => {
const audioStream = useMemo(() => {
return mediaSource?.MediaStreams?.find((stream) => stream.Type === "Audio");
}, [mediaSource]);
const fileSize = formatFileSize(mediaSource?.Size);
const codec = audioStream?.Codec?.toUpperCase();
const bitrate = formatBitrate(audioStream?.BitRate);
const sampleRate = formatSampleRate(audioStream?.SampleRate);
const playbackMethod = isTranscoding ? "Transcoding" : "Direct";
const hasAudioStats =
mediaSource && (fileSize || codec || bitrate || sampleRate);
return (
<ScrollView className='flex-1 px-6' showsVerticalScrollIndicator={false}>
{/* Album artwork */}
<View
className='self-center mb-8 mt-4'
style={{
width: ARTWORK_SIZE,
height: ARTWORK_SIZE,
borderRadius: 12,
overflow: "hidden",
backgroundColor: "#1a1a1a",
shadowColor: "#000",
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.4,
shadowRadius: 16,
elevation: 10,
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Ionicons name='musical-note' size={80} color='#666' />
</View>
)}
</View>
{/* Track info */}
<View className='mb-6'>
<Text numberOfLines={1} className='text-white text-2xl font-bold'>
{currentTrack.Name}
</Text>
<Text numberOfLines={1} className='text-purple-400 text-lg mt-1'>
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
</Text>
{currentTrack.Album && (
<Text numberOfLines={1} className='text-neutral-500 text-sm mt-1'>
{currentTrack.Album}
</Text>
)}
{/* Audio Stats */}
{hasAudioStats && (
<View className='flex-row flex-wrap gap-1.5 mt-3'>
{fileSize && <Badge variant='gray' text={fileSize} />}
{codec && <Badge variant='gray' text={codec} />}
<Badge
variant='gray'
text={playbackMethod}
iconLeft={
<Ionicons
name={isTranscoding ? "swap-horizontal" : "play"}
size={12}
color='white'
/>
}
/>
{bitrate && bitrate !== "N/A" && (
<Badge variant='gray' text={bitrate} />
)}
{sampleRate && <Badge variant='gray' text={sampleRate} />}
</View>
)}
</View>
{/* Progress slider */}
<View className='mb-4'>
<Slider
theme={{
maximumTrackTintColor: "#333",
minimumTrackTintColor: "#9334E9",
bubbleBackgroundColor: "#9334E9",
bubbleTextColor: "#fff",
}}
progress={sliderProgress}
minimumValue={sliderMin}
maximumValue={sliderMax}
onSlidingComplete={onSliderComplete}
thumbWidth={16}
sliderHeight={6}
containerStyle={{ borderRadius: 10 }}
renderBubble={() => null}
/>
<View className='flex flex-row justify-between px-1 mt-2'>
<Text className='text-neutral-500 text-xs'>{progressText}</Text>
<Text className='text-neutral-500 text-xs'>{durationText}</Text>
</View>
</View>
{/* Main Controls */}
<View className='flex flex-row items-center justify-center mb-2'>
<TouchableOpacity
onPress={onPrevious}
disabled={!canGoPrevious || isLoading}
className='p-4'
style={{ opacity: canGoPrevious && !isLoading ? 1 : 0.3 }}
>
<Ionicons name='play-skip-back' size={32} color='white' />
</TouchableOpacity>
<TouchableOpacity
onPress={onTogglePlayPause}
disabled={isLoading}
className='mx-8 bg-white rounded-full p-4'
>
{isLoading ? (
<ActivityIndicator size={36} color='#121212' />
) : (
<Ionicons
name={isPlaying ? "pause" : "play"}
size={36}
color='#121212'
style={isPlaying ? {} : { marginLeft: 4 }}
/>
)}
</TouchableOpacity>
<TouchableOpacity
onPress={onNext}
disabled={!canGoNext || isLoading}
className='p-4'
style={{ opacity: canGoNext && !isLoading ? 1 : 0.3 }}
>
<Ionicons name='play-skip-forward' size={32} color='white' />
</TouchableOpacity>
</View>
{/* Shuffle & Repeat Controls */}
<View className='flex flex-row items-center justify-center mb-2'>
<TouchableOpacity onPress={onToggleShuffle} className='p-3 mx-4'>
<Ionicons
name='shuffle'
size={24}
color={shuffleEnabled ? "#9334E9" : "#666"}
/>
</TouchableOpacity>
<TouchableOpacity onPress={onCycleRepeat} className='p-3 mx-4 relative'>
<Ionicons
name={getRepeatIcon() as any}
size={24}
color={repeatMode !== "off" ? "#9334E9" : "#666"}
/>
{repeatMode === "one" && (
<View className='absolute right-0 bg-purple-600 rounded-full w-4 h-4 items-center justify-center'>
<Text className='text-white text-[10px] font-bold'>1</Text>
</View>
)}
</TouchableOpacity>
</View>
{/* Queue info */}
{queue.length > 1 && (
<View className='items-center mb-4'>
<Text className='text-neutral-500 text-sm'>
{queueIndex + 1} of {queue.length}
</Text>
</View>
)}
</ScrollView>
);
};
interface QueueViewProps {
api: any;
queue: BaseItemDto[];
queueIndex: number;
onJumpToIndex: (index: number) => void;
onRemoveFromQueue: (index: number) => void;
onReorderQueue: (newQueue: BaseItemDto[]) => void;
}
const QueueView: React.FC<QueueViewProps> = ({
api,
queue,
queueIndex,
onJumpToIndex,
onRemoveFromQueue,
onReorderQueue,
}) => {
const renderQueueItem = useCallback(
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
const index = getIndex() ?? 0;
const isCurrentTrack = index === queueIndex;
const isPast = index < queueIndex;
const albumId = item.AlbumId || item.ParentId;
const imageUrl = api
? albumId
? `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=80&maxWidth=80`
: `${api.basePath}/Items/${item.Id}/Images/Primary?maxHeight=80&maxWidth=80`
: null;
return (
<ScaleDecorator>
<TouchableOpacity
onPress={() => onJumpToIndex(index)}
onLongPress={drag}
disabled={isActive}
className='flex-row items-center px-4 py-3'
style={{
opacity: isPast && !isActive ? 0.5 : 1,
backgroundColor: isActive
? "#2a2a2a"
: isCurrentTrack
? "rgba(147, 52, 233, 0.3)"
: "#121212",
}}
>
{/* Drag handle */}
<TouchableOpacity
onPressIn={drag}
disabled={isActive}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='pr-2'
>
<Ionicons
name='reorder-three'
size={20}
color={isActive ? "#9334E9" : "#666"}
/>
</TouchableOpacity>
{/* Album art */}
<View className='w-12 h-12 rounded overflow-hidden bg-neutral-800 mr-3'>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View className='flex-1 items-center justify-center'>
<Ionicons name='musical-note' size={16} color='#666' />
</View>
)}
</View>
{/* Track info */}
<View className='flex-1 mr-2'>
<Text
numberOfLines={1}
className={`text-base ${isCurrentTrack ? "text-purple-400 font-semibold" : "text-white"}`}
>
{item.Name}
</Text>
<Text numberOfLines={1} className='text-neutral-500 text-sm'>
{item.Artists?.join(", ") || item.AlbumArtist}
</Text>
</View>
{/* Now playing indicator */}
{isCurrentTrack && (
<Ionicons name='musical-note' size={16} color='#9334E9' />
)}
{/* Remove button (not for current track) */}
{!isCurrentTrack && (
<TouchableOpacity
onPress={() => onRemoveFromQueue(index)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='p-2'
>
<Ionicons name='close' size={20} color='#666' />
</TouchableOpacity>
)}
</TouchableOpacity>
</ScaleDecorator>
);
},
[api, queueIndex, onJumpToIndex, onRemoveFromQueue],
);
const handleDragEnd = useCallback(
({ data }: { data: BaseItemDto[] }) => {
onReorderQueue(data);
},
[onReorderQueue],
);
const history = queue.slice(0, queueIndex);
return (
<DraggableFlatList
data={queue}
keyExtractor={(item, index) => `${item.Id}-${index}`}
renderItem={renderQueueItem}
onDragEnd={handleDragEnd}
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<View className='px-4 py-2'>
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
{history.length > 0 ? "Playing from queue" : "Up next"}
</Text>
</View>
}
ListEmptyComponent={
<View className='flex-1 items-center justify-center py-20'>
<Text className='text-neutral-500'>Queue is empty</Text>
</View>
}
/>
);
};

View File

@@ -1,7 +1,33 @@
import { Stack } from "expo-router";
import { useEffect } from "react";
import { AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { useOrientation } from "@/hooks/useOrientation";
import { useSettings } from "@/utils/atoms/settings";
export default function Layout() {
const { settings } = useSettings();
const { lockOrientation, unlockOrientation } = useOrientation();
useEffect(() => {
if (settings?.defaultVideoOrientation) {
lockOrientation(settings.defaultVideoOrientation);
}
// Re-apply orientation lock when app returns to foreground (iOS resets it)
const subscription = AppState.addEventListener("change", (nextAppState) => {
if (nextAppState === "active" && settings?.defaultVideoOrientation) {
lockOrientation(settings.defaultVideoOrientation);
}
});
return () => {
subscription.remove();
unlockOrientation();
};
}, [settings?.defaultVideoOrientation, lockOrientation, unlockOrientation]);
return (
<>
<SystemBars hidden />

File diff suppressed because it is too large Load Diff

View File

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

View File

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

Binary file not shown.

After

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

View File

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

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.7/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.5/schema.json",
"files": {
"includes": [
"**/*",
@@ -8,7 +8,8 @@
"!android",
"!Streamyfin.app",
"!utils/jellyseerr",
"!.expo"
"!.expo",
"!docs/jellyfin-openapi-stable.json"
]
},
"linter": {

851
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 { FC } from "react";
import { Platform, View, type ViewProps } from "react-native";
import { View, type ViewProps } from "react-native";
import { RoundButton } from "@/components/RoundButton";
import { useFavorite } from "@/hooks/useFavorite";
@@ -11,24 +11,12 @@ interface Props extends ViewProps {
export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
const { isFavorite, toggleFavorite } = useFavorite(item);
if (Platform.OS === "ios") {
return (
<View {...props}>
<RoundButton
size='large'
icon={isFavorite ? "heart" : "heart-outline"}
onPress={toggleFavorite}
/>
</View>
);
}
return (
<View {...props}>
<RoundButton
size='large'
icon={isFavorite ? "heart" : "heart-outline"}
fillColor={isFavorite ? "primary" : undefined}
color={isFavorite ? "purple" : "white"}
onPress={toggleFavorite}
/>
</View>

View File

@@ -0,0 +1,43 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { FC } from "react";
import { useCallback, useRef } from "react";
import { View, type ViewProps } from "react-native";
import { RoundButton } from "@/components/RoundButton";
import {
WatchlistSheet,
type WatchlistSheetRef,
} from "@/components/watchlists/WatchlistSheet";
import {
useItemInWatchlists,
useStreamystatsEnabled,
} from "@/hooks/useWatchlists";
interface Props extends ViewProps {
item: BaseItemDto;
}
export const AddToWatchlist: FC<Props> = ({ item, ...props }) => {
const streamystatsEnabled = useStreamystatsEnabled();
const sheetRef = useRef<WatchlistSheetRef>(null);
const { data: watchlistsContainingItem } = useItemInWatchlists(item.Id);
const isInAnyWatchlist = (watchlistsContainingItem?.length ?? 0) > 0;
const handlePress = useCallback(() => {
sheetRef.current?.open(item);
}, [item]);
// Don't render if Streamystats is not enabled
if (!streamystatsEnabled) return null;
return (
<View {...props}>
<RoundButton
size='large'
icon={isInAnyWatchlist ? "list" : "list-outline"}
onPress={handlePress}
/>
<WatchlistSheet ref={sheetRef} />
</View>
);
};

View File

@@ -1,11 +1,9 @@
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo;
@@ -20,6 +18,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
...props
}) => {
const isTv = Platform.isTV;
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const audioStreams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
@@ -31,55 +31,58 @@ export const AudioTrackSelector: React.FC<Props> = ({
[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;
return (
<View
className='flex shrink'
style={{
minWidth: 50,
<PlatformDropdown
groups={optionGroups}
trigger={trigger}
title={t("item_card.audio")}
open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
}}
>
<DropdownMenu.Root>
<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>
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
);
};

View File

@@ -1,10 +1,8 @@
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
export type Bitrate = {
key: string;
@@ -61,6 +59,8 @@ export const BitrateSelector: React.FC<Props> = ({
...props
}) => {
const isTv = Platform.isTV;
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const sorted = useMemo(() => {
if (inverted)
@@ -76,53 +76,59 @@ export const BitrateSelector: React.FC<Props> = ({
);
}, [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;
return (
<View
className='flex shrink'
style={{
minWidth: 60,
maxWidth: 200,
<PlatformDropdown
groups={optionGroups}
trigger={trigger}
title={t("item_card.quality")}
open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
}}
>
<DropdownMenu.Root>
<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>
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
);
};

View File

@@ -2,7 +2,6 @@ import type React from "react";
import {
type PropsWithChildren,
type ReactNode,
useMemo,
useRef,
useState,
} from "react";
@@ -18,6 +17,58 @@ import {
import { useHaptic } from "@/hooks/useHaptic";
import { Loader } from "./Loader";
const getColorClasses = (
color: "purple" | "red" | "black" | "transparent" | "white",
variant: "solid" | "border",
focused: boolean,
): string => {
if (variant === "border") {
switch (color) {
case "purple":
return focused
? "bg-transparent border-2 border-purple-400"
: "bg-transparent border-2 border-purple-600";
case "red":
return focused
? "bg-transparent border-2 border-red-400"
: "bg-transparent border-2 border-red-600";
case "black":
return focused
? "bg-transparent border-2 border-neutral-700"
: "bg-transparent border-2 border-neutral-900";
case "white":
return focused
? "bg-transparent border-2 border-gray-100"
: "bg-transparent border-2 border-white";
case "transparent":
return focused
? "bg-transparent border-2 border-gray-400"
: "bg-transparent border-2 border-gray-600";
default:
return "";
}
} else {
switch (color) {
case "purple":
return focused
? "bg-purple-500 border-2 border-white"
: "bg-purple-600 border border-purple-700";
case "red":
return "bg-red-600";
case "black":
return "bg-neutral-900";
case "white":
return focused
? "bg-gray-100 border-2 border-gray-300"
: "bg-white border border-gray-200";
case "transparent":
return "bg-transparent";
default:
return "";
}
}
};
export interface ButtonProps
extends React.ComponentProps<typeof TouchableOpacity> {
onPress?: () => void;
@@ -26,7 +77,8 @@ export interface ButtonProps
disabled?: boolean;
children?: string | ReactNode;
loading?: boolean;
color?: "purple" | "red" | "black" | "transparent";
color?: "purple" | "red" | "black" | "transparent" | "white";
variant?: "solid" | "border";
iconRight?: ReactNode;
iconLeft?: ReactNode;
justify?: "center" | "between";
@@ -39,6 +91,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
disabled = false,
loading = false,
color = "purple",
variant = "solid",
iconRight,
iconLeft,
children,
@@ -56,23 +109,13 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
useNativeDriver: true,
}).start();
const colorClasses = useMemo(() => {
switch (color) {
case "purple":
return focused
? "bg-purple-500 border-2 border-white"
: "bg-purple-600 border border-purple-700";
case "red":
return "bg-red-600";
case "black":
return "bg-neutral-900";
case "transparent":
return "bg-transparent";
}
}, [color, focused]);
const colorClasses = getColorClasses(color, variant, focused);
const lightHapticFeedback = useHaptic("light");
const textColorClass =
color === "white" && variant === "solid" ? "text-black" : "text-white";
return Platform.isTV ? (
<Pressable
className='w-full'
@@ -98,10 +141,12 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
>
<View
className={`rounded-2xl py-5 items-center justify-center
${focused ? "bg-purple-500 border-2 border-white" : "bg-purple-600 border border-purple-700"}
${colorClasses}
${className}`}
>
<Text className='text-white text-xl font-bold'>{children}</Text>
<Text className={`${textColorClass} text-xl font-bold`}>
{children}
</Text>
</View>
</Animated.View>
</Pressable>
@@ -135,7 +180,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
{iconLeft ? iconLeft : <View className='w-4' />}
<Text
className={`
text-white font-bold text-base
${textColorClass} font-bold text-base
${disabled ? "text-gray-300" : ""}
${textClassName}
${iconRight ? "mr-2" : ""}

View File

@@ -64,9 +64,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const { settings } = useSettings();
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
const { processes, startBackgroundDownload, getDownloadedItems } =
useDownload();
const downloadedFiles = getDownloadedItems();
const { processes, startBackgroundDownload, downloadedItems } = useDownload();
const downloadedFiles = downloadedItems;
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined
@@ -90,11 +89,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
bottomSheetModalRef.current?.present();
}, []);
const handleSheetChanges = useCallback((index: number) => {
// Ensure modal is fully dismissed when index is -1
if (index === -1) {
// Modal is fully closed
}
const handleSheetChanges = useCallback((_index: number) => {
// Modal state tracking handled by BottomSheetModal
}, []);
const closeModal = useCallback(() => {
@@ -113,7 +109,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
useEffect(() => {
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource,
mediaSource: defaultMediaSource ?? undefined,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
@@ -136,13 +132,15 @@ export const DownloadItems: React.FC<DownloadProps> = ({
return itemsNotDownloaded.length === 0;
}, [items, itemsNotDownloaded]);
const itemsProcesses = useMemo(
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
() =>
processes?.filter((p) => p?.item?.Id && itemIds.includes(p.item.Id)) ||
[],
[processes, itemIds],
);
const progress = useMemo(() => {
if (itemIds.length === 1)
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
return itemsProcesses.reduce((acc, p) => acc + (p.progress || 0), 0);
return (
((itemIds.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))
);
}, [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 onDownloadedPress = () => {
@@ -256,13 +261,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
throw new Error("No item id");
}
// Ensure modal is dismissed before starting download
await closeModal();
closeModal();
// Small delay to ensure modal is fully dismissed
// Wait for modal dismiss animation to complete
setTimeout(() => {
initiateDownload(...itemsToDownload);
}, 100);
}, 300);
} else {
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
@@ -282,7 +286,14 @@ export const DownloadItems: React.FC<DownloadProps> = ({
);
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 ? (
<Loader />
) : (
@@ -336,9 +347,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
backgroundColor: "#171717",
}}
onChange={handleSheetChanges}
onDismiss={() => {
// Ensure any pending state is cleared when modal is dismissed
}}
backdropComponent={renderBackdrop}
enablePanDownToClose
enableDismissOnClose
@@ -359,16 +367,18 @@ export const DownloadItems: React.FC<DownloadProps> = ({
})}
</Text>
</View>
<View className='flex flex-col space-y-2 w-full items-start'>
<BitrateSelector
inverted
onChange={(val) =>
setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val },
)
}
selected={selectedOptions?.bitrate}
/>
<View className='flex flex-col space-y-2 w-full'>
<View className='items-start'>
<BitrateSelector
inverted
onChange={(val) =>
setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val },
)
}
selected={selectedOptions?.bitrate}
/>
</View>
{itemsNotDownloaded.length > 1 && (
<View className='flex flex-row items-center justify-between w-full py-2'>
<Text>{t("item_card.download.download_unwatched_only")}</Text>
@@ -380,21 +390,23 @@ export const DownloadItems: React.FC<DownloadProps> = ({
)}
{itemsNotDownloaded.length === 1 && (
<View>
<MediaSourceSelector
item={items[0]}
onChange={(val) =>
setSelectedOptions(
(prev) =>
prev && {
...prev,
mediaSource: val,
},
)
}
selected={selectedOptions?.mediaSource}
/>
<View className='items-start'>
<MediaSourceSelector
item={items[0]}
onChange={(val) =>
setSelectedOptions(
(prev) =>
prev && {
...prev,
mediaSource: val,
},
)
}
selected={selectedOptions?.mediaSource}
/>
</View>
{selectedOptions?.mediaSource && (
<View className='flex flex-col space-y-2'>
<View className='flex flex-col space-y-2 items-start'>
<AudioTrackSelector
source={selectedOptions.mediaSource}
onChange={(val) => {
@@ -427,11 +439,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
)}
</View>
<Button
className='mt-auto'
onPress={acceptDownloadOptions}
color='purple'
>
<Button onPress={acceptDownloadOptions} color='purple'>
{t("item_card.download.download_button")}
</Button>
</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,79 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
} from "@gorhom/bottom-sheet";
import { useCallback, useEffect } 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, isVisible } = useGlobalModal();
useEffect(() => {
if (isVisible && modalState.content) {
modalRef.current?.present();
}
}, [isVisible, modalState.content, modalRef]);
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

@@ -6,19 +6,19 @@ import { Image } from "expo-image";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { type Bitrate } from "@/components/BitrateSelector";
import { ItemImage } from "@/components/common/ItemImage";
import { DownloadSingleItem } from "@/components/DownloadItem";
import { ItemPeopleSections } from "@/components/item/ItemPeopleSections";
import { MediaSourceButton } from "@/components/MediaSourceButton";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
import { PlayButton } from "@/components/PlayButton";
import { PlayedStatus } from "@/components/PlayedStatus";
import { SimilarItems } from "@/components/SimilarItems";
import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
@@ -29,13 +29,10 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { AddToFavorites } from "./AddToFavorites";
import { BitrateSheet } from "./BitRateSheet";
import { AddToWatchlist } from "./AddToWatchlist";
import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { MediaSourceSheet } from "./MediaSourceSheet";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
import { TrackSheet } from "./TrackSheet";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
@@ -49,17 +46,17 @@ export type SelectedOptions = {
interface ItemContentProps {
item: BaseItemDto;
isOffline: boolean;
itemWithSources?: BaseItemDto | null;
}
export const ItemContent: React.FC<ItemContentProps> = React.memo(
({ item, isOffline }) => {
({ item, isOffline, itemWithSources }) => {
const [api] = useAtom(apiAtom);
const { settings } = useSettings();
const { orientation } = useOrientation();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const [user] = useAtom(userAtom);
const { t } = useTranslation();
const itemColors = useImageColorsReturn({ item });
@@ -70,18 +67,23 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
SelectedOptions | undefined
>(undefined);
// Use itemWithSources for play settings since it has MediaSources data
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(item!, settings);
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null),
[api, item],
);
const onLogoLoad = React.useCallback(() => {
setLoadingLogo(false);
}, []);
const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
@@ -90,7 +92,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
useEffect(() => {
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource,
mediaSource: defaultMediaSource ?? undefined,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
@@ -102,7 +104,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
]);
useEffect(() => {
if (!Platform.isTV) {
if (!Platform.isTV && itemWithSources) {
navigation.setOptions({
headerRight: () =>
item &&
@@ -112,14 +114,19 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
{item.Type !== "Program" && (
<View className='flex flex-row items-center'>
{!Platform.isTV && (
<DownloadSingleItem item={item} size='large' />
)}
{user?.Policy?.IsAdministrator && (
<PlayInRemoteSessionButton item={item} size='large' />
<DownloadSingleItem item={itemWithSources} size='large' />
)}
{user?.Policy?.IsAdministrator &&
!settings.hideRemoteSessionButton && (
<PlayInRemoteSessionButton item={item} size='large' />
)}
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
{settings.streamyStatsServerUrl &&
!settings.hideWatchlistsTab && (
<AddToWatchlist item={item} />
)}
</View>
)}
</View>
@@ -129,21 +136,34 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
{item.Type !== "Program" && (
<View className='flex flex-row items-center space-x-2'>
{!Platform.isTV && (
<DownloadSingleItem item={item} size='large' />
)}
{user?.Policy?.IsAdministrator && (
<PlayInRemoteSessionButton item={item} size='large' />
<DownloadSingleItem item={itemWithSources} size='large' />
)}
{user?.Policy?.IsAdministrator &&
!settings.hideRemoteSessionButton && (
<PlayInRemoteSessionButton item={item} size='large' />
)}
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
{settings.streamyStatsServerUrl &&
!settings.hideWatchlistsTab && (
<AddToWatchlist item={item} />
)}
</View>
)}
</View>
)),
});
}
}, [item, navigation, user]);
}, [
item,
navigation,
user,
itemWithSources,
settings.hideRemoteSessionButton,
settings.streamyStatsServerUrl,
settings.hideWatchlistsTab,
]);
useEffect(() => {
if (item) {
@@ -165,7 +185,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
}}
>
<ParallaxScrollView
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
className='flex-1'
headerHeight={headerHeight}
headerImage={
<View style={[{ flex: 1 }]}>
@@ -192,8 +212,8 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
width: "100%",
}}
contentFit='contain'
onLoad={() => setLoadingLogo(false)}
onError={() => setLoadingLogo(false)}
onLoad={onLogoLoad}
onError={onLogoLoad}
/>
) : (
<View />
@@ -201,76 +221,27 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
}
>
<View className='flex flex-col bg-transparent shrink'>
<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 pt-2 mb-2 shrink'>
<ItemHeader item={item} className='mb-2' />
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
<View className='flex flex-row items-center justify-start w-full h-16'>
<BitrateSheet
className='mr-1'
onChange={(val) =>
setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val },
)
}
selected={selectedOptions.bitrate}
/>
<MediaSourceSheet
className='mr-1'
item={item}
onChange={(val) =>
setSelectedOptions(
(prev) =>
prev && {
...prev,
mediaSource: val,
},
)
}
selected={selectedOptions.mediaSource}
/>
<TrackSheet
className='mr-1'
streamType='Audio'
title={t("item_card.audio")}
source={selectedOptions.mediaSource}
onChange={(val) => {
setSelectedOptions(
(prev) =>
prev && {
...prev,
audioIndex: val,
},
);
}}
selected={selectedOptions.audioIndex}
/>
<TrackSheet
source={selectedOptions.mediaSource}
streamType='Subtitle'
title={t("item_card.subtitles")}
onChange={(val) =>
setSelectedOptions(
(prev) =>
prev && {
...prev,
subtitleIndex: val,
},
)
}
selected={selectedOptions.subtitleIndex}
/>
</View>
)}
<PlayButton
className='grow'
selectedOptions={selectedOptions}
item={item}
isOffline={isOffline}
colors={itemColors}
/>
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
<PlayButton
selectedOptions={selectedOptions}
item={item}
isOffline={isOffline}
colors={itemColors}
/>
<View className='w-1' />
{!isOffline && (
<MediaSourceButton
selectedOptions={selectedOptions}
setSelectedOptions={setSelectedOptions}
item={itemWithSources}
colors={itemColors}
/>
)}
</View>
</View>
{item.Type === "Episode" && (
<SeasonEpisodesCarousel
item={item}
@@ -279,33 +250,21 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
/>
)}
{!isOffline && (
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
)}
{!isOffline &&
selectedOptions.mediaSource?.MediaStreams &&
selectedOptions.mediaSource.MediaStreams.length > 0 && (
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
)}
<OverviewText text={item.Overview} className='px-4 mb-4' />
{item.Type !== "Program" && (
<>
{item.Type === "Episode" && !isOffline && (
<CurrentSeries item={item} className='mb-4' />
<CurrentSeries item={item} className='mb-2' />
)}
{!isOffline && (
<CastAndCrew item={item} className='mb-4' loading={loading} />
)}
{item.People && item.People.length > 0 && !isOffline && (
<View className='mb-4'>
{item.People.slice(0, 3).map((person, idx) => (
<MoreMoviesWithActor
currentItem={item}
key={idx}
actorId={person.Id!}
className='mb-4'
/>
))}
</View>
)}
<ItemPeopleSections item={item} isOffline={isOffline} />
{!isOffline && <SimilarItems itemId={item.Id} />}
</>

View File

@@ -183,6 +183,12 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
if (!source || !videoStream) return null;
// Dolby Vision video check
const isDolbyVision =
videoStream.VideoRangeType === "DOVI" ||
videoStream.DvVersionMajor != null ||
videoStream.DvVersionMinor != null;
return (
<View className='flex-row flex-wrap gap-2'>
<Badge
@@ -195,6 +201,15 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
text={`${videoStream.Width}x${videoStream.Height}`}
/>
{isDolbyVision && (
<Badge
variant='gray'
iconLeft={
<Ionicons name='sparkles-outline' size={16} color='white' />
}
text={"DV"}
/>
)}
<Badge
variant='gray'
iconLeft={

View File

@@ -0,0 +1,193 @@
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { BITRATES } from "./BitRateSheet";
import type { SelectedOptions } from "./ItemContent";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
item?: BaseItemDto | null;
selectedOptions: SelectedOptions;
setSelectedOptions: React.Dispatch<
React.SetStateAction<SelectedOptions | undefined>
>;
colors?: ThemeColors;
}
export const MediaSourceButton: React.FC<Props> = ({
item,
selectedOptions,
setSelectedOptions,
colors,
}: Props) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const effectiveColors = colors || {
primary: "#7c3aed",
text: "#000000",
};
useEffect(() => {
const firstMediaSource = item?.MediaSources?.[0];
if (!firstMediaSource) return;
setSelectedOptions((prev) => {
if (!prev) return prev;
return {
...prev,
mediaSource: firstMediaSource,
};
});
}, [item, setSelectedOptions]);
const getMediaSourceDisplayName = useCallback((source: MediaSourceInfo) => {
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
if (source.Name) return source.Name;
if (videoStream?.DisplayTitle) return videoStream.DisplayTitle;
return `Source ${source.Id}`;
}, []);
const audioStreams = useMemo(
() =>
selectedOptions.mediaSource?.MediaStreams?.filter(
(x) => x.Type === "Audio",
) || [],
[selectedOptions.mediaSource],
);
const subtitleStreams = useMemo(
() =>
selectedOptions.mediaSource?.MediaStreams?.filter(
(x) => x.Type === "Subtitle",
) || [],
[selectedOptions.mediaSource],
);
const optionGroups: OptionGroup[] = useMemo(() => {
const groups: OptionGroup[] = [];
// Bitrate group
groups.push({
title: t("item_card.quality"),
options: BITRATES.map((bitrate) => ({
type: "radio" as const,
label: bitrate.key,
value: bitrate,
selected: bitrate.value === selectedOptions.bitrate?.value,
onPress: () =>
setSelectedOptions((prev) => prev && { ...prev, bitrate }),
})),
});
// Media Source group (only if multiple sources)
if (item?.MediaSources && item.MediaSources.length > 1) {
groups.push({
title: t("item_card.video"),
options: item.MediaSources.map((source) => ({
type: "radio" as const,
label: getMediaSourceDisplayName(source),
value: source,
selected: source.Id === selectedOptions.mediaSource?.Id,
onPress: () =>
setSelectedOptions(
(prev) => prev && { ...prev, mediaSource: source },
),
})),
});
}
// Audio track group
if (audioStreams.length > 0) {
groups.push({
title: t("item_card.audio"),
options: audioStreams.map((stream) => ({
type: "radio" as const,
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
value: stream.Index,
selected: stream.Index === selectedOptions.audioIndex,
onPress: () =>
setSelectedOptions(
(prev) => prev && { ...prev, audioIndex: stream.Index ?? 0 },
),
})),
});
}
// Subtitle track group (with None option)
if (subtitleStreams.length > 0) {
const noneOption = {
type: "radio" as const,
label: t("common.none"),
value: -1,
selected: selectedOptions.subtitleIndex === -1,
onPress: () =>
setSelectedOptions((prev) => prev && { ...prev, subtitleIndex: -1 }),
};
const subtitleOptions = subtitleStreams.map((stream) => ({
type: "radio" as const,
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
value: stream.Index,
selected: stream.Index === selectedOptions.subtitleIndex,
onPress: () =>
setSelectedOptions(
(prev) => prev && { ...prev, subtitleIndex: stream.Index ?? -1 },
),
}));
groups.push({
title: t("item_card.subtitles"),
options: [noneOption, ...subtitleOptions],
});
}
return groups;
}, [
item,
selectedOptions,
audioStreams,
subtitleStreams,
getMediaSourceDisplayName,
t,
setSelectedOptions,
]);
const trigger = (
<TouchableOpacity
disabled={!item}
onPress={() => setOpen(true)}
className='relative'
>
<View
style={{ backgroundColor: effectiveColors.primary, opacity: 0.7 }}
className='absolute w-12 h-12 rounded-full'
/>
<View className='w-12 h-12 rounded-full z-10 items-center justify-center'>
{!item ? (
<ActivityIndicator size='small' color={effectiveColors.text} />
) : (
<Ionicons name='list' size={24} color={effectiveColors.text} />
)}
</View>
</TouchableOpacity>
);
return (
<PlatformDropdown
groups={optionGroups}
trigger={trigger}
title={t("item_card.media_options")}
open={open}
onOpenChange={setOpen}
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
);
};

View File

@@ -2,13 +2,11 @@ import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useCallback, useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto;
@@ -23,7 +21,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
...props
}) => {
const isTv = Platform.isTV;
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const getDisplayName = useCallback((source: MediaSourceInfo) => {
@@ -46,50 +44,60 @@ export const MediaSourceSelector: React.FC<Props> = ({
return getDisplayName(selected);
}, [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;
return (
<View
className='flex shrink'
style={{
minWidth: 50,
<PlatformDropdown
groups={optionGroups}
trigger={trigger}
title={t("item_card.video")}
open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
}}
>
<DropdownMenu.Root>
<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>
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
);
};

View File

@@ -3,6 +3,7 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import type React from "react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
@@ -10,16 +11,18 @@ import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
import MoviePoster from "@/components/posters/MoviePoster";
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
interface Props extends ViewProps {
actorId: string;
actorName?: string | null;
currentItem: BaseItemDto;
}
export const MoreMoviesWithActor: React.FC<Props> = ({
actorId,
actorName,
currentItem,
...props
}) => {
@@ -27,19 +30,6 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
const [user] = useAtom(userAtom);
const { t } = useTranslation();
const { data: actor } = useQuery({
queryKey: ["actor", actorId],
queryFn: async () => {
if (!api || !user?.Id) return null;
return await getUserItemData({
api,
userId: user.Id,
itemId: actorId,
});
},
enabled: !!api && !!user?.Id && !!actorId,
});
const { data: items, isLoading } = useQuery({
queryKey: ["actor", "movies", actorId, currentItem.Id],
queryFn: async () => {
@@ -72,29 +62,34 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
enabled: !!api && !!user?.Id && !!actorId,
});
const renderItem = useCallback(
(item: BaseItemDto, idx: number) => (
<TouchableItemRouter
key={item.Id ?? idx}
item={item}
className='flex flex-col w-28'
>
<View>
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
),
[],
);
if (items?.length === 0) return null;
return (
<View {...props}>
<Text className='text-lg font-bold mb-2 px-4'>
{t("item_card.more_with", { name: actor?.Name })}
{t("item_card.more_with", { name: actorName ?? "" })}
</Text>
<HorizontalScroll
data={items}
loading={isLoading}
height={247}
renderItem={(item: BaseItemDto, idx: number) => (
<TouchableItemRouter
key={idx}
item={item}
className='flex flex-col w-28'
>
<View>
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
)}
height={POSTER_CAROUSEL_HEIGHT}
renderItem={renderItem}
/>
</View>
);

View File

@@ -99,7 +99,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
style={{
top: -50,
}}
className='relative flex-1 bg-transparent pb-24'
className='relative flex-1 bg-transparent pb-4'
>
<LinearGradient
// 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, isVisible } = 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]);
// Watch for modal dismissal on Android (e.g., swipe down, backdrop tap)
// and sync the controlled open state
useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true && !isVisible) {
controlledOnOpenChange?.(false);
}
}, [isVisible, controlledOpen, controlledOnOpenChange]);
if (Platform.OS === "ios") {
return (
<Host style={expoUIConfig?.hostStyle}>
<ContextMenu>
<ContextMenu.Trigger>{trigger}</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

@@ -1,11 +1,12 @@
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { Feather, Ionicons } from "@expo/vector-icons";
import { BottomSheetView } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Alert, TouchableOpacity, View } from "react-native";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import CastContext, {
CastButton,
PlayServicesState,
@@ -24,6 +25,8 @@ import Animated, {
} from "react-native-reanimated";
import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { getDownloadedItemById } from "@/providers/Downloads/database";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
@@ -33,10 +36,11 @@ import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import { runtimeTicksToMinutes } from "@/utils/time";
import type { Button } from "./Button";
import { Button } from "./Button";
import { Text } from "./common/Text";
import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof Button> {
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
item: BaseItemDto;
selectedOptions: SelectedOptions;
isOffline?: boolean;
@@ -51,12 +55,12 @@ export const PlayButton: React.FC<Props> = ({
selectedOptions,
isOffline,
colors,
...props
}: Props) => {
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const mediaStatus = useMediaStatus();
const { t } = useTranslation();
const { showModal, hideModal } = useGlobalModal();
const [globalColorAtom] = useAtom(itemThemeColorAtom);
const api = useAtomValue(apiAtom);
@@ -86,12 +90,9 @@ export const PlayButton: React.FC<Props> = ({
[router, isOffline],
);
const onPress = useCallback(async () => {
console.log("onPress");
const handleNormalPlayFlow = useCallback(async () => {
if (!item) return;
lightHapticFeedback();
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
@@ -165,7 +166,7 @@ export const PlayButton: React.FC<Props> = ({
api,
item,
deviceProfile: enableH265 ? chromecasth265 : chromecast,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
userId: user.Id,
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
@@ -273,6 +274,117 @@ export const PlayButton: React.FC<Props> = ({
showActionSheetWithOptions,
mediaStatus,
selectedOptions,
goToPlayer,
isOffline,
t,
]);
const onPress = useCallback(async () => {
if (!item) return;
lightHapticFeedback();
// Check if item is downloaded
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
if (downloadedItem) {
if (Platform.OS === "android") {
// Show bottom sheet for Android
showModal(
<BottomSheetView>
<View className='px-4 mt-4 mb-12'>
<View className='pb-6'>
<Text className='text-2xl font-bold mb-2'>
{t("player.downloaded_file_title")}
</Text>
<Text className='opacity-70 text-base'>
{t("player.downloaded_file_message")}
</Text>
</View>
<View className='space-y-3'>
<Button
onPress={() => {
hideModal();
const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
}}
color='purple'
>
{Platform.OS === "android"
? "Play downloaded file"
: t("player.downloaded_file_yes")}
</Button>
<Button
onPress={() => {
hideModal();
handleNormalPlayFlow();
}}
color='white'
variant='border'
>
{Platform.OS === "android"
? "Stream file"
: t("player.downloaded_file_no")}
</Button>
</View>
</View>
</BottomSheetView>,
{
snapPoints: ["35%"],
enablePanDownToClose: true,
},
);
} else {
// Show alert for iOS
Alert.alert(
t("player.downloaded_file_title"),
t("player.downloaded_file_message"),
[
{
text: t("player.downloaded_file_yes"),
onPress: () => {
const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
},
isPreferred: true,
},
{
text: t("player.downloaded_file_no"),
onPress: () => {
handleNormalPlayFlow();
},
},
{
text: t("player.downloaded_file_cancel"),
style: "cancel",
},
],
);
}
return;
}
// If not downloaded, proceed with normal flow
handleNormalPlayFlow();
}, [
item,
lightHapticFeedback,
handleNormalPlayFlow,
goToPlayer,
t,
showModal,
hideModal,
effectiveColors,
]);
const derivedTargetWidth = useDerivedValue(() => {
@@ -360,9 +472,6 @@ export const PlayButton: React.FC<Props> = ({
[startColor.value.text, endColor.value.text],
),
}));
/**
* *********************
*/
return (
<TouchableOpacity
@@ -370,8 +479,7 @@ export const PlayButton: React.FC<Props> = ({
accessibilityLabel='Play button'
accessibilityHint='Tap to play the media'
onPress={onPress}
className={"relative"}
{...props}
className={"relative flex-1"}
>
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
<Animated.View
@@ -399,7 +507,11 @@ export const PlayButton: React.FC<Props> = ({
>
<View className='flex flex-row items-center space-x-2'>
<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 style={animatedTextStyle}>
<Ionicons name='play-circle' size={24} />
@@ -410,15 +522,6 @@ export const PlayButton: React.FC<Props> = ({
<CastButton tintColor='transparent' />
</Animated.Text>
)}
{!client && settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name='vlc'
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</View>
</TouchableOpacity>

View File

@@ -1,4 +1,4 @@
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
@@ -17,7 +17,6 @@ import Animated, {
import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { runtimeTicksToMinutes } from "@/utils/time";
import type { Button } from "./Button";
import type { SelectedOptions } from "./ItemContent";
@@ -50,7 +49,6 @@ export const PlayButton: React.FC<Props> = ({
const startColor = useSharedValue(effectiveColors);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const { settings } = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(
@@ -61,7 +59,6 @@ export const PlayButton: React.FC<Props> = ({
);
const onPress = () => {
console.log("onpress");
if (!item) return;
lightHapticFeedback();
@@ -207,15 +204,6 @@ export const PlayButton: React.FC<Props> = ({
<Animated.Text style={animatedTextStyle}>
<Ionicons name='play-circle' size={24} />
</Animated.Text>
{settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name='vlc'
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</View>
</TouchableOpacity>

View File

@@ -0,0 +1,180 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { useSettings } from "@/utils/atoms/settings";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
import { PlaybackSpeedScope } from "./video-player/controls/utils/playback-speed-settings";
export const PLAYBACK_SPEEDS = [
{ label: "0.25x", value: 0.25 },
{ label: "0.5x", value: 0.5 },
{ label: "0.75x", value: 0.75 },
{ label: "1x", value: 1.0 },
{ label: "1.25x", value: 1.25 },
{ label: "1.5x", value: 1.5 },
{ label: "1.75x", value: 1.75 },
{ label: "2x", value: 2.0 },
{ label: "2.25x", value: 2.25 },
{ label: "2.5x", value: 2.5 },
{ label: "2.75x", value: 2.75 },
{ label: "3x", value: 3.0 },
];
interface Props extends React.ComponentProps<typeof View> {
onChange: (value: number, scope: PlaybackSpeedScope) => void;
selected: number;
item?: BaseItemDto;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export const PlaybackSpeedSelector: React.FC<Props> = ({
onChange,
selected,
item,
open: controlledOpen,
onOpenChange,
...props
}) => {
const isTv = Platform.isTV;
const { t } = useTranslation();
const { settings } = useSettings();
const [internalOpen, setInternalOpen] = useState(false);
// Determine initial scope based on existing settings
const initialScope = useMemo<PlaybackSpeedScope>(() => {
if (!item || !settings) return PlaybackSpeedScope.All;
const itemId = item?.Id;
if (!itemId) return PlaybackSpeedScope.All;
// Check for media-specific speed preference
if (settings?.playbackSpeedPerMedia?.[itemId] !== undefined) {
return PlaybackSpeedScope.Media;
}
// Check for show-specific speed preference (only for episodes)
const seriesId = item?.SeriesId;
const perShowSettings = settings?.playbackSpeedPerShow;
if (
seriesId &&
perShowSettings &&
perShowSettings[seriesId] !== undefined
) {
return PlaybackSpeedScope.Show;
}
// If no custom setting exists, check default playback speed
// Show "All" if speed is not 1x, otherwise show "Media"
return (settings?.defaultPlaybackSpeed ?? 1.0) !== 1.0
? PlaybackSpeedScope.All
: PlaybackSpeedScope.Media;
}, [item?.Id, item?.SeriesId, settings]);
const [selectedScope, setSelectedScope] =
useState<PlaybackSpeedScope>(initialScope);
// Update selectedScope when initialScope changes
useEffect(() => {
setSelectedScope(initialScope);
}, [initialScope]);
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
const setOpen = onOpenChange || setInternalOpen;
const scopeLabels = useMemo<Record<PlaybackSpeedScope, string>>(() => {
const labels: Record<string, string> = {
[PlaybackSpeedScope.Media]: t("playback_speed.scope.media"),
};
if (item?.SeriesId) {
labels[PlaybackSpeedScope.Show] = t("playback_speed.scope.show");
}
labels[PlaybackSpeedScope.All] = t("playback_speed.scope.all");
return labels as Record<PlaybackSpeedScope, string>;
}, [item?.SeriesId, t]);
const availableScopes = useMemo<PlaybackSpeedScope[]>(() => {
const scopes = [PlaybackSpeedScope.Media];
if (item?.SeriesId) {
scopes.push(PlaybackSpeedScope.Show);
}
scopes.push(PlaybackSpeedScope.All);
return scopes;
}, [item?.SeriesId]);
const handleSpeedSelect = useCallback(
(speed: number) => {
onChange(speed, selectedScope);
setOpen(false);
},
[onChange, selectedScope, setOpen],
);
const optionGroups = useMemo<OptionGroup[]>(() => {
const groups: OptionGroup[] = [];
// Scope selection group
groups.push({
title: t("playback_speed.apply_to"),
options: availableScopes.map((scope) => ({
type: "radio" as const,
label: scopeLabels[scope],
value: scope,
selected: selectedScope === scope,
onPress: () => setSelectedScope(scope),
})),
});
// Speed selection group
groups.push({
title: t("playback_speed.speed"),
options: PLAYBACK_SPEEDS.map((speed) => ({
type: "radio" as const,
label: speed.label,
value: speed.value,
selected: selected === speed.value,
onPress: () => handleSpeedSelect(speed.value),
})),
});
return groups;
}, [
t,
availableScopes,
scopeLabels,
selectedScope,
selected,
handleSpeedSelect,
]);
const trigger = useMemo(
() => (
<View className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'>
<Ionicons name='speedometer' size={24} color='white' />
</View>
),
[],
);
if (isTv) return null;
return (
<View className='flex shrink' style={{ minWidth: 60 }} {...props}>
<PlatformDropdown
title={t("playback_speed.title")}
groups={optionGroups}
trigger={trigger}
open={open}
onOpenChange={setOpen}
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
</View>
);
};

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