Compare commits

...

115 Commits

Author SHA1 Message Date
renovate[bot]
36304ad58e chore(deps): Update dependency react-native-nitro-modules to v0.33.1 (#1340)
Some checks are pending
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Waiting to run
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Waiting to run
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Waiting to run
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Waiting to run
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 16:48:51 +01:00
renovate[bot]
baeb83581e chore(deps): Update actions/setup-node action to v6.2.0 (#1372)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 15:22:19 +01:00
Fredrik Burmester
05b7a4c50d fix: downloads should work when connecting through QC 2026-01-15 07:54:08 +01:00
Fredrik Burmester
28b67f3ad6 fix(mpv): handle audio track selection for transcoded streams on iOS 2026-01-15 07:53:15 +01:00
Chris
51cd195bfe Reverting Jellyseer/Seer logo update
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Final logo not yet released, holding off until further updates
2026-01-14 20:23:39 +01:00
Chris
0184e266a0 Update Jellyseer logo to Seer logo
Updated logo in order to reflecting the new branding
2026-01-14 20:17:25 +01:00
Gauvain
ae658aa5b0 fix: remove Android emulator detection from MPV player (#1369) 2026-01-14 16:46:17 +01:00
Alex
81f79a54af fix(mpv): Add progress throttling for mpv (#1366)
Some checks failed
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-14 13:14:52 +01:00
renovate[bot]
ca1b640a61 chore(deps): Update dependency @babel/core to v7.28.6 (#1363)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 11:41:57 +01:00
renovate[bot]
e771949c95 chore(deps): Update dependency @tanstack/react-query to v5.90.17 (#1365)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 10:57:40 +01:00
renovate[bot]
78bfa68a17 chore(deps): Update dependency react-i18next to v16.5.3 (#1364)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 10:06:03 +01:00
renovate[bot]
ac59615d79 chore(deps): Update dependency jotai to v2.16.2 (#1329)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 09:17:32 +01:00
Fredrik Burmester
4dd80cd8f5 Merge branch 'develop' into fix/external-sub-selection
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-13 22:43:05 +01:00
renovate[bot]
db9f02b225 chore(deps): Update BRAINSia/free-disk-space action to v2.1.3 (#1358)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:42:18 +01:00
renovate[bot]
7a0bbb1084 chore(deps): Update actions/upload-artifact action to v6 (#1362)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:20:42 +01:00
renovate[bot]
05925530c0 chore(deps): Update actions/checkout action to v6 (#1361)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:20:28 +01:00
renovate[bot]
625a292e26 chore(deps): Update actions/cache action to v5 (#1360)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:20:15 +01:00
renovate[bot]
1acd3102ea chore(deps): Update oven-sh/setup-bun action to v2.1.0 (#1359)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:19:58 +01:00
renovate[bot]
543881dc41 chore(deps): Update BRAINSia/free-disk-space digest to 7ef2f7e (#1357)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:19:27 +01:00
Cristea Florian Victor
5d93483dc2 feat: xcode build script (#1296)
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2026-01-13 19:32:58 +01:00
Gauvain
d54a29020a fix(ci): code scanning alert no. 219 (#1353)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-01-13 18:41:56 +01:00
renovate[bot]
1d04e39b85 chore(deps): Update github/codeql-action action to v4.31.10 (#1352)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 15:05:27 +01:00
Alex Kim
ecc62259fc Re-add config version 2026-01-13 22:21:19 +11:00
Alex Kim
ffd96e05fe Merge branch 'fix/external-sub-selection' of github.com:streamyfin/streamyfin into fix/external-sub-selection 2026-01-13 22:19:58 +11:00
Alex Kim
8541ba02d4 Add android version for stopping auto selection of subtitles 2026-01-13 22:19:47 +11:00
Chris
6c955d8a2a Enhance README with Discord link and badge
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Added Discord link and badge to the README.
2026-01-12 22:06:18 +01:00
Fredrik Burmester
b0bb6c6c9a feat: add technical stream info overlay for MPV player 2026-01-12 21:55:32 +01:00
Fredrik Burmester
82abc291d4 Merge branch 'develop' into fix/external-sub-selection 2026-01-12 21:02:16 +01:00
renovate[bot]
3da4b42ca3 chore(deps): Update dependency @tanstack/react-query to v5.90.16 (#1328)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 15:16:38 +01:00
Alex Kim
16940075b2 Stop external subs from being selected when added 2026-01-12 22:50:36 +11:00
renovate[bot]
a3bbb1bc3a chore(deps): Update dependency @types/lodash to v4.17.23 (#1344)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 12:11:05 +01:00
renovate[bot]
1874c116a6 chore(deps): Update dependency react-i18next to v16.5.2 (#1345)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 11:05:17 +01:00
Simon Eklundh
7a0f70778d fix: fix music videos and home videos on library (#1326)
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
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2026-01-11 22:31:25 +01:00
Simon Eklundh
6957c4fd64 feat: add autorotate for landscape (#1265)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2026-01-11 22:27:19 +01:00
github-actions[bot]
1c0ed82deb feat: New Crowdin Translations (#1343)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-01-11 17:39:23 +01:00
Alex
ad54823f96 refactor: downloads to minimize prop drilling and improve layout and design (#1337)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: Simon-Eklundh <simon.eklundh@proton.me>
2026-01-11 17:38:41 +01:00
Fredrik Burmester
cfa638afc6 chore: deps
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
2026-01-11 13:12:03 +01:00
Fredrik Burmester
467bea7192 feat(network): add local network auto-switch feature (#1334) 2026-01-11 13:08:14 +01:00
github-actions[bot]
ac9ac5d423 feat: New Crowdin Translations (#1341)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-01-11 13:07:53 +01:00
Fredrik Burmester
62b45121e5 feat(settings): add toggle to disable auto-play next episode (#1342) 2026-01-11 13:07:38 +01:00
Fredrik Burmester
0e238ad10e feat(ios): glassview container for badges 2026-01-11 11:48:30 +01:00
Fredrik Burmester
ce793e3469 fix: ensure continue watching overlay appears above controls 2026-01-11 11:30:28 +01:00
Fredrik Burmester
beba4853b9 Revert "feat(settings): add toggle to disable auto-play next episode"
This reverts commit d1b15a9dde.
2026-01-11 10:19:35 +01:00
Fredrik Burmester
d1b15a9dde feat(settings): add toggle to disable auto-play next episode
Add new setting to completely disable the auto-play next episode feature.
When disabled, the countdown button is hidden and the max episode count
setting appears greyed out.
2026-01-11 10:16:10 +01:00
Fredrik Burmester
12847894df feat(watchlists): add see all navigation for promoted watchlists
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
2026-01-11 00:19:31 +01:00
Fredrik Burmester
773eae8cb9 feat(home): add see all navigation from recently added sections 2026-01-11 00:16:18 +01:00
Fredrik Burmester
781c309332 feat(search): lower debounce to 200ms and add AbortController using TanStack Pacer 2026-01-10 23:51:16 +01:00
Fredrik Burmester
8a7f5fd4c5 chore: ai docs 2026-01-10 22:54:08 +01:00
Fredrik Burmester
838c8f48a3 fix(mpv-player): resolve swift compiler warnings 2026-01-10 21:57:53 +01:00
Fredrik Burmester
399eb0a351 fix(chromecast): add missing MediaInfo fields
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
2026-01-10 21:32:09 +01:00
Fredrik Burmester
f1575ca48b feat: MPV player for both Android and iOS with added HW decoding PiP (with subtitles) (#1332)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
Co-authored-by: Alex <111128610+Alexk2309@users.noreply.github.com>
Co-authored-by: Simon-Eklundh <simon.eklundh@proton.me>
2026-01-10 19:35:27 +01:00
github-actions[bot]
df2f44e086 feat: New Crowdin Translations (#1333)
Some checks failed
🏷️🔀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
🏗️ 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
🌐 Translation Sync / sync-translations (push) Has been cancelled
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-01-10 12:13:30 +01:00
Fredrik Burmester
9d566d697d fix: crowdin setting 2026-01-10 11:35:10 +01:00
Fredrik Burmester
35f7257a6d chore(claude): add /reflect skill for session learnings 2026-01-10 11:33:07 +01:00
Fredrik Burmester
d4ab62bdd6 chore: update ai doc 2026-01-10 11:33:07 +01:00
Fredrik Burmester
ca7591ad21 fix: add back redirect for startup tab 2026-01-10 11:33:07 +01:00
Fredrik Burmester
7514bc2c2c fix: redirect when intro modal first time 2026-01-10 11:33:07 +01:00
Fredrik Burmester
241f8c949a fix: cache issues with songs, partial downloads, component rerenders, etc 2026-01-10 11:33:07 +01:00
Fredrik Burmester
f369738f7b fix: remove console errors when offline - since offline is supported 2026-01-10 11:33:07 +01:00
Fredrik Burmester
ac6288d9a4 Update Crowdin configuration file 2026-01-10 11:18:56 +01:00
lance chant
91128944c6 fix: fixing an issue where offline playback wasn't working/tracking when back online (#1239)
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: retardgerman <78982850+retardgerman@users.noreply.github.com>
2026-01-09 09:17:35 +01:00
Fredrik Burmester
81449963fa feat(auth): add multi-account support with PIN/password protection 2026-01-09 08:37:30 +01:00
Fredrik Burmester
8569cd390b fix(android): quick connect not showing bc keyboard 2026-01-09 07:56:17 +01:00
Fredrik Burmester
3959aa2f72 fix(android): convert intro modal to bottom sheet bc broken on smaller screens 2026-01-09 07:15:53 +01:00
Fredrik Burmester
097001b092 fix(player): load external subtitles for offline playback
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
2026-01-08 21:51:27 +01:00
Fredrik Burmester
d1387ec725 fix(downloads): preserve audio track selection for offline playback 2026-01-08 21:51:27 +01:00
Fredrik Burmester
51ecde1565 fix(vlc): add audio transcoding mode to fix 7.1 TrueHD playback 2026-01-08 21:51:27 +01:00
Gauvain
0a0da687d5 ci(crowdin): include untranslated files in exports (#1323)
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
2026-01-08 13:32:18 +01:00
Gauvain
0a3cc87b2a chore(i18n): include untranslated strings in Crowdin sync (#1321)
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
2026-01-07 22:55:13 +01:00
Gauvain
77596bd788 fix(ci): use default GITHUB_TOKEN for Crowdin workflow (#1320) 2026-01-07 22:48:41 +01:00
Fredrik Burmester
630e7a1a36 chore: fix deps 2026-01-07 22:38:28 +01:00
renovate[bot]
04bc412bce chore(deps): Update dependency expo-constants to v18.0.13 (#1319)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:36:43 +01:00
Fredrik Burmester
99aa6bd342 fix(player): hide sub size when vlc 2026-01-07 22:20:11 +01:00
renovate[bot]
a3751c4801 chore(deps): Update dependency @biomejs/biome to v2.3.11 (#1228)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:19:03 +01:00
renovate[bot]
e8ddd1cad7 chore(deps): Pin dependencies (#1235)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:18:40 +01:00
Fredrik Burmester
be8651357b feat(player): vlc subtitle options 2026-01-07 22:18:11 +01:00
Fredrik Burmester
588c8ffeb5 fix(player): audio and subtitle dropdown not visible in player 2026-01-07 22:18:11 +01:00
renovate[bot]
3e01310466 chore(deps): Update github/codeql-action action to v4.31.9 (#1230)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:11:51 +01:00
renovate[bot]
f3417b5a75 chore(deps): Update dependency lint-staged to v16.2.7 (#1236)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:11:25 +01:00
renovate[bot]
f97d7d78c1 chore(deps): Update dependency react-native-mmkv to v4.0.1 (#1238)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:10:49 +01:00
renovate[bot]
eaa519fdda chore(deps): Update dependency @types/lodash to v4.17.21 (#1248)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:10:39 +01:00
renovate[bot]
d84ff82590 chore(deps): Update dependency react-i18next to v16.5.1 (#1249)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:10:30 +01:00
renovate[bot]
ea271b8cdd chore(deps): Update dependency expo-doctor to v1.17.14 (#1260)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:10:22 +01:00
renovate[bot]
b6270f1157 chore(deps): Update crowdin/github-action action to v2.13.0 (#1263)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:09:53 +01:00
renovate[bot]
fe6299c22b chore(deps): Update actions/checkout action to v6 (#1264)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:09:43 +01:00
renovate[bot]
88722b3e17 chore(deps): Update actions/cache action to v5 (#1280)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:09:32 +01:00
renovate[bot]
1a4159ffee chore(deps): Update actions/upload-artifact action to v6 (#1281)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:09:13 +01:00
renovate[bot]
bd13791a83 chore(deps): Update peter-evans/create-pull-request action to v8 (#1282)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:09:00 +01:00
renovate[bot]
968c3e35d5 chore(deps): Update dependency @react-native-community/cli to v20.1.0 (#1317)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:08:42 +01:00
renovate[bot]
b822e315a8 chore(deps): Update oven-sh/setup-bun action to v2.1.0 (#1318)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:08:26 +01:00
Fredrik Burmester
f862a2424e fix(player): restore missing subtitle and audio dropdown for vlc
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
2026-01-07 20:25:32 +01:00
Simon Eklundh
77ee4aca27 chore: Delete .github/workflows/stale.yml (#1254)
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
2026-01-07 07:32:43 +01:00
Fredrik Burmester
d4ea8debfa feat: marquee for music title in modal
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
2026-01-06 19:15:17 +01:00
Fredrik Burmester
e1dd410f73 feat: airplay and chromecast for music 2026-01-06 19:10:19 +01:00
Fredrik Burmester
896c7460df refactor(music): move storage clear options 2026-01-06 18:15:53 +01:00
Fredrik Burmester
3453fd22b8 fix: better music modal design and favorite song 2026-01-06 17:55:37 +01:00
Fredrik Burmester
1c3369c61f feat: search for artists, albums and songs
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2026-01-06 16:40:47 +01:00
Fredrik Burmester
99c9caf03c feat: 3-dot menu open song options 2026-01-06 16:03:25 +01:00
Fredrik Burmester
055357de60 feat: swipe to remove individual server logins 2026-01-06 15:46:12 +01:00
Fredrik Burmester
a24e254a9e fix: better animation 2026-01-06 15:37:13 +01:00
Fredrik Burmester
4bdb8003bb chore: lint 2026-01-06 15:28:58 +01:00
Fredrik Burmester
9825ccce6e fix: width to be same as bottom tab bar ios specifically 2026-01-06 15:27:08 +01:00
Fredrik Burmester
9bfac2a4d9 fix: improve downloaded music queue
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 / 🔍 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
2026-01-05 21:32:41 +01:00
Fredrik Burmester
478052599f chore: version 2026-01-05 21:32:41 +01:00
Fredrik Burmester
37b0b10098 feat: save login credentials when switching servers 2026-01-05 21:32:41 +01:00
Fredrik Burmester
9ca852bb7e fix: intro can't be closed on small phones / large zoom 2026-01-05 21:32:41 +01:00
Fredrik Burmester
a37a8753c2 revert: remove accidentally merged ios widget commits
Reverts commits d58c1a77 (feat: ios widget) and 08fe871d (wip: widget)
that were accidentally merged from feat/ios-widget-build-target branch.
2026-01-05 21:32:41 +01:00
Fredrik Burmester
5eae6e6cd0 fix(websocket): persist reconnect counter across retry attempts
Move reconnectAttempts from local variable to useRef so it persists
across recursive connectWebSocket calls. Previously, each call reset
the counter to 0, causing infinite reconnection attempts instead of
stopping at the max of 5.

Also reset the counter on successful connection (onopen) so fresh
reconnection attempts get full retries.
2026-01-05 21:32:41 +01:00
Fredrik Burmester
e0c408452d fix(auth): await push token deletion before clearing API state on logout
The DELETE request for removing the push token was not awaited,
causing a race condition where setApi(null) could execute while
the request was still in flight. This ensures proper sequencing.
2026-01-05 21:32:41 +01:00
Fredrik Burmester
4c8dfa0e2f wip: widget 2026-01-05 21:32:41 +01:00
Fredrik Burmester
6c95962e12 feat: ios widget 2026-01-05 21:32:41 +01:00
Fredrik Burmester
cbbac3c25c fix(hls): parse M3U8 attributes respecting quoted strings
The previous implementation split on commas without respecting quoted
values, causing subtitles with commas in names (e.g., "English, Forced")
or URIs with special characters to be incorrectly parsed.

Uses regex to properly match key=value pairs where values can be either
quoted (containing any characters except quotes) or unquoted (no commas).
2026-01-05 21:32:41 +01:00
Fredrik Burmester
964d53cc79 feat(music): add haptic feedback to track options menu 2026-01-05 21:32:41 +01:00
Fredrik Burmester
a26980ddab refactor: improve music design 2026-01-05 21:32:41 +01:00
Fredrik Burmester
090ed98233 fix: refresh data on open page in background replace cached data 2026-01-05 21:32:41 +01:00
265 changed files with 28739 additions and 10031 deletions

View File

@@ -0,0 +1,37 @@
---
description: Reflect on this session to extract and store learned facts about the codebase
---
Analyze the current conversation to extract useful facts that should be remembered for future sessions. Focus on:
1. **Corrections**: Things the user corrected you about
2. **Clarifications**: Misunderstandings about how the codebase works
3. **Patterns**: Important conventions or patterns you learned
4. **Gotchas**: Surprising behaviors or edge cases discovered
5. **Locations**: Files or code that was hard to find
## Instructions
1. Read the existing facts file at `.claude/learned-facts.md`
2. Review this conversation for learnings worth preserving
3. For each new fact:
- Write it concisely (1-2 sentences max)
- Include context for why it matters
- Add today's date
4. Skip facts that duplicate existing entries
5. Append new facts to `.claude/learned-facts.md`
## Fact Format
Use this format for each fact:
```
- **[Brief Topic]**: [Concise description of the fact] _(YYYY-MM-DD)_
```
## Example Facts
- **State management**: Use Jotai atoms for global state, NOT React Context - atoms are in `utils/atoms/` _(2025-01-09)_
- **Package manager**: Always use `bun`, never npm or yarn - the project is configured for bun only _(2025-01-09)_
- **TV platform**: Check `Platform.isTV` for TV-specific code paths, not just OS checks _(2025-01-09)_
After updating the file, summarize what facts you added (or note if nothing new was learned this session).

27
.claude/learned-facts.md Normal file
View File

@@ -0,0 +1,27 @@
# Learned Facts
This file contains facts about the codebase learned from past sessions. These are things Claude got wrong or needed clarification on, stored here to prevent the same mistakes in future sessions.
This file is auto-imported into CLAUDE.md and loaded at the start of each session.
## Facts
<!-- New facts will be appended below this line -->
- **Native bottom tabs + useRouter conflict**: When using `@bottom-tabs/react-navigation` with Expo Router, avoid using the `useRouter()` hook in components rendered at the provider level (outside the tab navigator). The hook subscribes to navigation state changes and can cause unexpected tab switches. Use the static `router` import from `expo-router` instead. _(2025-01-09)_
- **IntroSheet rendering location**: The `IntroSheet` component is rendered inside `IntroSheetProvider` which wraps the entire navigation stack. Any hooks in IntroSheet that interact with navigation state can affect the native bottom tabs. _(2025-01-09)_
- **Intro modal trigger location**: The intro modal trigger logic should be in the `Home.tsx` component, not in the tabs `_layout.tsx`. Triggering modals from tab layout can interfere with native bottom tabs navigation. _(2025-01-09)_
- **Tab folder naming**: The tab folders use underscore prefix naming like `(_home)` instead of just `(home)` based on the project's file structure conventions. _(2025-01-09)_
- **macOS header buttons fix**: Header buttons (`headerRight`/`headerLeft`) don't respond to touches on macOS Catalyst builds when using standard React Native `TouchableOpacity`. Fix by using `Pressable` from `react-native-gesture-handler` instead. The library is already installed and `GestureHandlerRootView` wraps the app. _(2026-01-10)_
- **Header button locations**: Header buttons are defined in multiple places: `app/(auth)/(tabs)/(home)/_layout.tsx` (SettingsButton, SessionsButton, back buttons), `components/common/HeaderBackButton.tsx` (reusable), `components/Chromecast.tsx`, `components/RoundButton.tsx`, and dynamically via `navigation.setOptions()` in `components/home/Home.tsx` and `app/(auth)/(tabs)/(home)/downloads/index.tsx`. _(2026-01-10)_
- **useNetworkAwareQueryClient limitations**: The `useNetworkAwareQueryClient` hook uses `Object.create(queryClient)` which breaks QueryClient methods that use JavaScript private fields (like `getQueriesData`, `setQueriesData`, `setQueryData`). Only use it when you ONLY need `invalidateQueries`. For cache manipulation, use standard `useQueryClient` from `@tanstack/react-query`. _(2026-01-10)_
- **Mark as played flow**: The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`. _(2026-01-10)_
- **Stack screen header configuration**: Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling. _(2026-01-10)_

12
.github/crowdin.yml vendored
View File

@@ -1,12 +0,0 @@
"project_id_env": "CROWDIN_PROJECT_ID"
"api_token_env": "CROWDIN_PERSONAL_TOKEN"
"base_path": "."
"preserve_hierarchy": true
"files": [
{
"source": "translations/en.json",
"translation": "translations/%two_letters_code%.json"
}
]

View File

@@ -20,8 +20,20 @@ jobs:
contents: read contents: read
steps: steps:
- name: 🗑️ Free Disk Space
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
with:
tool-cache: false
mandb: true
android: false
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: false
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
@@ -29,12 +41,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -48,7 +60,7 @@ jobs:
bun run submodule-reload bun run submodule-reload
- name: 💾 Cache Gradle global - name: 💾 Cache Gradle global
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -61,7 +73,7 @@ jobs:
run: bun run prebuild run: bun run prebuild
- name: 💾 Cache project Gradle (.gradle) - name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with: with:
path: android/.gradle path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -76,7 +88,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact - name: 📤 Upload APK artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }} name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
path: | path: |
@@ -91,8 +103,20 @@ jobs:
contents: read contents: read
steps: steps:
- name: 🗑️ Free Disk Space
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
with:
tool-cache: false
mandb: true
android: false
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: false
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
@@ -100,12 +124,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -119,7 +143,7 @@ jobs:
bun run submodule-reload bun run submodule-reload
- name: 💾 Cache Gradle global - name: 💾 Cache Gradle global
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -132,7 +156,7 @@ jobs:
run: bun run prebuild:tv run: bun run prebuild:tv
- name: 💾 Cache project Gradle (.gradle) - name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with: with:
path: android/.gradle path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -147,7 +171,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact - name: 📤 Upload APK artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }} name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
path: | path: |
@@ -163,7 +187,7 @@ jobs:
steps: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
@@ -171,12 +195,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
@@ -194,7 +218,7 @@ jobs:
- name: 🔧 Setup Xcode - name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1 uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
with: with:
xcode-version: "26.0.1" xcode-version: "26.2"
- name: 🏗️ Setup EAS - name: 🏗️ Setup EAS
uses: expo/expo-github-action@main uses: expo/expo-github-action@main
@@ -203,9 +227,6 @@ jobs:
token: ${{ secrets.EXPO_TOKEN }} token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true eas-cache: true
- name: ⚙️ Ensure iOS SDKs installed
run: xcodebuild -downloadPlatform iOS
- name: 🚀 Build iOS app - name: 🚀 Build iOS app
env: env:
EXPO_TV: 0 EXPO_TV: 0
@@ -215,12 +236,69 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact - name: 📤 Upload IPA artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }} name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
path: build-*.ipa path: build-*.ipa
retention-days: 7 retention-days: 7
build-ios-phone-unsigned:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: macos-26
name: 🍎 Build iOS IPA (Phone - Unsigned)
permissions:
contents: read
steps:
- name: 📥 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 🛠️ Generate project files
run: bun run prebuild
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
with:
xcode-version: "26.2"
- name: 🚀 Build iOS app
env:
EXPO_TV: 0
run: bun run ios:unsigned-build ${{ github.event_name == 'pull_request' && '-- --verbose' || '' }}
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }}
path: build/*.ipa
retention-days: 7
# Disabled for now - uncomment when ready to build iOS TV # Disabled for now - uncomment when ready to build iOS TV
# build-ios-tv: # build-ios-tv:
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin')) # if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
@@ -271,9 +349,6 @@ jobs:
# token: ${{ secrets.EXPO_TOKEN }} # token: ${{ secrets.EXPO_TOKEN }}
# eas-cache: true # eas-cache: true
# #
# - name: ⚙️ Ensure tvOS SDKs installed
# run: xcodebuild -downloadPlatform tvOS
#
# - name: 🚀 Build iOS app # - name: 🚀 Build iOS app
# env: # env:
# EXPO_TV: 1 # EXPO_TV: 1

View File

@@ -19,7 +19,7 @@ jobs:
steps: steps:
- name: 📥 Checkout repository - name: 📥 Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false show-progress: false
@@ -27,12 +27,12 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with: with:
path: | path: |
~/.bun/install/cache ~/.bun/install/cache

View File

@@ -24,16 +24,16 @@ jobs:
steps: steps:
- name: 📥 Checkout repository - name: 📥 Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: 🏁 Initialize CodeQL - name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3 uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild - name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3 uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
- name: 🧪 Perform CodeQL Analysis - name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3 uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10

View File

@@ -23,12 +23,12 @@ jobs:
steps: steps:
- name: 📥 Checkout Repository - name: 📥 Checkout Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
fetch-depth: 0 fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin - name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0 uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0
with: with:
upload_sources: true upload_sources: true
upload_translations: true upload_translations: true
@@ -40,11 +40,12 @@ jobs:
pull_request_base_branch_name: "develop" pull_request_base_branch_name: "develop"
pull_request_labels: "🌐 translation" pull_request_labels: "🌐 translation"
# Quality control options # Quality control options
skip_untranslated_strings: true skip_untranslated_strings: false
skip_untranslated_files: false
export_only_approved: false export_only_approved: false
# Commit customization # Commit customization
commit_message: "feat(i18n): update translations from Crowdin" commit_message: "feat(i18n): update translations from Crowdin"
env: env:
GITHUB_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@@ -51,7 +51,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
@@ -69,14 +69,14 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: 🛒 Checkout repository - name: 🛒 Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive submodules: recursive
fetch-depth: 0 fetch-depth: 0
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with: with:
bun-version: latest bun-version: latest
@@ -100,19 +100,19 @@ jobs:
steps: steps:
- name: "📥 Checkout PR code" - name: "📥 Checkout PR code"
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive submodules: recursive
fetch-depth: 0 fetch-depth: 0
- name: "🟢 Setup Node.js" - name: "🟢 Setup Node.js"
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version: '24.x' node-version: '24.x'
- name: "🍞 Setup Bun" - name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with: with:
bun-version: latest bun-version: latest

View File

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

View File

@@ -1,49 +0,0 @@
name: 🕒 Handle Stale Issues
on:
schedule:
# Runs daily at 1:30 AM UTC (3:30 AM CEST - France time)
- cron: "30 1 * * *"
jobs:
stale-issues:
name: 🗑️ Cleanup Stale Issues
runs-on: ubuntu-24.04
permissions:
issues: write
pull-requests: write
steps:
- name: 🔄 Mark/Close Stale Issues
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with:
# Global settings
repo-token: ${{ secrets.GITHUB_TOKEN }}
operations-per-run: 500 # Increase if you have >1000 issues
enable-statistics: true
# Issue configuration
days-before-issue-stale: 90
days-before-issue-close: 7
stale-issue-label: "🕰️ stale"
exempt-issue-labels: "Roadmap v1,help needed,enhancement"
# Notifications messages
stale-issue-message: |
⏳ This issue has been automatically marked as **stale** because it has had no activity for 90 days.
**Next steps:**
- If this is still relevant, add a comment to keep it open
- Otherwise, it will be closed in 7 days
Thank you for your contributions! 🙌
close-issue-message: |
🚮 This issue has been automatically closed due to inactivity (7 days since being marked stale).
**Need to reopen?**
Click "Reopen" and add a comment explaining why this should stay open.
# Disable PR handling
days-before-pr-stale: -1
days-before-pr-close: -1

View File

@@ -18,10 +18,10 @@ jobs:
steps: steps:
- name: 📥 Checkout repository - name: 📥 Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: "🟢 Setup Node.js" - name: "🟢 Setup Node.js"
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version: '24.x' node-version: '24.x'
cache: 'npm' cache: 'npm'
@@ -54,7 +54,7 @@ jobs:
dry_run: no-push dry_run: no-push
- name: 📬 Commit and create pull request - name: 📬 Commit and create pull request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8 uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
with: with:
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
branch: ci-update-bug-report branch: ci-update-bug-report

7
.gitignore vendored
View File

@@ -19,7 +19,7 @@ web-build/
/androidtv /androidtv
# Module-specific Builds # Module-specific Builds
modules/vlc-player/android/build modules/mpv-player/android/build
modules/player/android modules/player/android
modules/hls-downloader/android/build modules/hls-downloader/android/build
@@ -66,7 +66,10 @@ streamyfin-4fec1-firebase-adminsdk.json
# Version and Backup Files # Version and Backup Files
/version-backup-* /version-backup-*
modules/background-downloader/android/build/*
/modules/sf-player/android/build /modules/sf-player/android/build
/modules/music-controls/android/build /modules/music-controls/android/build
modules/background-downloader/android/build/*
/modules/mpv-player/android/build /modules/mpv-player/android/build
# ios:unsigned-build Artifacts
build/

View File

@@ -1,5 +1,7 @@
# CLAUDE.md # CLAUDE.md
@.claude/learned-facts.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview ## Project Overview
@@ -75,6 +77,21 @@ bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build error
- File-based routing in `app/` directory - File-based routing in `app/` directory
- Tab navigation: `(home)`, `(search)`, `(favorites)`, `(libraries)`, `(watchlists)` - Tab navigation: `(home)`, `(search)`, `(favorites)`, `(libraries)`, `(watchlists)`
- Shared routes use parenthesized groups like `(home,libraries,search,favorites,watchlists)` - Shared routes use parenthesized groups like `(home,libraries,search,favorites,watchlists)`
- **IMPORTANT**: Always use `useAppRouter` from `@/hooks/useAppRouter` instead of `useRouter` from `expo-router`. This custom hook automatically handles offline mode state preservation across navigation:
```typescript
// ✅ Correct
import useRouter from "@/hooks/useAppRouter";
const router = useRouter();
// ❌ Never use this
import { useRouter } from "expo-router";
import { router } from "expo-router";
```
**Offline Mode**:
- Use `OfflineModeProvider` from `@/providers/OfflineModeProvider` to wrap pages that support offline content
- Use `useOfflineMode()` hook to check if current context is offline
- The `useAppRouter` hook automatically injects `offline=true` param when navigating within an offline context
**Providers** (wrapping order in `app/_layout.tsx`): **Providers** (wrapping order in `app/_layout.tsx`):
1. JotaiProvider 1. JotaiProvider

View File

@@ -5,6 +5,12 @@
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%"> <img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
</p> </p>
<p align="center">
<a href="https://discord.gg/aJvAYeycyY">
<img alt="Streamyfin Discord" src="https://img.shields.io/badge/Discord-Streamyfin-blue?style=flat-square&logo=discord">
</a>
</p>
**Streamyfin is a user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.** **Streamyfin is a user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
--- ---
@@ -54,6 +60,11 @@ The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that ho
Chromecast support is currently under development. Video casting is already available, and we're actively working on adding subtitle support and additional features. Chromecast support is currently under development. Video casting is already available, and we're actively working on adding subtitle support and additional features.
### 🎬 MPV Player
Streamyfin uses [MPV](https://mpv.io/) as its primary video player on all platforms, powered by [MPVKit](https://github.com/mpvkit/MPVKit). MPV is a powerful, open-source media player known for its wide format support and high-quality playback.
Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building the native MPV module in Streamyfin.
### 🔍 Jellysearch ### 🔍 Jellysearch
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin [Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin
@@ -230,6 +241,7 @@ We also thank all other developers who have contributed to Streamyfin, your effo
A special mention to the following people and projects for their contributions: A special mention to the following people and projects for their contributions:
- [@Alexk2309](https://github.com/Alexk2309) for building the native MPV module that integrates [MPVKit](https://github.com/mpvkit/MPVKit) with React Native
- [Reiverr](https://github.com/aleksilassila/reiverr) for invaluable help with understanding the Jellyfin API - [Reiverr](https://github.com/aleksilassila/reiverr) for invaluable help with understanding the Jellyfin API
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for providing the TypeScript SDK - [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for providing the TypeScript SDK
- [Seerr](https://github.com/seerr-team/seerr) for enabling API integration with their project - [Seerr](https://github.com/seerr-team/seerr) for enabling API integration with their project

View File

@@ -6,9 +6,6 @@ module.exports = ({ config }) => {
"react-native-google-cast", "react-native-google-cast",
{ useDefaultExpandedMediaControls: true }, { useDefaultExpandedMediaControls: true },
]); ]);
// KSPlayer for iOS (GPU acceleration + native PiP)
config.plugins.push("./plugins/withKSPlayer.js");
} }
// Only override googleServicesFile if env var is set // Only override googleServicesFile if env var is set

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.51.0", "version": "0.52.0",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
@@ -17,6 +17,7 @@
"NSMicrophoneUsageDescription": "The app needs access to your microphone.", "NSMicrophoneUsageDescription": "The app needs access to your microphone.",
"UIBackgroundModes": ["audio", "fetch"], "UIBackgroundModes": ["audio", "fetch"],
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.", "NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
"NSLocationWhenInUseUsageDescription": "Streamyfin uses your location to detect your home WiFi network for automatic local server switching.",
"NSAppTransportSecurity": { "NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true "NSAllowsArbitraryLoads": true
}, },
@@ -28,13 +29,16 @@
"usesNonExemptEncryption": false "usesNonExemptEncryption": false
}, },
"supportsTablet": true, "supportsTablet": true,
"entitlements": {
"com.apple.developer.networking.wifi-info": true
},
"bundleIdentifier": "com.fredrikburmester.streamyfin", "bundleIdentifier": "com.fredrikburmester.streamyfin",
"icon": "./assets/images/icon-ios-liquid-glass.icon", "icon": "./assets/images/icon-ios-liquid-glass.icon",
"appleTeamId": "MWD5K362T8" "appleTeamId": "MWD5K362T8"
}, },
"android": { "android": {
"jsEngine": "hermes", "jsEngine": "hermes",
"versionCode": 91, "versionCode": 92,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png", "foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png", "monochromeImage": "./assets/images/icon-android-themed.png",
@@ -44,7 +48,8 @@
"permissions": [ "permissions": [
"android.permission.FOREGROUND_SERVICE", "android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK", "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.WRITE_SETTINGS" "android.permission.WRITE_SETTINGS",
"android.permission.ACCESS_FINE_LOCATION"
], ],
"blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"], "blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"],
"googleServicesFile": "./google-services.json" "googleServicesFile": "./google-services.json"
@@ -58,7 +63,8 @@
"expo-build-properties", "expo-build-properties",
{ {
"ios": { "ios": {
"deploymentTarget": "15.6" "deploymentTarget": "15.6",
"useFrameworks": "static"
}, },
"android": { "android": {
"buildArchs": ["arm64-v8a", "x86_64"], "buildArchs": ["arm64-v8a", "x86_64"],
@@ -66,7 +72,7 @@
"targetSdkVersion": 35, "targetSdkVersion": 35,
"buildToolsVersion": "35.0.0", "buildToolsVersion": "35.0.0",
"kotlinVersion": "2.0.21", "kotlinVersion": "2.0.21",
"minSdkVersion": 24, "minSdkVersion": 26,
"usesCleartextTraffic": true, "usesCleartextTraffic": true,
"packagingOptions": { "packagingOptions": {
"jniLibs": { "jniLibs": {
@@ -84,12 +90,6 @@
"initialOrientation": "DEFAULT" "initialOrientation": "DEFAULT"
} }
], ],
[
"expo-sensors",
{
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
}
],
"expo-localization", "expo-localization",
"expo-asset", "expo-asset",
[ [
@@ -120,7 +120,14 @@
["./plugins/withChangeNativeAndroidTextToWhite.js"], ["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidManifest.js"], ["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"], ["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"] ["./plugins/withGradleProperties.js"],
[
"./plugins/withGitPod.js",
{
"podName": "MPVKit-GPL",
"podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec"
}
]
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true

View File

@@ -29,7 +29,7 @@ export default function menuLinks() {
); );
const config = response?.data; const config = response?.data;
if (!config && !Object.hasOwn(config, "menuLinks")) { if (!config || !Object.hasOwn(config, "menuLinks")) {
console.error("Menu links not found"); console.error("Menu links not found");
return; return;
} }

View File

@@ -1,8 +1,10 @@
import { Feather, Ionicons } from "@expo/vector-icons"; import { Feather, Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router"; import { Stack } from "expo-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, View } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import useRouter from "@/hooks/useAppRouter";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast"); const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
@@ -46,32 +48,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
title: t("home.downloads.downloads_title"), title: t("home.downloads.downloads_title"),
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity <Pressable
onPress={() => _router.back()} onPress={() => _router.back()}
className='pl-0.5' className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity> </Pressable>
),
}}
/>
<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>
), ),
}} }}
/> />
@@ -84,13 +67,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity <Pressable
onPress={() => _router.back()} onPress={() => _router.back()}
className='pl-0.5' className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity> </Pressable>
), ),
}} }}
/> />
@@ -102,13 +85,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity <Pressable
onPress={() => _router.back()} onPress={() => _router.back()}
className='pl-0.5' className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity> </Pressable>
), ),
}} }}
/> />
@@ -120,13 +103,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity <Pressable
onPress={() => _router.back()} onPress={() => _router.back()}
className='pl-0.5' className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity> </Pressable>
), ),
}} }}
/> />
@@ -138,13 +121,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity <Pressable
onPress={() => _router.back()} onPress={() => _router.back()}
className='pl-0.5' className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity> </Pressable>
), ),
}} }}
/> />
@@ -156,13 +139,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity <Pressable
onPress={() => _router.back()} onPress={() => _router.back()}
className='pl-0.5' className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity> </Pressable>
), ),
}} }}
/> />
@@ -174,13 +157,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity <Pressable
onPress={() => _router.back()} onPress={() => _router.back()}
className='pl-0.5' className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity> </Pressable>
), ),
}} }}
/> />
@@ -192,13 +175,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity <Pressable
onPress={() => _router.back()} onPress={() => _router.back()}
className='pl-0.5' className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity> </Pressable>
), ),
}} }}
/> />
@@ -210,13 +193,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity <Pressable
onPress={() => _router.back()} onPress={() => _router.back()}
className='pl-0.5' className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity> </Pressable>
), ),
}} }}
/> />
@@ -228,13 +211,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity <Pressable
onPress={() => _router.back()} onPress={() => _router.back()}
className='pl-0.5' className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity> </Pressable>
), ),
}} }}
/> />
@@ -246,13 +229,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity <Pressable
onPress={() => _router.back()} onPress={() => _router.back()}
className='pl-0.5' className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity> </Pressable>
), ),
}} }}
/> />
@@ -264,13 +247,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity <Pressable
onPress={() => _router.back()} onPress={() => _router.back()}
className='pl-0.5' className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity> </Pressable>
), ),
}} }}
/> />
@@ -282,13 +265,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity <Pressable
onPress={() => _router.back()} onPress={() => _router.back()}
className='pl-0.5' className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity> </Pressable>
), ),
}} }}
/> />
@@ -300,13 +283,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity <Pressable
onPress={() => _router.back()} onPress={() => _router.back()}
className='pl-0.5' className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity> </Pressable>
), ),
}} }}
/> />
@@ -318,27 +301,32 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity <Pressable
onPress={() => _router.back()} onPress={() => _router.back()}
className='pl-0.5' className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity> </Pressable>
), ),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='intro/page' name='settings/network/page'
options={{ options={{
headerShown: false, title: t("home.settings.network.title"),
title: "", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'> <Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity> </Pressable>
), ),
presentation: "modal",
}} }}
/> />
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
@@ -349,9 +337,9 @@ export default function IndexLayout() {
options={{ options={{
title: "", title: "",
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'> <Pressable onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity> </Pressable>
), ),
headerShown: true, headerShown: true,
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
@@ -367,13 +355,13 @@ const SettingsButton = () => {
const router = useRouter(); const router = useRouter();
return ( return (
<TouchableOpacity <Pressable
onPress={() => { onPress={() => {
router.push("/(auth)/settings"); router.push("/(auth)/settings");
}} }}
> >
<Feather name='settings' color={"white"} size={22} /> <Feather name='settings' color={"white"} size={22} />
</TouchableOpacity> </Pressable>
); );
}; };
@@ -382,7 +370,7 @@ const SessionsButton = () => {
const { sessions = [] } = useSessions({} as useSessionsProps); const { sessions = [] } = useSessions({} as useSessionsProps);
return ( return (
<TouchableOpacity <Pressable
onPress={() => { onPress={() => {
router.push("/(auth)/sessions"); router.push("/(auth)/sessions");
}} }}
@@ -393,6 +381,6 @@ const SessionsButton = () => {
color={sessions.length === 0 ? "white" : "#9333ea"} color={sessions.length === 0 ? "white" : "#9333ea"}
size={28} size={28}
/> />
</TouchableOpacity> </Pressable>
); );
}; };

View File

@@ -1,181 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { FlashList } from "@shopify/flash-list";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
import {
SeasonDropdown,
type SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
export default function page() {
const navigation = useNavigation();
const local = useLocalSearchParams();
const { seriesId, episodeSeasonIndex } = local as {
seriesId: string;
episodeSeasonIndex: number | string | undefined;
};
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
{},
);
const { downloadedItems, deleteItems } = useDownload();
const insets = useSafeAreaInsets();
const series = useMemo(() => {
try {
return (
downloadedItems
?.filter((f) => f.item.SeriesId === seriesId)
?.sort(
(a, b) =>
(a.item.ParentIndexNumber ?? 0) - (b.item.ParentIndexNumber ?? 0),
) || []
);
} catch {
return [];
}
}, [downloadedItems, seriesId]);
// Group episodes by season in a single pass
const seasonGroups = useMemo(() => {
const groups: Record<number, BaseItemDto[]> = {};
series.forEach((episode) => {
const seasonNumber = episode.item.ParentIndexNumber;
if (seasonNumber !== undefined && seasonNumber !== null) {
if (!groups[seasonNumber]) {
groups[seasonNumber] = [];
}
groups[seasonNumber].push(episode.item);
}
});
// Sort episodes within each season
Object.values(groups).forEach((episodes) => {
episodes.sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0));
});
return groups;
}, [series]);
// Get unique seasons (just the season numbers, sorted)
const uniqueSeasons = useMemo(() => {
const seasonNumbers = Object.keys(seasonGroups)
.map(Number)
.sort((a, b) => a - b);
return seasonNumbers.map((seasonNum) => seasonGroups[seasonNum][0]); // First episode of each season
}, [seasonGroups]);
const seasonIndex =
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ??
episodeSeasonIndex ??
series?.[0]?.item?.ParentIndexNumber ??
"";
const groupBySeason = useMemo<BaseItemDto[]>(() => {
return seasonGroups[Number(seasonIndex)] ?? [];
}, [seasonGroups, seasonIndex]);
const initialSeasonIndex = useMemo(
() =>
groupBySeason?.[0]?.ParentIndexNumber ??
series?.[0]?.item?.ParentIndexNumber,
[groupBySeason, series],
);
useEffect(() => {
if (series.length > 0) {
navigation.setOptions({
title: series[0].item.SeriesName,
});
} else {
storage.remove(seriesId);
router.back();
}
}, [series]);
const deleteSeries = useCallback(() => {
Alert.alert(
"Delete season",
"Are you sure you want to delete the entire season?",
[
{
text: "Cancel",
style: "cancel",
},
{
text: "Delete",
onPress: () =>
deleteItems(
groupBySeason
.map((item) => item.Id)
.filter((id) => id !== undefined),
),
style: "destructive",
},
],
);
}, [groupBySeason, deleteItems]);
const ListHeaderComponent = useCallback(() => {
if (series.length === 0) return null;
return (
<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'>
<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,15 +1,10 @@
import { BottomSheetModal } from "@gorhom/bottom-sheet"; import { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useNavigation, useRouter } from "expo-router"; import { useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { Alert, Platform, ScrollView, View } from "react-native";
Alert, import { Pressable } from "react-native-gesture-handler";
Platform,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
@@ -18,8 +13,10 @@ import ActiveDownloads from "@/components/downloads/ActiveDownloads";
import { DownloadSize } from "@/components/downloads/DownloadSize"; import { DownloadSize } from "@/components/downloads/DownloadSize";
import { MovieCard } from "@/components/downloads/MovieCard"; import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard"; import { SeriesCard } from "@/components/downloads/SeriesCard";
import useRouter from "@/hooks/useAppRouter";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { type DownloadedItem } from "@/providers/Downloads/types"; import { type DownloadedItem } from "@/providers/Downloads/types";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { queueAtom } from "@/utils/atoms/queue"; import { queueAtom } from "@/utils/atoms/queue";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
@@ -103,12 +100,12 @@ export default function page() {
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
<TouchableOpacity <Pressable
onPress={bottomSheetModalRef.current?.present} onPress={bottomSheetModalRef.current?.present}
className='px-2' className='px-2'
> >
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} /> <DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
</TouchableOpacity> </Pressable>
), ),
}); });
}, [downloadedFiles]); }, [downloadedFiles]);
@@ -166,57 +163,13 @@ export default function page() {
); );
return ( return (
<OfflineModeProvider isOffline={true}>
<ScrollView <ScrollView
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior='automatic' contentInsetAdjustmentBehavior='automatic'
> >
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}> <View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
<View className='mb-4 flex flex-col space-y-4 px-4'> <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>
<Text className='text-xs opacity-70 text-red-600'>
{t("home.downloads.queue_hint")}
</Text>
<View className='flex flex-col space-y-2 mt-2'>
{queue.map((q, index) => (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/page?id=${q.item.Id}`)
}
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between'
key={index}
>
<View>
<Text className='font-semibold'>{q.item.Name}</Text>
<Text className='text-xs opacity-50'>
{q.item.Type}
</Text>
</View>
<TouchableOpacity
onPress={() => {
removeProcess(q.id);
setQueue((prev) => {
if (!prev) return [];
return [...prev.filter((i) => i.id !== q.id)];
});
}}
>
<Ionicons name='close' size={24} color='red' />
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
{queue.length === 0 && (
<Text className='opacity-50'>
{t("home.downloads.no_items_in_queue")}
</Text>
)}
</View> */}
<ActiveDownloads /> <ActiveDownloads />
</View> </View>
@@ -233,11 +186,7 @@ export default function page() {
<ScrollView horizontal showsHorizontalScrollIndicator={false}> <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'> <View className='px-4 flex flex-row'>
{movies?.map((item) => ( {movies?.map((item) => (
<TouchableItemRouter <TouchableItemRouter item={item.item} key={item.item.Id}>
item={item.item}
isOffline
key={item.item.Id}
>
<MovieCard item={item.item} /> <MovieCard item={item.item} />
</TouchableItemRouter> </TouchableItemRouter>
))} ))}
@@ -260,7 +209,10 @@ export default function page() {
<ScrollView horizontal showsHorizontalScrollIndicator={false}> <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'> <View className='px-4 flex flex-row'>
{groupedBySeries?.map((items) => ( {groupedBySeries?.map((items) => (
<View className='mb-2 last:mb-0' key={items[0].item.SeriesId}> <View
className='mb-2 last:mb-0'
key={items[0].item.SeriesId}
>
<SeriesCard <SeriesCard
items={items.map((i) => i.item)} items={items.map((i) => i.item)}
key={items[0].item.SeriesId} key={items[0].item.SeriesId}
@@ -279,17 +231,15 @@ export default function page() {
{t("home.downloads.other_media")} {t("home.downloads.other_media")}
</Text> </Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'> <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> <Text className='text-xs font-bold'>
{otherMedia?.length}
</Text>
</View> </View>
</View> </View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}> <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'> <View className='px-4 flex flex-row'>
{otherMedia?.map((item) => ( {otherMedia?.map((item) => (
<TouchableItemRouter <TouchableItemRouter item={item.item} key={item.item.Id}>
item={item.item}
isOffline
key={item.item.Id}
>
<MovieCard item={item.item} /> <MovieCard item={item.item} />
</TouchableItemRouter> </TouchableItemRouter>
))} ))}
@@ -306,5 +256,6 @@ export default function page() {
)} )}
</View> </View>
</ScrollView> </ScrollView>
</OfflineModeProvider>
); );
} }

View File

@@ -1,154 +0,0 @@
import { Feather, Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { useFocusEffect, useRouter } from "expo-router";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Linking, Platform, TouchableOpacity, View } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { storage } from "@/utils/mmkv";
export default function page() {
const router = useRouter();
const { t } = useTranslation();
useFocusEffect(
useCallback(() => {
storage.set("hasShownIntro", true);
}, []),
);
return (
<View
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 space-y-4" : "py-16 space-y-8"} px-4`}
>
<View>
<Text className='text-3xl font-bold text-center mb-2'>
{t("home.intro.welcome_to_streamyfin")}
</Text>
<Text className='text-center'>
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
</Text>
</View>
<View>
<Text className='text-lg font-bold'>
{t("home.intro.features_title")}
</Text>
<Text className='text-xs'>{t("home.intro.features_description")}</Text>
<View className='flex flex-row items-center mt-4'>
<Image
source={require("@/assets/icons/jellyseerr-logo.svg")}
style={{
width: 50,
height: 50,
}}
/>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>Jellyseerr</Text>
<Text className='shrink text-xs'>
{t("home.intro.jellyseerr_feature_description")}
</Text>
</View>
</View>
{!Platform.isTV && (
<>
<View className='flex flex-row items-center mt-4'>
<View
style={{
width: 50,
height: 50,
}}
className='flex items-center justify-center'
>
<Ionicons
name='cloud-download-outline'
size={32}
color='white'
/>
</View>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>
{t("home.intro.downloads_feature_title")}
</Text>
<Text className='shrink text-xs'>
{t("home.intro.downloads_feature_description")}
</Text>
</View>
</View>
<View className='flex flex-row items-center mt-4'>
<View
style={{
width: 50,
height: 50,
}}
className='flex items-center justify-center'
>
<Feather name='cast' size={28} color={"white"} />
</View>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>Chromecast</Text>
<Text className='shrink text-xs'>
{t("home.intro.chromecast_feature_description")}
</Text>
</View>
</View>
</>
)}
<View className='flex flex-row items-center mt-4'>
<View
style={{
width: 50,
height: 50,
}}
className='flex items-center justify-center'
>
<Feather name='settings' size={28} color={"white"} />
</View>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>
{t("home.intro.centralised_settings_plugin_title")}
</Text>
<View className='flex-row flex-wrap items-baseline'>
<Text className='shrink text-xs'>
{t("home.intro.centralised_settings_plugin_description")}{" "}
</Text>
<TouchableOpacity
onPress={() => {
Linking.openURL(
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
);
}}
>
<Text className='text-xs text-purple-600 underline'>
{t("home.intro.read_more")}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</View>
<View>
<Button
onPress={() => {
router.back();
}}
className='mt-4'
>
{t("home.intro.done_button")}
</Button>
<TouchableOpacity
onPress={() => {
router.back();
router.push("/settings");
}}
className='mt-4'
>
<Text className='text-purple-600 text-center'>
{t("home.intro.go_to_settings_button")}
</Text>
</TouchableOpacity>
</View>
</View>
);
}

View File

@@ -1,4 +1,4 @@
import { useNavigation, useRouter } from "expo-router"; import { useNavigation } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -11,6 +11,7 @@ import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { QuickConnect } from "@/components/settings/QuickConnect"; import { QuickConnect } from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings"; import { StorageSettings } from "@/components/settings/StorageSettings";
import { UserInfo } from "@/components/settings/UserInfo"; import { UserInfo } from "@/components/settings/UserInfo";
import useRouter from "@/hooks/useAppRouter";
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
export default function settings() { export default function settings() {
@@ -90,6 +91,11 @@ export default function settings() {
showArrow showArrow
title={t("home.settings.intro.title")} title={t("home.settings.intro.title")}
/> />
<ListItem
onPress={() => router.push("/settings/network/page")}
showArrow
title={t("home.settings.network.title")}
/>
<ListItem <ListItem
onPress={() => router.push("/settings/logs/page")} onPress={() => router.push("/settings/logs/page")}
showArrow showArrow

View File

@@ -2,6 +2,7 @@ import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AudioToggles } from "@/components/settings/AudioToggles"; import { AudioToggles } from "@/components/settings/AudioToggles";
import { MediaProvider } from "@/components/settings/MediaContext"; import { MediaProvider } from "@/components/settings/MediaContext";
import { MpvSubtitleSettings } from "@/components/settings/MpvSubtitleSettings";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles"; import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
export default function AudioSubtitlesPage() { export default function AudioSubtitlesPage() {
@@ -22,6 +23,7 @@ export default function AudioSubtitlesPage() {
<MediaProvider> <MediaProvider>
<AudioToggles className='mb-4' /> <AudioToggles className='mb-4' />
<SubtitleToggles className='mb-4' /> <SubtitleToggles className='mb-4' />
<MpvSubtitleSettings className='mb-4' />
</MediaProvider> </MediaProvider>
</View> </View>
</ScrollView> </ScrollView>

View File

@@ -1,13 +1,13 @@
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, ScrollView, View } from "react-native"; import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import { useIntroSheet } from "@/providers/IntroSheetProvider";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
export default function IntroPage() { export default function IntroPage() {
const router = useRouter(); const { showIntro } = useIntroSheet();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -26,7 +26,7 @@ export default function IntroPage() {
<ListGroup title={t("home.settings.intro.title")}> <ListGroup title={t("home.settings.intro.title")}>
<ListItem <ListItem
onPress={() => { onPress={() => {
router.push("/intro/page"); showIntro();
}} }}
title={t("home.settings.intro.show_intro")} title={t("home.settings.intro.show_intro")}
/> />

View File

@@ -1,13 +1,22 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, ScrollView, View } from "react-native"; import { Platform, ScrollView, View } from "react-native";
import { Switch } from "react-native-gesture-handler"; import { Switch } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import { PlatformDropdown } from "@/components/PlatformDropdown"; import { PlatformDropdown } from "@/components/PlatformDropdown";
import { useHaptic } from "@/hooks/useHaptic";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import {
clearCache,
clearPermanentDownloads,
getStorageStats,
} from "@/providers/AudioStorage";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
const CACHE_SIZE_OPTIONS = [ const CACHE_SIZE_OPTIONS = [
@@ -29,6 +38,40 @@ export default function MusicSettingsPage() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useNetworkAwareQueryClient();
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const { data: musicCacheStats } = useQuery({
queryKey: ["musicCacheStats"],
queryFn: () => getStorageStats(),
});
const onClearMusicCacheClicked = useCallback(async () => {
try {
await clearCache();
queryClient.invalidateQueries({ queryKey: ["musicCacheStats"] });
queryClient.invalidateQueries({ queryKey: ["appSize"] });
successHapticFeedback();
toast.success(t("home.settings.storage.music_cache_cleared"));
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
}
}, [queryClient, successHapticFeedback, errorHapticFeedback, t]);
const onDeleteDownloadedSongsClicked = useCallback(async () => {
try {
await clearPermanentDownloads();
queryClient.invalidateQueries({ queryKey: ["musicCacheStats"] });
queryClient.invalidateQueries({ queryKey: ["appSize"] });
successHapticFeedback();
toast.success(t("home.settings.storage.downloaded_songs_deleted"));
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
}
}, [queryClient, successHapticFeedback, errorHapticFeedback, t]);
const cacheSizeOptions = useMemo( const cacheSizeOptions = useMemo(
() => [ () => [
@@ -171,6 +214,37 @@ export default function MusicSettingsPage() {
</ListItem> </ListItem>
</ListGroup> </ListGroup>
</View> </View>
{!Platform.isTV && (
<View className='mt-4'>
<ListGroup
title={t("home.settings.storage.music_cache_title")}
description={
<Text className='text-[#8E8D91] text-xs'>
{t("home.settings.storage.music_cache_description")}
</Text>
}
>
<ListItem
onPress={onClearMusicCacheClicked}
title={t("home.settings.storage.clear_music_cache")}
subtitle={t("home.settings.storage.music_cache_size", {
size: (musicCacheStats?.cacheSize ?? 0).bytesToReadable(),
})}
/>
</ListGroup>
<ListGroup>
<ListItem
textColor='red'
onPress={onDeleteDownloadedSongsClicked}
title={t("home.settings.storage.delete_all_downloaded_songs")}
subtitle={t("home.settings.storage.downloaded_songs_size", {
size: (musicCacheStats?.permanentSize ?? 0).bytesToReadable(),
})}
/>
</ListGroup>
</View>
)}
</View> </View>
</ScrollView> </ScrollView>
); );

View File

@@ -0,0 +1,48 @@
import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { LocalNetworkSettings } from "@/components/settings/LocalNetworkSettings";
import { apiAtom } from "@/providers/JellyfinProvider";
import { storage } from "@/utils/mmkv";
export default function NetworkSettingsPage() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const api = useAtomValue(apiAtom);
const remoteUrl = storage.getString("serverUrl");
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: insets.bottom + 20,
}}
>
<View
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<ListGroup title={t("home.settings.network.current_server")}>
<ListItem
title={t("home.settings.network.remote_url")}
subtitle={remoteUrl ?? t("home.settings.network.not_configured")}
/>
<ListItem
title={t("home.settings.network.active_url")}
subtitle={api?.basePath ?? t("home.settings.network.not_connected")}
/>
</ListGroup>
<View className='mt-4'>
<LocalNetworkSettings />
</View>
</View>
</ScrollView>
);
}

View File

@@ -1,4 +1,3 @@
import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -16,6 +15,7 @@ import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export default function page() { export default function page() {
@@ -26,7 +26,7 @@ export default function page() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
const queryClient = useQueryClient(); const queryClient = useNetworkAwareQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || ""); const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");

View File

@@ -1,4 +1,3 @@
import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -15,6 +14,7 @@ import { toast } from "sonner-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export default function page() { export default function page() {
@@ -28,7 +28,7 @@ export default function page() {
pluginSettings, pluginSettings,
refreshStreamyfinPluginSettings, refreshStreamyfinPluginSettings,
} = useSettings(); } = useSettings();
const queryClient = useQueryClient(); const queryClient = useNetworkAwareQueryClient();
// Local state for all editable fields // Local state for all editable fields
const [url, setUrl] = useState<string>(settings?.streamyStatsServerUrl || ""); const [url, setUrl] = useState<string>(settings?.streamyStatsServerUrl || "");

View File

@@ -13,6 +13,7 @@ import Animated, {
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ItemContent } from "@/components/ItemContent"; import { ItemContent } from "@/components/ItemContent";
import { useItemQuery } from "@/hooks/useItemQuery"; import { useItemQuery } from "@/hooks/useItemQuery";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
const Page: React.FC = () => { const Page: React.FC = () => {
const { id } = useLocalSearchParams() as { id: string }; const { id } = useLocalSearchParams() as { id: string };
@@ -75,6 +76,7 @@ const Page: React.FC = () => {
); );
return ( return (
<OfflineModeProvider isOffline={isOffline}>
<View className='flex flex-1 relative'> <View className='flex flex-1 relative'>
<Animated.View <Animated.View
pointerEvents={"none"} pointerEvents={"none"}
@@ -100,14 +102,9 @@ const Page: React.FC = () => {
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' /> <View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' /> <View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
</Animated.View> </Animated.View>
{item && ( {item && <ItemContent item={item} itemWithSources={itemWithSources} />}
<ItemContent
item={item}
isOffline={isOffline}
itemWithSources={itemWithSources}
/>
)}
</View> </View>
</OfflineModeProvider>
); );
}; };

View File

@@ -8,7 +8,7 @@ import {
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -27,6 +27,7 @@ import { PlatformDropdown } from "@/components/PlatformDropdown";
import { JellyserrRatings } from "@/components/Ratings"; import { JellyserrRatings } from "@/components/Ratings";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons"; import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions"; import { ItemActions } from "@/components/series/SeriesActions";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest"; import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants"; import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";

View File

@@ -227,7 +227,7 @@ export default function ArtistDetailScreen() {
{section.type === "albums" ? ( {section.type === "albums" ? (
<HorizontalScroll <HorizontalScroll
data={section.data} data={section.data}
height={200} height={178}
keyExtractor={(item) => item.Id!} keyExtractor={(item) => item.Id!}
renderItem={(item) => <MusicAlbumCard album={item} />} renderItem={(item) => <MusicAlbumCard album={item} />}
/> />

View File

@@ -8,12 +8,7 @@ import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { ActivityIndicator, TouchableOpacity, View } from "react-native";
ActivityIndicator,
Dimensions,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
@@ -30,8 +25,7 @@ import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { runtimeTicksToMinutes } from "@/utils/time"; import { runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH } = Dimensions.get("window"); const ARTWORK_SIZE = 120;
const ARTWORK_SIZE = SCREEN_WIDTH * 0.5;
export default function PlaylistDetailScreen() { export default function PlaylistDetailScreen() {
const { playlistId } = useLocalSearchParams<{ playlistId: string }>(); const { playlistId } = useLocalSearchParams<{ playlistId: string }>();

View File

@@ -14,86 +14,124 @@ import { ParallaxScrollView } from "@/components/ParallaxPage";
import { NextUp } from "@/components/series/NextUp"; import { NextUp } from "@/components/series/NextUp";
import { SeasonPicker } from "@/components/series/SeasonPicker"; import { SeasonPicker } from "@/components/series/SeasonPicker";
import { SeriesHeader } from "@/components/series/SeriesHeader"; import { SeriesHeader } from "@/components/series/SeriesHeader";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import {
buildOfflineSeriesFromEpisodes,
getDownloadedEpisodesForSeries,
} from "@/utils/downloads/offline-series";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { storage } from "@/utils/mmkv";
const page: React.FC = () => { const page: React.FC = () => {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation(); const { t } = useTranslation();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { id: seriesId, seasonIndex } = params as { const {
id: seriesId,
seasonIndex,
offline: offlineParam,
} = params as {
id: string; id: string;
seasonIndex: string; seasonIndex: string;
offline?: string;
}; };
const isOffline = offlineParam === "true";
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { getDownloadedItems, downloadedItems } = useDownload();
// For offline mode, construct series data from downloaded episodes
// Include downloadedItems.length so query refetches when items are deleted
const { data: item } = useQuery({ const { data: item } = useQuery({
queryKey: ["series", seriesId], queryKey: ["series", seriesId, isOffline, downloadedItems.length],
queryFn: async () => queryFn: async () => {
await getUserItemData({ if (isOffline) {
return buildOfflineSeriesFromEpisodes(getDownloadedItems(), seriesId);
}
return await getUserItemData({
api, api,
userId: user?.Id, userId: user?.Id,
itemId: seriesId, itemId: seriesId,
}), });
staleTime: 60 * 1000, },
staleTime: isOffline ? Infinity : 60 * 1000,
enabled: isOffline || (!!api && !!user?.Id),
}); });
const backdropUrl = useMemo( // For offline mode, use stored base64 image
() => const base64Image = useMemo(() => {
getBackdropUrl({ if (isOffline) {
return storage.getString(seriesId);
}
return null;
}, [isOffline, seriesId]);
const backdropUrl = useMemo(() => {
if (isOffline && base64Image) {
return `data:image/jpeg;base64,${base64Image}`;
}
return getBackdropUrl({
api, api,
item, item,
quality: 90, quality: 90,
width: 1000, width: 1000,
}), });
[item], }, [isOffline, base64Image, api, item]);
);
const logoUrl = useMemo( const logoUrl = useMemo(() => {
() => if (isOffline) {
getLogoImageUrlById({ return null; // No logo in offline mode
}
return getLogoImageUrlById({
api, api,
item, item,
}), });
[item], }, [isOffline, api, item]);
);
const { data: allEpisodes, isLoading } = useQuery({ const { data: allEpisodes, isLoading } = useQuery({
queryKey: ["AllEpisodes", item?.Id], queryKey: ["AllEpisodes", seriesId, isOffline, downloadedItems.length],
queryFn: async () => { queryFn: async () => {
if (!api || !user?.Id || !item?.Id) return []; if (isOffline) {
return getDownloadedEpisodesForSeries(getDownloadedItems(), seriesId);
}
if (!api || !user?.Id) return [];
const res = await getTvShowsApi(api).getEpisodes({ const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.Id, seriesId: seriesId,
userId: user.Id, userId: user.Id,
enableUserData: true, enableUserData: true,
// Note: Including trick play is necessary to enable trick play downloads
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"], fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
}); });
return res?.data.Items || []; return res?.data.Items || [];
}, },
select: (data) => select: (data) =>
// This needs to be sorted by parent index number and then index number, that way we can download the episodes in the correct order.
[...(data || [])].sort( [...(data || [])].sort(
(a, b) => (a, b) =>
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) || (a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0), (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
), ),
staleTime: 60, staleTime: isOffline ? Infinity : 60,
enabled: !!api && !!user?.Id && !!item?.Id, enabled: isOffline || (!!api && !!user?.Id),
}); });
useEffect(() => { useEffect(() => {
// Don't show header buttons in offline mode
if (isOffline) {
navigation.setOptions({
headerRight: () => null,
});
return;
}
navigation.setOptions({ navigation.setOptions({
headerRight: () => headerRight: () =>
!isLoading && !isLoading && item && allEpisodes && allEpisodes.length > 0 ? (
item &&
allEpisodes &&
allEpisodes.length > 0 && (
<View className='flex flex-row items-center space-x-2'> <View className='flex flex-row items-center space-x-2'>
<AddToFavorites item={item} /> <AddToFavorites item={item} />
{!Platform.isTV && ( {!Platform.isTV && (
@@ -114,16 +152,19 @@ const page: React.FC = () => {
/> />
)} )}
</View> </View>
), ) : null,
}); });
}, [allEpisodes, isLoading, item]); }, [allEpisodes, isLoading, item, isOffline]);
if (!item || !backdropUrl) return null; // For offline mode, we can show the page even without backdropUrl
if (!item || (!isOffline && !backdropUrl)) return null;
return ( return (
<OfflineModeProvider isOffline={isOffline}>
<ParallaxScrollView <ParallaxScrollView
headerHeight={400} headerHeight={400}
headerImage={ headerImage={
backdropUrl ? (
<Image <Image
source={{ source={{
uri: backdropUrl, uri: backdropUrl,
@@ -133,6 +174,15 @@ const page: React.FC = () => {
height: "100%", height: "100%",
}} }}
/> />
) : (
<View
style={{
width: "100%",
height: "100%",
backgroundColor: "#1a1a1a",
}}
/>
)
} }
logo={ logo={
logoUrl ? ( logoUrl ? (
@@ -151,12 +201,15 @@ const page: React.FC = () => {
> >
<View className='flex flex-col pt-4'> <View className='flex flex-col pt-4'>
<SeriesHeader item={item} /> <SeriesHeader item={item} />
{!isOffline && (
<View className='mb-4'> <View className='mb-4'>
<NextUp seriesId={seriesId} /> <NextUp seriesId={seriesId} />
</View> </View>
)}
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} /> <SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
</View> </View>
</ParallaxScrollView> </ParallaxScrollView>
</OfflineModeProvider>
); );
}; };

View File

@@ -50,8 +50,13 @@ import {
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
const Page = () => { const Page = () => {
const searchParams = useLocalSearchParams(); const searchParams = useLocalSearchParams() as {
const { libraryId } = searchParams as { libraryId: string }; libraryId: string;
sortBy?: string;
sortOrder?: string;
filterBy?: string;
};
const { libraryId } = searchParams;
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
@@ -76,23 +81,33 @@ const Page = () => {
const { t } = useTranslation(); const { t } = useTranslation();
useEffect(() => { useEffect(() => {
// Check for URL params first (from "See All" navigation)
const urlSortBy = searchParams.sortBy as SortByOption | undefined;
const urlSortOrder = searchParams.sortOrder as SortOrderOption | undefined;
const urlFilterBy = searchParams.filterBy as FilterByOption | undefined;
// Apply sortOrder: URL param > saved preference > default
if (urlSortOrder && Object.values(SortOrderOption).includes(urlSortOrder)) {
_setSortOrder([urlSortOrder]);
} else {
const sop = getSortOrderPreference(libraryId, sortOrderPreference); const sop = getSortOrderPreference(libraryId, sortOrderPreference);
if (sop) { _setSortOrder([sop || SortOrderOption.Ascending]);
_setSortOrder([sop]);
} else {
_setSortOrder([SortOrderOption.Ascending]);
} }
// Apply sortBy: URL param > saved preference > default
if (urlSortBy && Object.values(SortByOption).includes(urlSortBy)) {
_setSortBy([urlSortBy]);
} else {
const obp = getSortByPreference(libraryId, sortByPreference); const obp = getSortByPreference(libraryId, sortByPreference);
if (obp) { _setSortBy([obp || SortByOption.SortName]);
_setSortBy([obp]);
} else {
_setSortBy([SortByOption.SortName]);
} }
const fp = getFilterByPreference(libraryId, filterByPreference);
if (fp) { // Apply filterBy: URL param > saved preference > default
_setFilterBy([fp]); if (urlFilterBy && Object.values(FilterByOption).includes(urlFilterBy)) {
_setFilterBy([urlFilterBy]);
} else { } else {
_setFilterBy([]); const fp = getFilterByPreference(libraryId, filterByPreference);
_setFilterBy(fp ? [fp] : []);
} }
}, [ }, [
libraryId, libraryId,
@@ -102,6 +117,9 @@ const Page = () => {
_setSortBy, _setSortBy,
filterByPreference, filterByPreference,
_setFilterBy, _setFilterBy,
searchParams.sortBy,
searchParams.sortOrder,
searchParams.filterBy,
]); ]);
const setSortBy = useCallback( const setSortBy = useCallback(
@@ -191,6 +209,10 @@ const Page = () => {
itemType = "Series"; itemType = "Series";
} else if (library.CollectionType === "boxsets") { } else if (library.CollectionType === "boxsets") {
itemType = "BoxSet"; itemType = "BoxSet";
} else if (library.CollectionType === "homevideos") {
itemType = "Video";
} else if (library.CollectionType === "musicvideos") {
itemType = "MusicVideo";
} }
const response = await getItemsApi(api).getItems({ const response = await getItemsApi(api).getItems({

View File

@@ -13,8 +13,7 @@ import { useTranslation } from "react-i18next";
const { Navigator } = createMaterialTopTabNavigator(); const { Navigator } = createMaterialTopTabNavigator();
const TAB_LABEL_FONT_SIZE = 13; const TAB_LABEL_FONT_SIZE = 13;
const TAB_ITEM_HORIZONTAL_PADDING = 18; const TAB_ITEM_HORIZONTAL_PADDING = 12;
const TAB_ITEM_MIN_WIDTH = 110;
export const Tab = withLayoutContext< export const Tab = withLayoutContext<
MaterialTopTabNavigationOptions, MaterialTopTabNavigationOptions,
@@ -48,7 +47,6 @@ const Layout = () => {
}, },
tabBarItemStyle: { tabBarItemStyle: {
width: "auto", width: "auto",
minWidth: TAB_ITEM_MIN_WIDTH,
paddingHorizontal: TAB_ITEM_HORIZONTAL_PADDING, paddingHorizontal: TAB_ITEM_HORIZONTAL_PADDING,
}, },
tabBarStyle: { backgroundColor: "black" }, tabBarStyle: { backgroundColor: "black" },

View File

@@ -6,11 +6,11 @@ import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Dimensions, RefreshControl, View } from "react-native"; import { RefreshControl, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard"; import { MusicAlbumRowCard } from "@/components/music/MusicAlbumRowCard";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
const ITEMS_PER_PAGE = 40; const ITEMS_PER_PAGE = 40;
@@ -65,13 +65,6 @@ export default function AlbumsScreen() {
return data?.pages.flatMap((page) => page.items) || []; return data?.pages.flatMap((page) => page.items) || [];
}, [data]); }, [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(() => { const handleEndReached = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) { if (hasNextPage && !isFetchingNextPage) {
fetchNextPage(); fetchNextPage();
@@ -98,11 +91,10 @@ export default function AlbumsScreen() {
<View className='flex-1 bg-black'> <View className='flex-1 bg-black'>
<FlashList <FlashList
data={albums} data={albums}
numColumns={numColumns}
contentContainerStyle={{ contentContainerStyle={{
paddingBottom: insets.bottom + 100, paddingBottom: insets.bottom + 100,
paddingTop: 16, paddingTop: 8,
paddingHorizontal: padding, paddingHorizontal: 16,
}} }}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
@@ -113,17 +105,7 @@ export default function AlbumsScreen() {
} }
onEndReached={handleEndReached} onEndReached={handleEndReached}
onEndReachedThreshold={0.5} onEndReachedThreshold={0.5}
renderItem={({ item, index }) => ( renderItem={({ item }) => <MusicAlbumRowCard album={item} />}
<View
style={{
width: itemWidth,
marginRight: index % numColumns === 0 ? gap : 0,
marginBottom: gap,
}}
>
<MusicAlbumCard album={item} width={itemWidth} />
</View>
)}
keyExtractor={(item) => item.Id!} keyExtractor={(item) => item.Id!}
ListFooterComponent={ ListFooterComponent={
isFetchingNextPage ? ( isFetchingNextPage ? (

View File

@@ -6,7 +6,7 @@ import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Dimensions, RefreshControl, View } from "react-native"; import { RefreshControl, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
@@ -71,13 +71,6 @@ export default function ArtistsScreen() {
return data?.pages.flatMap((page) => page.items) || []; return data?.pages.flatMap((page) => page.items) || [];
}, [data]); }, [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(() => { const handleEndReached = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) { if (hasNextPage && !isFetchingNextPage) {
fetchNextPage(); fetchNextPage();
@@ -135,11 +128,10 @@ export default function ArtistsScreen() {
<View className='flex-1 bg-black'> <View className='flex-1 bg-black'>
<FlashList <FlashList
data={artists} data={artists}
numColumns={numColumns}
contentContainerStyle={{ contentContainerStyle={{
paddingBottom: insets.bottom + 100, paddingBottom: insets.bottom + 100,
paddingTop: 16, paddingTop: 8,
paddingHorizontal: padding, paddingHorizontal: 16,
}} }}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
@@ -150,17 +142,7 @@ export default function ArtistsScreen() {
} }
onEndReached={handleEndReached} onEndReached={handleEndReached}
onEndReachedThreshold={0.5} onEndReachedThreshold={0.5}
renderItem={({ item, index }) => ( renderItem={({ item }) => <MusicArtistCard artist={item} />}
<View
style={{
width: itemWidth,
marginRight: index % numColumns !== numColumns - 1 ? gap : 0,
marginBottom: gap,
}}
>
<MusicArtistCard artist={item} size={itemWidth} />
</View>
)}
keyExtractor={(item) => item.Id!} keyExtractor={(item) => item.Id!}
ListFooterComponent={ ListFooterComponent={
isFetchingNextPage ? ( isFetchingNextPage ? (

View File

@@ -7,17 +7,17 @@ import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useLayoutEffect, useMemo, useState } from "react"; import { useCallback, useLayoutEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { RefreshControl, TouchableOpacity, View } from "react-native";
Dimensions,
RefreshControl,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal"; import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { MusicPlaylistCard } from "@/components/music/MusicPlaylistCard"; import { MusicPlaylistCard } from "@/components/music/MusicPlaylistCard";
import {
type PlaylistSortOption,
type PlaylistSortOrder,
PlaylistSortSheet,
} from "@/components/music/PlaylistSortSheet";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
const ITEMS_PER_PAGE = 40; const ITEMS_PER_PAGE = 40;
@@ -36,9 +36,20 @@ export default function PlaylistsScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
const [createModalOpen, setCreateModalOpen] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false);
const [sortSheetOpen, setSortSheetOpen] = useState(false);
const [sortBy, setSortBy] = useState<PlaylistSortOption>("SortName");
const [sortOrder, setSortOrder] = useState<PlaylistSortOrder>("Ascending");
const isReady = Boolean(api && user?.Id && libraryId); const isReady = Boolean(api && user?.Id && libraryId);
const handleSortChange = useCallback(
(newSortBy: PlaylistSortOption, newSortOrder: PlaylistSortOrder) => {
setSortBy(newSortBy);
setSortOrder(newSortOrder);
},
[],
);
useLayoutEffect(() => { useLayoutEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
@@ -63,13 +74,13 @@ export default function PlaylistsScreen() {
isFetchingNextPage, isFetchingNextPage,
refetch, refetch,
} = useInfiniteQuery({ } = useInfiniteQuery({
queryKey: ["music-playlists", libraryId, user?.Id], queryKey: ["music-playlists", libraryId, user?.Id, sortBy, sortOrder],
queryFn: async ({ pageParam = 0 }) => { queryFn: async ({ pageParam = 0 }) => {
const response = await getItemsApi(api!).getItems({ const response = await getItemsApi(api!).getItems({
userId: user?.Id, userId: user?.Id,
includeItemTypes: ["Playlist"], includeItemTypes: ["Playlist"],
sortBy: ["SortName"], sortBy: [sortBy],
sortOrder: ["Ascending"], sortOrder: [sortOrder],
limit: ITEMS_PER_PAGE, limit: ITEMS_PER_PAGE,
startIndex: pageParam, startIndex: pageParam,
recursive: true, recursive: true,
@@ -93,13 +104,6 @@ export default function PlaylistsScreen() {
return data?.pages.flatMap((page) => page.items) || []; return data?.pages.flatMap((page) => page.items) || [];
}, [data]); }, [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(() => { const handleEndReached = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) { if (hasNextPage && !isFetchingNextPage) {
fetchNextPage(); fetchNextPage();
@@ -171,11 +175,10 @@ export default function PlaylistsScreen() {
<View className='flex-1 bg-black'> <View className='flex-1 bg-black'>
<FlashList <FlashList
data={playlists} data={playlists}
numColumns={numColumns}
contentContainerStyle={{ contentContainerStyle={{
paddingBottom: insets.bottom + 100, paddingBottom: insets.bottom + 100,
paddingTop: 16, paddingTop: 8,
paddingHorizontal: padding, paddingHorizontal: 16,
}} }}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
@@ -186,17 +189,26 @@ export default function PlaylistsScreen() {
} }
onEndReached={handleEndReached} onEndReached={handleEndReached}
onEndReachedThreshold={0.5} onEndReachedThreshold={0.5}
renderItem={({ item, index }) => ( ListHeaderComponent={
<View <TouchableOpacity
style={{ onPress={() => setSortSheetOpen(true)}
width: itemWidth, className='flex-row items-center mb-2 py-1'
marginRight: index % numColumns === 0 ? gap : 0,
marginBottom: gap,
}}
> >
<MusicPlaylistCard playlist={item} width={itemWidth} /> <Ionicons name='swap-vertical' size={18} color='#9334E9' />
</View> <Text className='text-purple-500 text-sm ml-1.5'>
{t(
`music.sort.${sortBy === "SortName" ? "alphabetical" : "date_created"}`,
)} )}
</Text>
<Ionicons
name={sortOrder === "Ascending" ? "arrow-up" : "arrow-down"}
size={14}
color='#9334E9'
style={{ marginLeft: 4 }}
/>
</TouchableOpacity>
}
renderItem={({ item }) => <MusicPlaylistCard playlist={item} />}
keyExtractor={(item) => item.Id!} keyExtractor={(item) => item.Id!}
ListFooterComponent={ ListFooterComponent={
isFetchingNextPage ? ( isFetchingNextPage ? (
@@ -210,6 +222,13 @@ export default function PlaylistsScreen() {
open={createModalOpen} open={createModalOpen}
setOpen={setCreateModalOpen} setOpen={setCreateModalOpen}
/> />
<PlaylistSortSheet
open={sortSheetOpen}
setOpen={setSortSheetOpen}
sortBy={sortBy}
sortOrder={sortOrder}
onSortChange={handleSortChange}
/>
</View> </View>
); );
} }

View File

@@ -290,7 +290,7 @@ export default function SuggestionsScreen() {
{section.type === "albums" ? ( {section.type === "albums" ? (
<HorizontalScroll <HorizontalScroll
data={section.data} data={section.data}
height={200} height={178}
keyExtractor={(item) => item.Id!} keyExtractor={(item) => item.Id!}
renderItem={(item) => <MusicAlbumCard album={item} />} renderItem={(item) => <MusicAlbumCard album={item} />}
/> />

View File

@@ -3,9 +3,11 @@ import type {
BaseItemKind, BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useAsyncDebouncer } from "@tanstack/react-pacer";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { router, useLocalSearchParams, useNavigation } from "expo-router"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { import {
useCallback, useCallback,
@@ -19,7 +21,6 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Input } from "@/components/common/Input"; import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
@@ -35,10 +36,12 @@ import { DiscoverFilters } from "@/components/search/DiscoverFilters";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton"; import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper"; import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { SearchTabButtons } from "@/components/search/SearchTabButtons"; import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { createStreamystatsApi } from "@/utils/streamystats"; import { createStreamystatsApi } from "@/utils/streamystats";
type SearchType = "Library" | "Discover"; type SearchType = "Library" | "Discover";
@@ -55,6 +58,7 @@ const exampleSearches = [
export default function search() { export default function search() {
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter();
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
@@ -68,7 +72,23 @@ export default function search() {
const [searchType, setSearchType] = useState<SearchType>("Library"); const [searchType, setSearchType] = useState<SearchType>("Library");
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
const [debouncedSearch] = useDebounce(search, 500); const [debouncedSearch, setDebouncedSearch] = useState("");
const abortControllerRef = useRef<AbortController | null>(null);
const searchDebouncer = useAsyncDebouncer(
async (query: string) => {
// Cancel previous in-flight requests
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
setDebouncedSearch(query);
return query;
},
{ wait: 200 },
);
useEffect(() => {
searchDebouncer.maybeExecute(search);
}, [search]);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
@@ -98,9 +118,11 @@ export default function search() {
async ({ async ({
types, types,
query, query,
signal,
}: { }: {
types: BaseItemKind[]; types: BaseItemKind[];
query: string; query: string;
signal?: AbortSignal;
}): Promise<BaseItemDto[]> => { }): Promise<BaseItemDto[]> => {
if (!api || !query) { if (!api || !query) {
return []; return [];
@@ -108,13 +130,16 @@ export default function search() {
try { try {
if (searchEngine === "Jellyfin") { if (searchEngine === "Jellyfin") {
const searchApi = await getItemsApi(api).getItems({ const searchApi = await getItemsApi(api).getItems(
{
searchTerm: query, searchTerm: query,
limit: 10, limit: 10,
includeItemTypes: types, includeItemTypes: types,
recursive: true, recursive: true,
userId: user?.Id, userId: user?.Id,
}); },
{ signal },
);
return (searchApi.data.Items as BaseItemDto[]) || []; return (searchApi.data.Items as BaseItemDto[]) || [];
} }
@@ -143,6 +168,7 @@ export default function search() {
query, query,
searchType as "movies" | "series" | "episodes" | "actors" | "media", searchType as "movies" | "series" | "episodes" | "actors" | "media",
10, 10,
signal,
); );
const allIds: string[] = [ const allIds: string[] = [
@@ -157,10 +183,13 @@ export default function search() {
return []; return [];
} }
const itemsResponse = await getItemsApi(api).getItems({ const itemsResponse = await getItemsApi(api).getItems(
{
ids: allIds, ids: allIds,
enableImageTypes: ["Primary", "Backdrop", "Thumb"], enableImageTypes: ["Primary", "Backdrop", "Thumb"],
}); },
{ signal },
);
return (itemsResponse.data.Items as BaseItemDto[]) || []; return (itemsResponse.data.Items as BaseItemDto[]) || [];
} }
@@ -176,7 +205,7 @@ export default function search() {
.map((type) => encodeURIComponent(type)) .map((type) => encodeURIComponent(type))
.join("&includeItemTypes=")}`; .join("&includeItemTypes=")}`;
const response1 = await axios.get(url); const response1 = await axios.get(url, { signal });
const ids = response1.data.ids; const ids = response1.data.ids;
@@ -184,19 +213,65 @@ export default function search() {
return []; return [];
} }
const response2 = await getItemsApi(api).getItems({ const response2 = await getItemsApi(api).getItems(
{
ids, ids,
enableImageTypes: ["Primary", "Backdrop", "Thumb"], enableImageTypes: ["Primary", "Backdrop", "Thumb"],
}); },
{ signal },
);
return (response2.data.Items as BaseItemDto[]) || []; return (response2.data.Items as BaseItemDto[]) || [];
} catch (_error) { } catch (error) {
// Silently handle aborted requests
if (error instanceof Error && error.name === "AbortError") {
return [];
}
return []; return [];
} }
}, },
[api, searchEngine, settings, user?.Id], [api, searchEngine, settings, user?.Id],
); );
// Separate search function for music types - always uses Jellyfin since Streamystats doesn't support music
const jellyfinSearchFn = useCallback(
async ({
types,
query,
signal,
}: {
types: BaseItemKind[];
query: string;
signal?: AbortSignal;
}): Promise<BaseItemDto[]> => {
if (!api || !query) {
return [];
}
try {
const searchApi = await getItemsApi(api).getItems(
{
searchTerm: query,
limit: 10,
includeItemTypes: types,
recursive: true,
userId: user?.Id,
},
{ signal },
);
return (searchApi.data.Items as BaseItemDto[]) || [];
} catch (error) {
// Silently handle aborted requests
if (error instanceof Error && error.name === "AbortError") {
return [];
}
return [];
}
},
[api, user?.Id],
);
type HeaderSearchBarRef = { type HeaderSearchBarRef = {
focus: () => void; focus: () => void;
blur: () => void; blur: () => void;
@@ -243,6 +318,7 @@ export default function search() {
searchFn({ searchFn({
query: debouncedSearch, query: debouncedSearch,
types: ["Movie"], types: ["Movie"],
signal: abortControllerRef.current?.signal,
}), }),
enabled: searchType === "Library" && debouncedSearch.length > 0, enabled: searchType === "Library" && debouncedSearch.length > 0,
}); });
@@ -253,6 +329,7 @@ export default function search() {
searchFn({ searchFn({
query: debouncedSearch, query: debouncedSearch,
types: ["Series"], types: ["Series"],
signal: abortControllerRef.current?.signal,
}), }),
enabled: searchType === "Library" && debouncedSearch.length > 0, enabled: searchType === "Library" && debouncedSearch.length > 0,
}); });
@@ -263,6 +340,7 @@ export default function search() {
searchFn({ searchFn({
query: debouncedSearch, query: debouncedSearch,
types: ["Episode"], types: ["Episode"],
signal: abortControllerRef.current?.signal,
}), }),
enabled: searchType === "Library" && debouncedSearch.length > 0, enabled: searchType === "Library" && debouncedSearch.length > 0,
}); });
@@ -273,6 +351,7 @@ export default function search() {
searchFn({ searchFn({
query: debouncedSearch, query: debouncedSearch,
types: ["BoxSet"], types: ["BoxSet"],
signal: abortControllerRef.current?.signal,
}), }),
enabled: searchType === "Library" && debouncedSearch.length > 0, enabled: searchType === "Library" && debouncedSearch.length > 0,
}); });
@@ -283,6 +362,52 @@ export default function search() {
searchFn({ searchFn({
query: debouncedSearch, query: debouncedSearch,
types: ["Person"], types: ["Person"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
// Music search queries - always use Jellyfin since Streamystats doesn't support music
const { data: artists, isFetching: l9 } = useQuery({
queryKey: ["search", "artists", debouncedSearch],
queryFn: () =>
jellyfinSearchFn({
query: debouncedSearch,
types: ["MusicArtist"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: albums, isFetching: l10 } = useQuery({
queryKey: ["search", "albums", debouncedSearch],
queryFn: () =>
jellyfinSearchFn({
query: debouncedSearch,
types: ["MusicAlbum"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: songs, isFetching: l11 } = useQuery({
queryKey: ["search", "songs", debouncedSearch],
queryFn: () =>
jellyfinSearchFn({
query: debouncedSearch,
types: ["Audio"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: playlists, isFetching: l12 } = useQuery({
queryKey: ["search", "playlists", debouncedSearch],
queryFn: () =>
jellyfinSearchFn({
query: debouncedSearch,
types: ["Playlist"],
signal: abortControllerRef.current?.signal,
}), }),
enabled: searchType === "Library" && debouncedSearch.length > 0, enabled: searchType === "Library" && debouncedSearch.length > 0,
}); });
@@ -293,13 +418,27 @@ export default function search() {
episodes?.length || episodes?.length ||
series?.length || series?.length ||
collections?.length || collections?.length ||
actors?.length actors?.length ||
artists?.length ||
albums?.length ||
songs?.length ||
playlists?.length
); );
}, [episodes, movies, series, collections, actors]); }, [
episodes,
movies,
series,
collections,
actors,
artists,
albums,
songs,
playlists,
]);
const loading = useMemo(() => { const loading = useMemo(() => {
return l1 || l2 || l3 || l7 || l8; return l1 || l2 || l3 || l7 || l8 || l9 || l10 || l11 || l12;
}, [l1, l2, l3, l7, l8]); }, [l1, l2, l3, l7, l8, l9, l10, l11, l12]);
return ( return (
<ScrollView <ScrollView
@@ -308,6 +447,7 @@ export default function search() {
contentContainerStyle={{ contentContainerStyle={{
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
paddingBottom: 60,
}} }}
> >
{/* <View {/* <View
@@ -446,6 +586,172 @@ export default function search() {
</TouchableItemRouter> </TouchableItemRouter>
)} )}
/> />
{/* Music search results */}
<SearchItemWrapper
items={artists}
header={t("search.artists")}
renderItem={(item: BaseItemDto) => {
const imageUrl = getPrimaryImageUrl({ api, item });
return (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-24 mr-2 items-center'
>
<View
style={{
width: 80,
height: 80,
borderRadius: 40,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Text className='text-xl'>👤</Text>
</View>
)}
</View>
<Text numberOfLines={2} className='mt-2 text-center'>
{item.Name}
</Text>
</TouchableItemRouter>
);
}}
/>
<SearchItemWrapper
items={albums}
header={t("search.albums")}
renderItem={(item: BaseItemDto) => {
const imageUrl = getPrimaryImageUrl({ api, item });
return (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-28 mr-2'
>
<View
style={{
width: 112,
height: 112,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Text className='text-4xl'>🎵</Text>
</View>
)}
</View>
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className='opacity-50 text-xs' numberOfLines={1}>
{item.AlbumArtist || item.Artists?.join(", ")}
</Text>
</TouchableItemRouter>
);
}}
/>
<SearchItemWrapper
items={songs}
header={t("search.songs")}
renderItem={(item: BaseItemDto) => {
const imageUrl = getPrimaryImageUrl({ api, item });
return (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-28 mr-2'
>
<View
style={{
width: 112,
height: 112,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Text className='text-4xl'>🎵</Text>
</View>
)}
</View>
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className='opacity-50 text-xs' numberOfLines={1}>
{item.Artists?.join(", ") || item.AlbumArtist}
</Text>
</TouchableItemRouter>
);
}}
/>
<SearchItemWrapper
items={playlists}
header={t("search.playlists")}
renderItem={(item: BaseItemDto) => {
const imageUrl = getPrimaryImageUrl({ api, item });
return (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-28 mr-2'
>
<View
style={{
width: 112,
height: 112,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Text className='text-4xl'>🎶</Text>
</View>
)}
</View>
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className='opacity-50 text-xs'>
{item.ChildCount} tracks
</Text>
</TouchableItemRouter>
);
}}
/>
</View> </View>
) : ( ) : (
<JellyserrIndexPage <JellyserrIndexPage

View File

@@ -1,7 +1,7 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -19,6 +19,7 @@ import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { ItemPoster } from "@/components/posters/ItemPoster"; import { ItemPoster } from "@/components/posters/ItemPoster";
import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { import {
useDeleteWatchlist, useDeleteWatchlist,

View File

@@ -1,8 +1,10 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router"; import { Stack } from "expo-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity } from "react-native"; import { Platform } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import useRouter from "@/hooks/useAppRouter";
import { useStreamystatsEnabled } from "@/hooks/useWatchlists"; import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
export default function WatchlistsLayout() { export default function WatchlistsLayout() {
@@ -22,14 +24,14 @@ export default function WatchlistsLayout() {
headerShadowVisible: false, headerShadowVisible: false,
headerRight: streamystatsEnabled headerRight: streamystatsEnabled
? () => ( ? () => (
<TouchableOpacity <Pressable
onPress={() => onPress={() =>
router.push("/(auth)/(tabs)/(watchlists)/create") router.push("/(auth)/(tabs)/(watchlists)/create")
} }
className='p-1.5' className='p-1.5'
> >
<Ionicons name='add' size={24} color='white' /> <Ionicons name='add' size={24} color='white' />
</TouchableOpacity> </Pressable>
) )
: undefined, : undefined,
}} }}

View File

@@ -1,5 +1,4 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -15,6 +14,7 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { useCreateWatchlist } from "@/hooks/useWatchlistMutations"; import { useCreateWatchlist } from "@/hooks/useWatchlistMutations";
import type { import type {
StreamystatsWatchlistAllowedItemType, StreamystatsWatchlistAllowedItemType,

View File

@@ -1,5 +1,5 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -15,6 +15,7 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { useUpdateWatchlist } from "@/hooks/useWatchlistMutations"; import { useUpdateWatchlist } from "@/hooks/useWatchlistMutations";
import { useWatchlistDetailQuery } from "@/hooks/useWatchlists"; import { useWatchlistDetailQuery } from "@/hooks/useWatchlists";
import type { import type {

View File

@@ -1,6 +1,5 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useRouter } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -8,6 +7,7 @@ import { Platform, RefreshControl, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { import {
useStreamystatsEnabled, useStreamystatsEnabled,
useWatchlistsQuery, useWatchlistsQuery,

View File

@@ -7,8 +7,7 @@ import type {
ParamListBase, ParamListBase,
TabNavigationState, TabNavigationState,
} from "@react-navigation/native"; } from "@react-navigation/native";
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router"; import { withLayoutContext } from "expo-router";
import { useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
@@ -17,7 +16,6 @@ import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
import { storage } from "@/utils/mmkv";
const { Navigator } = createNativeBottomTabNavigator(); const { Navigator } = createNativeBottomTabNavigator();
@@ -31,22 +29,6 @@ export const NativeTabs = withLayoutContext<
export default function TabLayout() { export default function TabLayout() {
const { settings } = useSettings(); const { settings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
useFocusEffect(
useCallback(() => {
const hasShownIntro = storage.getBoolean("hasShownIntro");
if (!hasShownIntro) {
const timer = setTimeout(() => {
router.push("/intro/page");
}, 1000);
return () => {
clearTimeout(timer);
};
}
}, []),
);
return ( return (
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>

View File

@@ -1,12 +1,19 @@
import { ExpoAvRoutePickerView } from "@douglowder/expo-av-route-picker-view";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import type { import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Dimensions, Dimensions,
@@ -20,11 +27,20 @@ import DraggableFlatList, {
type RenderItemParams, type RenderItemParams,
ScaleDecorator, ScaleDecorator,
} from "react-native-draggable-flatlist"; } from "react-native-draggable-flatlist";
import { CastButton, CastState } from "react-native-google-cast";
import { useSharedValue } from "react-native-reanimated"; import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import TextTicker from "react-native-text-ticker";
import type { VolumeResult } from "react-native-volume-manager";
import { Badge } from "@/components/Badge"; import { Badge } from "@/components/Badge";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { apiAtom } from "@/providers/JellyfinProvider"; import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
import useRouter from "@/hooks/useAppRouter";
import { useFavorite } from "@/hooks/useFavorite";
import { useMusicCast } from "@/hooks/useMusicCast";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { import {
type RepeatMode, type RepeatMode,
useMusicPlayer, useMusicPlayer,
@@ -32,6 +48,11 @@ import {
import { formatBitrate } from "@/utils/bitrate"; import { formatBitrate } from "@/utils/bitrate";
import { formatDuration } from "@/utils/time"; import { formatDuration } from "@/utils/time";
// Conditionally require VolumeManager (not available on TV)
const VolumeManager = Platform.isTV
? null
: require("react-native-volume-manager");
const formatFileSize = (bytes?: number | null) => { const formatFileSize = (bytes?: number | null) => {
if (!bytes) return null; if (!bytes) return null;
const sizes = ["B", "KB", "MB", "GB"]; const sizes = ["B", "KB", "MB", "GB"];
@@ -52,9 +73,22 @@ type ViewMode = "player" | "queue";
export default function NowPlayingScreen() { export default function NowPlayingScreen() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [viewMode, setViewMode] = useState<ViewMode>("player"); const [viewMode, setViewMode] = useState<ViewMode>("player");
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
const {
isConnected: isCastConnected,
castQueue,
castState,
} = useMusicCast({
api,
userId: user?.Id,
});
const { const {
currentTrack, currentTrack,
@@ -78,8 +112,13 @@ export default function NowPlayingScreen() {
removeFromQueue, removeFromQueue,
reorderQueue, reorderQueue,
stop, stop,
pause,
} = useMusicPlayer(); } = useMusicPlayer();
const { isFavorite, toggleFavorite } = useFavorite(
currentTrack ?? ({ Id: "" } as BaseItemDto),
);
const sliderProgress = useSharedValue(0); const sliderProgress = useSharedValue(0);
const sliderMin = useSharedValue(0); const sliderMin = useSharedValue(0);
const sliderMax = useSharedValue(1); const sliderMax = useSharedValue(1);
@@ -92,6 +131,21 @@ export default function NowPlayingScreen() {
sliderMax.value = duration > 0 ? duration : 1; sliderMax.value = duration > 0 ? duration : 1;
}, [duration, sliderMax]); }, [duration, sliderMax]);
// Auto-cast queue when Chromecast becomes connected and pause local playback
const prevCastState = useRef<CastState | null | undefined>(null);
useEffect(() => {
if (
castState === CastState.CONNECTED &&
prevCastState.current !== CastState.CONNECTED &&
queue.length > 0
) {
// Just connected - pause local playback and cast the queue
pause();
castQueue({ queue, startIndex: queueIndex });
}
prevCastState.current = castState;
}, [castState, queue, queueIndex, castQueue, pause]);
const imageUrl = useMemo(() => { const imageUrl = useMemo(() => {
if (!api || !currentTrack) return null; if (!api || !currentTrack) return null;
const albumId = currentTrack.AlbumId || currentTrack.ParentId; const albumId = currentTrack.AlbumId || currentTrack.ParentId;
@@ -106,11 +160,17 @@ export default function NowPlayingScreen() {
return formatDuration(progressTicks); return formatDuration(progressTicks);
}, [progress]); }, [progress]);
const durationText = useMemo(() => { const _durationText = useMemo(() => {
const durationTicks = duration * 10000000; const durationTicks = duration * 10000000;
return formatDuration(durationTicks); return formatDuration(durationTicks);
}, [duration]); }, [duration]);
const remainingText = useMemo(() => {
const remaining = Math.max(0, duration - progress);
const remainingTicks = remaining * 10000000;
return `-${formatDuration(remainingTicks)}`;
}, [duration, progress]);
const handleSliderComplete = useCallback( const handleSliderComplete = useCallback(
(value: number) => { (value: number) => {
seek(value); seek(value);
@@ -134,6 +194,18 @@ export default function NowPlayingScreen() {
setRepeatMode(nextMode); setRepeatMode(nextMode);
}, [repeatMode, setRepeatMode]); }, [repeatMode, setRepeatMode]);
const handleOptionsPress = useCallback(() => {
setTrackOptionsOpen(true);
}, []);
const handleAddToPlaylist = useCallback(() => {
setPlaylistPickerOpen(true);
}, []);
const handleCreateNewPlaylist = useCallback(() => {
setCreatePlaylistOpen(true);
}, []);
const getRepeatIcon = (): string => { const getRepeatIcon = (): string => {
switch (repeatMode) { switch (repeatMode) {
case "one": case "one":
@@ -150,6 +222,7 @@ export default function NowPlayingScreen() {
if (!currentTrack) { if (!currentTrack) {
return ( return (
<BottomSheetModalProvider>
<View <View
className='flex-1 bg-[#121212] items-center justify-center' className='flex-1 bg-[#121212] items-center justify-center'
style={{ style={{
@@ -159,10 +232,12 @@ export default function NowPlayingScreen() {
> >
<Text className='text-neutral-500'>No track playing</Text> <Text className='text-neutral-500'>No track playing</Text>
</View> </View>
</BottomSheetModalProvider>
); );
} }
return ( return (
<BottomSheetModalProvider>
<View <View
className='flex-1 bg-[#121212]' className='flex-1 bg-[#121212]'
style={{ style={{
@@ -210,7 +285,8 @@ export default function NowPlayingScreen() {
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View style={{ width: 16 }} /> {/* Empty placeholder to balance header layout */}
<View className='p-2' style={{ width: 44 }} />
</View> </View>
{viewMode === "player" ? ( {viewMode === "player" ? (
@@ -222,7 +298,7 @@ export default function NowPlayingScreen() {
sliderMin={sliderMin} sliderMin={sliderMin}
sliderMax={sliderMax} sliderMax={sliderMax}
progressText={progressText} progressText={progressText}
durationText={durationText} remainingText={remainingText}
isPlaying={isPlaying} isPlaying={isPlaying}
isLoading={isLoading} isLoading={isLoading}
repeatMode={repeatMode} repeatMode={repeatMode}
@@ -236,10 +312,12 @@ export default function NowPlayingScreen() {
onCycleRepeat={cycleRepeatMode} onCycleRepeat={cycleRepeatMode}
onToggleShuffle={toggleShuffle} onToggleShuffle={toggleShuffle}
getRepeatIcon={getRepeatIcon} getRepeatIcon={getRepeatIcon}
queue={queue}
queueIndex={queueIndex}
mediaSource={mediaSource} mediaSource={mediaSource}
isTranscoding={isTranscoding} isTranscoding={isTranscoding}
isFavorite={isFavorite}
onToggleFavorite={toggleFavorite}
onOptionsPress={handleOptionsPress}
isCastConnected={isCastConnected}
/> />
) : ( ) : (
<QueueView <QueueView
@@ -251,7 +329,26 @@ export default function NowPlayingScreen() {
onReorderQueue={reorderQueue} onReorderQueue={reorderQueue}
/> />
)} )}
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={currentTrack}
onAddToPlaylist={handleAddToPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={currentTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={currentTrack?.Id}
/>
</View> </View>
</BottomSheetModalProvider>
); );
} }
@@ -263,7 +360,7 @@ interface PlayerViewProps {
sliderMin: any; sliderMin: any;
sliderMax: any; sliderMax: any;
progressText: string; progressText: string;
durationText: string; remainingText: string;
isPlaying: boolean; isPlaying: boolean;
isLoading: boolean; isLoading: boolean;
repeatMode: RepeatMode; repeatMode: RepeatMode;
@@ -277,10 +374,12 @@ interface PlayerViewProps {
onCycleRepeat: () => void; onCycleRepeat: () => void;
onToggleShuffle: () => void; onToggleShuffle: () => void;
getRepeatIcon: () => string; getRepeatIcon: () => string;
queue: BaseItemDto[];
queueIndex: number;
mediaSource: MediaSourceInfo | null; mediaSource: MediaSourceInfo | null;
isTranscoding: boolean; isTranscoding: boolean;
isFavorite: boolean | undefined;
onToggleFavorite: () => void;
onOptionsPress: () => void;
isCastConnected: boolean;
} }
const PlayerView: React.FC<PlayerViewProps> = ({ const PlayerView: React.FC<PlayerViewProps> = ({
@@ -290,7 +389,7 @@ const PlayerView: React.FC<PlayerViewProps> = ({
sliderMin, sliderMin,
sliderMax, sliderMax,
progressText, progressText,
durationText, remainingText,
isPlaying, isPlaying,
isLoading, isLoading,
repeatMode, repeatMode,
@@ -304,15 +403,42 @@ const PlayerView: React.FC<PlayerViewProps> = ({
onCycleRepeat, onCycleRepeat,
onToggleShuffle, onToggleShuffle,
getRepeatIcon, getRepeatIcon,
queue,
queueIndex,
mediaSource, mediaSource,
isTranscoding, isTranscoding,
isFavorite,
onToggleFavorite,
onOptionsPress,
isCastConnected,
}) => { }) => {
const audioStream = useMemo(() => { const audioStream = useMemo(() => {
return mediaSource?.MediaStreams?.find((stream) => stream.Type === "Audio"); return mediaSource?.MediaStreams?.find((stream) => stream.Type === "Audio");
}, [mediaSource]); }, [mediaSource]);
// Volume slider state
const volumeProgress = useSharedValue(0);
const volumeMin = useSharedValue(0);
const volumeMax = useSharedValue(1);
const isTv = Platform.isTV;
useEffect(() => {
if (isTv || !VolumeManager) return;
// Get initial volume
VolumeManager.getVolume().then(({ volume }: { volume: number }) => {
volumeProgress.value = volume;
});
// Listen to volume changes
const listener = VolumeManager.addVolumeListener((result: VolumeResult) => {
volumeProgress.value = result.volume;
});
return () => listener.remove();
}, [isTv, volumeProgress]);
const handleVolumeChange = useCallback((value: number) => {
if (VolumeManager) {
VolumeManager.setVolume(value);
}
}, []);
const fileSize = formatFileSize(mediaSource?.Size); const fileSize = formatFileSize(mediaSource?.Size);
const codec = audioStream?.Codec?.toUpperCase(); const codec = audioStream?.Codec?.toUpperCase();
const bitrate = formatBitrate(audioStream?.BitRate); const bitrate = formatBitrate(audioStream?.BitRate);
@@ -353,19 +479,60 @@ const PlayerView: React.FC<PlayerViewProps> = ({
)} )}
</View> </View>
{/* Track info */} {/* Track info with actions */}
<View className='mb-6'> <View className='mb-6'>
<Text numberOfLines={1} className='text-white text-2xl font-bold'> <View className='flex-row items-start justify-between'>
<View className='flex-1 mr-4'>
<TextTicker
style={{ color: "white", fontSize: 24, fontWeight: "bold" }}
duration={Math.max(4000, (currentTrack.Name?.length || 0) * 250)}
loop
bounce={false}
repeatSpacer={80}
marqueeDelay={1500}
scroll={false}
animationType='scroll'
easing={(t) => t}
>
{currentTrack.Name} {currentTrack.Name}
</Text> </TextTicker>
<Text numberOfLines={1} className='text-purple-400 text-lg mt-1'> <TextTicker
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist} style={{ color: "#a3a3a3", fontSize: 18 }}
</Text> duration={Math.max(
{currentTrack.Album && ( 4000,
<Text numberOfLines={1} className='text-neutral-500 text-sm mt-1'> (
{currentTrack.Album} currentTrack.Artists?.join(", ") ||
</Text> currentTrack.AlbumArtist ||
""
).length * 250,
)} )}
loop
bounce={false}
repeatSpacer={80}
marqueeDelay={2000}
scroll={false}
animationType='scroll'
easing={(t) => t}
>
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
</TextTicker>
</View>
<TouchableOpacity
onPress={onToggleFavorite}
className='p-2'
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons
name={isFavorite ? "heart" : "heart-outline"}
size={24}
color={isFavorite ? "#ec4899" : "white"}
/>
</TouchableOpacity>
<TouchableOpacity onPress={onOptionsPress} className='p-2'>
<Ionicons name='ellipsis-horizontal' size={24} color='white' />
</TouchableOpacity>
</View>
{/* Audio Stats */} {/* Audio Stats */}
{hasAudioStats && ( {hasAudioStats && (
@@ -395,28 +562,36 @@ const PlayerView: React.FC<PlayerViewProps> = ({
<View className='mb-4'> <View className='mb-4'>
<Slider <Slider
theme={{ theme={{
maximumTrackTintColor: "#333", maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#9334E9", minimumTrackTintColor: "#fff",
bubbleBackgroundColor: "#9334E9", bubbleBackgroundColor: "#fff",
bubbleTextColor: "#fff", bubbleTextColor: "#666",
}} }}
progress={sliderProgress} progress={sliderProgress}
minimumValue={sliderMin} minimumValue={sliderMin}
maximumValue={sliderMax} maximumValue={sliderMax}
onSlidingComplete={onSliderComplete} onSlidingComplete={onSliderComplete}
thumbWidth={16} renderThumb={() => null}
sliderHeight={6} sliderHeight={8}
containerStyle={{ borderRadius: 10 }} containerStyle={{ borderRadius: 100 }}
renderBubble={() => null} renderBubble={() => null}
/> />
<View className='flex flex-row justify-between px-1 mt-2'> <View className='flex flex-row justify-between mt-2'>
<Text className='text-neutral-500 text-xs'>{progressText}</Text> <Text className='text-neutral-500 text-xs'>{progressText}</Text>
<Text className='text-neutral-500 text-xs'>{durationText}</Text> <Text className='text-neutral-500 text-xs'>{remainingText}</Text>
</View> </View>
</View> </View>
{/* Main Controls */} {/* Main Controls with Shuffle & Repeat */}
<View className='flex flex-row items-center justify-center mb-2'> <View className='flex flex-row items-center justify-center mb-6'>
<TouchableOpacity onPress={onToggleShuffle} className='p-3'>
<Ionicons
name='shuffle'
size={24}
color={shuffleEnabled ? "#9334E9" : "#666"}
/>
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={onPrevious} onPress={onPrevious}
disabled={!canGoPrevious || isLoading} disabled={!canGoPrevious || isLoading}
@@ -429,7 +604,7 @@ const PlayerView: React.FC<PlayerViewProps> = ({
<TouchableOpacity <TouchableOpacity
onPress={onTogglePlayPause} onPress={onTogglePlayPause}
disabled={isLoading} disabled={isLoading}
className='mx-8 bg-white rounded-full p-4' className='mx-4 bg-white rounded-full p-4'
> >
{isLoading ? ( {isLoading ? (
<ActivityIndicator size={36} color='#121212' /> <ActivityIndicator size={36} color='#121212' />
@@ -451,38 +626,75 @@ const PlayerView: React.FC<PlayerViewProps> = ({
> >
<Ionicons name='play-skip-forward' size={32} color='white' /> <Ionicons name='play-skip-forward' size={32} color='white' />
</TouchableOpacity> </TouchableOpacity>
</View>
{/* Shuffle & Repeat Controls */} <TouchableOpacity onPress={onCycleRepeat} className='p-3 relative'>
<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 <Ionicons
name={getRepeatIcon() as any} name={getRepeatIcon() as any}
size={24} size={24}
color={repeatMode !== "off" ? "#9334E9" : "#666"} color={repeatMode !== "off" ? "#9334E9" : "#666"}
/> />
{repeatMode === "one" && ( {repeatMode === "one" && (
<View className='absolute right-0 bg-purple-600 rounded-full w-4 h-4 items-center justify-center'> <View className='absolute right-0 top-1 bg-purple-600 rounded-full w-4 h-4 items-center justify-center'>
<Text className='text-white text-[10px] font-bold'>1</Text> <Text className='text-white text-[10px] font-bold'>1</Text>
</View> </View>
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Queue info */} {/* Volume Slider */}
{queue.length > 1 && ( {!isTv && VolumeManager && (
<View className='items-center mb-4'> <View className='flex-row items-center mb-6'>
<Text className='text-neutral-500 text-sm'> <Ionicons name='volume-low' size={20} color='#666' />
{queueIndex + 1} of {queue.length} <View className='flex-1 mx-3'>
</Text> <Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
}}
progress={volumeProgress}
minimumValue={volumeMin}
maximumValue={volumeMax}
onSlidingComplete={handleVolumeChange}
renderThumb={() => null}
sliderHeight={8}
containerStyle={{ borderRadius: 100 }}
renderBubble={() => null}
/>
</View>
<Ionicons name='volume-high' size={20} color='#666' />
</View>
)}
{/* AirPlay & Chromecast Buttons */}
{!isTv && (
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 32,
marginBottom: 16,
}}
>
{/* AirPlay (iOS only) */}
{Platform.OS === "ios" && (
<View style={{ transform: [{ scale: 2.8 }] }}>
<ExpoAvRoutePickerView
style={{ width: 24, height: 24 }}
tintColor='#666666'
activeTintColor='#9334E9'
/>
</View>
)}
{/* Chromecast */}
<CastButton
style={{
width: 24,
height: 24,
tintColor: isCastConnected ? "#9334E9" : "#666",
transform: [{ translateY: 1 }],
}}
/>
</View> </View>
)} )}
</ScrollView> </ScrollView>

View File

@@ -3,7 +3,6 @@ import {
type MediaSourceInfo, type MediaSourceInfo,
PlaybackOrder, PlaybackOrder,
PlaybackProgressInfo, PlaybackProgressInfo,
PlaybackStartInfo,
RepeatMode, RepeatMode,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { import {
@@ -11,13 +10,12 @@ import {
getUserLibraryApi, getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { router, useGlobalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, Platform, View } from "react-native"; import { Alert, Platform, useWindowDimensions, View } from "react-native";
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated"; import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
import { BITRATES } from "@/components/BitrateSelector"; import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
@@ -28,6 +26,7 @@ import {
PlaybackSpeedScope, PlaybackSpeedScope,
updatePlaybackSpeedSettings, updatePlaybackSpeedSettings,
} from "@/components/video-player/controls/utils/playback-speed-settings"; } from "@/components/video-player/controls/utils/playback-speed-settings";
import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { usePlaybackManager } from "@/hooks/usePlaybackManager";
@@ -35,24 +34,20 @@ import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets"; import { useWebSocket } from "@/hooks/useWebsockets";
import { import {
type PlaybackStatePayload, type MpvOnErrorEventPayload,
type ProgressUpdatePayload, type MpvOnPlaybackStateChangePayload,
type SfOnErrorEventPayload, type MpvOnProgressEventPayload,
type SfOnPictureInPictureChangePayload, MpvPlayerView,
type SfOnPlaybackStateChangePayload, type MpvPlayerViewRef,
type SfOnProgressEventPayload, type MpvVideoSource,
SfPlayerView,
type SfPlayerViewRef,
type SfVideoSource,
setHardwareDecode,
type VlcPlayerSource,
VlcPlayerView,
type VlcPlayerViewRef,
} from "@/modules"; } from "@/modules";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types"; import { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings, VideoPlayerIOS } from "@/utils/atoms/settings";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { import {
getMpvAudioId, getMpvAudioId,
@@ -63,29 +58,22 @@ import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time"; import { msToTicks, ticksToSeconds } from "@/utils/time";
export default function page() { export default function page() {
const videoRef = useRef<SfPlayerViewRef | VlcPlayerViewRef>(null); const videoRef = useRef<MpvPlayerViewRef>(null);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const navigation = useNavigation(); const navigation = useNavigation();
const router = useRouter();
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
// Determine which player to use: const { width: screenWidth, height: screenHeight } = useWindowDimensions();
// - Android always uses VLC
// - iOS uses user setting (KSPlayer by default, VLC optional)
const useVlcPlayer =
Platform.OS === "android" ||
(Platform.OS === "ios" && settings.videoPlayerIOS === VideoPlayerIOS.VLC);
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
const [isPipMode, setIsPipMode] = useState(false); const [isPipMode, setIsPipMode] = useState(false);
const [aspectRatio, setAspectRatio] = useState< const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">(
"default" | "16:9" | "4:3" | "1:1" | "21:9" "default",
>("default"); );
const [scaleFactor, setScaleFactor] = useState<
0 | 0.25 | 0.5 | 0.75 | 1.0 | 1.25 | 1.5 | 2.0
>(0);
const [isZoomedToFill, setIsZoomedToFill] = useState(false); const [isZoomedToFill, setIsZoomedToFill] = useState(false);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
@@ -94,6 +82,7 @@ export default function page() {
const [tracksReady, setTracksReady] = useState(false); const [tracksReady, setTracksReady] = useState(false);
const [hasPlaybackStarted, setHasPlaybackStarted] = useState(false); const [hasPlaybackStarted, setHasPlaybackStarted] = useState(false);
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0); const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0);
const [showTechnicalInfo, setShowTechnicalInfo] = useState(false);
const progress = useSharedValue(0); const progress = useSharedValue(0);
const isSeeking = useSharedValue(false); const isSeeking = useSharedValue(false);
@@ -103,10 +92,9 @@ export default function page() {
: require("react-native-volume-manager"); : require("react-native-volume-manager");
const downloadUtils = useDownload(); const downloadUtils = useDownload();
const downloadedFiles = useMemo( // Call directly instead of useMemo - the function reference doesn't change
() => downloadUtils.getDownloadedItems(), // when data updates, only when the provider initializes
[downloadUtils.getDownloadedItems], const downloadedFiles = downloadUtils.getDownloadedItems();
);
const revalidateProgressCache = useInvalidatePlaybackProgressCache(); const revalidateProgressCache = useInvalidatePlaybackProgressCache();
@@ -125,7 +113,7 @@ export default function page() {
bitrateValue: bitrateValueStr, bitrateValue: bitrateValueStr,
offline: offlineStr, offline: offlineStr,
playbackPosition: playbackPositionFromUrl, playbackPosition: playbackPositionFromUrl,
} = useGlobalSearchParams<{ } = useLocalSearchParams<{
itemId: string; itemId: string;
audioIndex: string; audioIndex: string;
subtitleIndex: string; subtitleIndex: string;
@@ -140,7 +128,9 @@ export default function page() {
const offline = offlineStr === "true"; const offline = offlineStr === "true";
const playbackManager = usePlaybackManager({ isOffline: offline }); const playbackManager = usePlaybackManager({ isOffline: offline });
const audioIndex = audioIndexStr // Audio index: use URL param if provided, otherwise use stored index for offline playback
// This is computed after downloadedItem is available, see audioIndexResolved below
const audioIndexFromUrl = audioIndexStr
? Number.parseInt(audioIndexStr, 10) ? Number.parseInt(audioIndexStr, 10)
: undefined; : undefined;
const subtitleIndex = subtitleIndexStr const subtitleIndex = subtitleIndexStr
@@ -159,6 +149,17 @@ export default function page() {
isError: false, isError: false,
}); });
// Resolve audio index: use URL param if provided, otherwise use stored index for offline playback
const audioIndex = useMemo(() => {
if (audioIndexFromUrl !== undefined) {
return audioIndexFromUrl;
}
if (offline && downloadedItem?.userData?.audioStreamIndex !== undefined) {
return downloadedItem.userData.audioStreamIndex;
}
return undefined;
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
// Get the playback speed for this item based on settings // Get the playback speed for this item based on settings
const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed( const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed(
item, item,
@@ -177,15 +178,11 @@ export default function page() {
updateSettings, updateSettings,
); );
// Apply speed to the current player // Apply speed to the current player (MPV)
setCurrentPlaybackSpeed(speed); setCurrentPlaybackSpeed(speed);
if (useVlcPlayer) { await videoRef.current?.setSpeed?.(speed);
await (videoRef.current as VlcPlayerViewRef)?.setRate?.(speed);
} else {
await (videoRef.current as SfPlayerViewRef)?.setSpeed?.(speed);
}
}, },
[item, settings, updateSettings, useVlcPlayer], [item, settings, updateSettings],
); );
/** Gets the initial playback position from the URL. */ /** Gets the initial playback position from the URL. */
@@ -333,9 +330,12 @@ export default function page() {
useEffect(() => { useEffect(() => {
if (!stream || !api || offline) return; if (!stream || !api || offline) return;
const reportPlaybackStart = async () => { const reportPlaybackStart = async () => {
const progressInfo = currentPlayStateInfo();
if (progressInfo) {
await getPlaystateApi(api).reportPlaybackStart({ await getPlaystateApi(api).reportPlaybackStart({
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo, playbackStartInfo: progressInfo,
}); });
}
}; };
reportPlaybackStart(); reportPlaybackStart();
}, [stream, api, offline]); }, [stream, api, offline]);
@@ -345,14 +345,16 @@ export default function page() {
setIsPlaying(!isPlaying); setIsPlaying(!isPlaying);
if (isPlaying) { if (isPlaying) {
await videoRef.current?.pause(); await videoRef.current?.pause();
playbackManager.reportPlaybackProgress( const progressInfo = currentPlayStateInfo();
currentPlayStateInfo() as PlaybackProgressInfo, if (progressInfo) {
); playbackManager.reportPlaybackProgress(progressInfo);
}
} else { } else {
videoRef.current?.play(); videoRef.current?.play();
const progressInfo = currentPlayStateInfo();
if (!offline && api) { if (!offline && api) {
await getPlaystateApi(api).reportPlaybackStart({ await getPlaystateApi(api).reportPlaybackStart({
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo, playbackStartInfo: progressInfo,
}); });
} }
} }
@@ -385,7 +387,6 @@ export default function page() {
}); });
reportPlaybackStopped(); reportPlaybackStopped();
setIsPlaybackStopped(true); setIsPlaybackStopped(true);
// KSPlayer doesn't have a stop method, use pause instead
videoRef.current?.pause(); videoRef.current?.pause();
revalidateProgressCache(); revalidateProgressCache();
}, [videoRef, reportPlaybackStopped, progress]); }, [videoRef, reportPlaybackStopped, progress]);
@@ -397,22 +398,24 @@ export default function page() {
}; };
}, [navigation, stop]); }, [navigation, stop]);
const currentPlayStateInfo = useCallback(() => { const currentPlayStateInfo = useCallback(():
| PlaybackProgressInfo
| undefined => {
if (!stream || !item?.Id) return; if (!stream || !item?.Id) return;
return { return {
itemId: item.Id, ItemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined, AudioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, SubtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId, MediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.get()), PositionTicks: msToTicks(progress.get()),
isPaused: !isPlaying, IsPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", PlayMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId, PlaySessionId: stream.sessionId,
isMuted: isMuted, IsMuted: isMuted,
canSeek: true, CanSeek: true,
repeatMode: RepeatMode.RepeatNone, RepeatMode: RepeatMode.RepeatNone,
playbackOrder: PlaybackOrder.Default, PlaybackOrder: PlaybackOrder.Default,
}; };
}, [ }, [
stream, stream,
@@ -441,13 +444,13 @@ export default function page() {
[], [],
); );
/** Progress handler for iOS (SfPlayer) - position in seconds */ /** Progress handler for MPV - position in seconds */
const onProgressSf = useCallback( const onProgress = useCallback(
async (data: { nativeEvent: SfOnProgressEventPayload }) => { async (data: { nativeEvent: MpvOnProgressEventPayload }) => {
if (isSeeking.get() || isPlaybackStopped) return; if (isSeeking.get() || isPlaybackStopped) return;
const { position } = data.nativeEvent; const { position } = data.nativeEvent;
// KSPlayer reports position in seconds, convert to ms // MPV reports position in seconds, convert to ms
const currentTime = position * 1000; const currentTime = position * 1000;
if (isBuffering) { if (isBuffering) {
@@ -490,68 +493,21 @@ export default function page() {
], ],
); );
/** Progress handler for Android (VLC) - currentTime in milliseconds */
const onProgressVlc = useCallback(
async (data: ProgressUpdatePayload) => {
if (isSeeking.get() || isPlaybackStopped) return;
const { currentTime } = data.nativeEvent;
// VLC reports currentTime in milliseconds
if (isBuffering) {
setIsBuffering(false);
}
progress.set(currentTime);
// Update URL immediately after seeking, or every 30 seconds during normal playback
const now = Date.now();
const shouldUpdateUrl = wasJustSeeking.get();
wasJustSeeking.value = false;
if (
shouldUpdateUrl ||
now - lastUrlUpdateTime.get() > URL_UPDATE_INTERVAL
) {
router.setParams({
playbackPosition: msToTicks(currentTime).toString(),
});
lastUrlUpdateTime.value = now;
}
if (!item?.Id) return;
playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo,
);
},
[
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
isPlaying,
stream,
isSeeking,
isPlaybackStopped,
isBuffering,
],
);
/** Gets the initial playback position in seconds. */ /** Gets the initial playback position in seconds. */
const startPosition = useMemo(() => { const _startPosition = useMemo(() => {
return ticksToSeconds(getInitialPlaybackTicks()); return ticksToSeconds(getInitialPlaybackTicks());
}, [getInitialPlaybackTicks]); }, [getInitialPlaybackTicks]);
/** Build video source config for iOS (SfPlayer/KSPlayer) */ /** Build video source config for MPV */
const sfVideoSource = useMemo<SfVideoSource | undefined>(() => { const videoSource = useMemo<MpvVideoSource | undefined>(() => {
if (!stream?.url || useVlcPlayer) return undefined; if (!stream?.url) return undefined;
const mediaSource = stream.mediaSource; const mediaSource = stream.mediaSource;
const isTranscoding = Boolean(mediaSource?.TranscodingUrl); const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
// For offline playback, subtitles are embedded in the downloaded file // Get external subtitle URLs
// For online playback, get external subtitle URLs from server // - Online: prepend API base path to server URLs
// - Offline: use local file paths (stored in DeliveryUrl during download)
let externalSubs: string[] | undefined; let externalSubs: string[] | undefined;
if (!offline && api?.basePath) { if (!offline && api?.basePath) {
externalSubs = mediaSource?.MediaStreams?.filter( externalSubs = mediaSource?.MediaStreams?.filter(
@@ -560,6 +516,13 @@ export default function page() {
s.DeliveryMethod === "External" && s.DeliveryMethod === "External" &&
s.DeliveryUrl, s.DeliveryUrl,
).map((s) => `${api.basePath}${s.DeliveryUrl}`); ).map((s) => `${api.basePath}${s.DeliveryUrl}`);
} else if (offline) {
externalSubs = mediaSource?.MediaStreams?.filter(
(s) =>
s.Type === "Subtitle" &&
s.DeliveryMethod === "External" &&
s.DeliveryUrl,
).map((s) => s.DeliveryUrl!);
} }
// Calculate track IDs for initial selection // Calculate track IDs for initial selection
@@ -568,7 +531,11 @@ export default function page() {
subtitleIndex, subtitleIndex,
isTranscoding, isTranscoding,
); );
const initialAudioId = getMpvAudioId(mediaSource, audioIndex); const initialAudioId = getMpvAudioId(
mediaSource,
audioIndex,
isTranscoding,
);
// Calculate start position directly here to avoid timing issues // Calculate start position directly here to avoid timing issues
const startTicks = playbackPositionFromUrl const startTicks = playbackPositionFromUrl
@@ -576,15 +543,10 @@ export default function page() {
: (item?.UserData?.PlaybackPositionTicks ?? 0); : (item?.UserData?.PlaybackPositionTicks ?? 0);
const startPos = ticksToSeconds(startTicks); const startPos = ticksToSeconds(startTicks);
// For transcoded streams, the server already handles seeking via startTimeTicks,
// so we should NOT also tell the player to seek (would cause double-seeking).
// For direct play/stream, the player needs to seek itself.
const playerStartPos = isTranscoding ? 0 : startPos;
// Build source config - headers only needed for online streaming // Build source config - headers only needed for online streaming
const source: SfVideoSource = { const source: MpvVideoSource = {
url: stream.url, url: stream.url,
startPosition: playerStartPos, startPosition: startPos,
autoplay: true, autoplay: true,
initialSubtitleId, initialSubtitleId,
initialAudioId, initialAudioId,
@@ -613,96 +575,6 @@ export default function page() {
subtitleIndex, subtitleIndex,
audioIndex, audioIndex,
offline, offline,
useVlcPlayer,
]);
/** Build video source config for Android (VLC) */
const vlcVideoSource = useMemo<VlcPlayerSource | undefined>(() => {
if (!stream?.url || !useVlcPlayer) return undefined;
const mediaSource = stream.mediaSource;
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
// For VLC, external subtitles need name and DeliveryUrl
let externalSubs: { name: string; DeliveryUrl: string }[] | undefined;
if (!offline && api?.basePath) {
externalSubs = mediaSource?.MediaStreams?.filter(
(s) =>
s.Type === "Subtitle" &&
s.DeliveryMethod === "External" &&
s.DeliveryUrl,
).map((s) => ({
name: s.DisplayTitle || s.Title || `Subtitle ${s.Index}`,
DeliveryUrl: `${api.basePath}${s.DeliveryUrl}`,
}));
}
// Build VLC init options (required for VLC to work properly)
const initOptions: string[] = [""];
// Get all subtitle and audio streams
const allSubs =
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") ?? [];
const textSubs = allSubs.filter((s) => s.IsTextSubtitleStream);
const allAudio =
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? [];
// Find chosen tracks
const chosenSubtitleTrack = allSubs.find((s) => s.Index === subtitleIndex);
const chosenAudioTrack = allAudio.find((a) => a.Index === audioIndex);
// Set subtitle track
if (
chosenSubtitleTrack &&
(!isTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
) {
const finalIndex = !isTranscoding
? allSubs.indexOf(chosenSubtitleTrack)
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
if (finalIndex >= 0) {
initOptions.push(`--sub-track=${finalIndex}`);
}
}
// Set audio track
if (!isTranscoding && chosenAudioTrack) {
const audioTrackIndex = allAudio.indexOf(chosenAudioTrack);
if (audioTrackIndex >= 0) {
initOptions.push(`--audio-track=${audioTrackIndex}`);
}
}
// Add subtitle styling
if (settings.subtitleSize) {
initOptions.push(`--sub-text-scale=${settings.subtitleSize}`);
}
initOptions.push("--sub-margin=40");
// For transcoded streams, the server already handles seeking via startTimeTicks,
// so we should NOT also tell the player to seek (would cause double-seeking).
// For direct play/stream, the player needs to seek itself.
const playerStartPos = isTranscoding ? 0 : startPosition;
const source: VlcPlayerSource = {
uri: stream.url,
startPosition: playerStartPos,
autoplay: true,
isNetwork: !offline,
externalSubtitles: externalSubs,
initOptions,
};
return source;
}, [
stream?.url,
stream?.mediaSource,
startPosition,
useVlcPlayer,
api?.basePath,
offline,
subtitleIndex,
audioIndex,
settings.subtitleSize,
]); ]);
const volumeUpCb = useCallback(async () => { const volumeUpCb = useCallback(async () => {
@@ -784,9 +656,9 @@ export default function page() {
setVolume: setVolumeCb, setVolume: setVolumeCb,
}); });
/** Playback state handler for iOS (SfPlayer) */ /** Playback state handler for MPV */
const onPlaybackStateChangedSf = useCallback( const onPlaybackStateChanged = useCallback(
async (e: { nativeEvent: SfOnPlaybackStateChangePayload }) => { async (e: { nativeEvent: MpvOnPlaybackStateChangePayload }) => {
const { isPaused, isPlaying: playing, isLoading } = e.nativeEvent; const { isPaused, isPlaying: playing, isLoading } = e.nativeEvent;
if (playing) { if (playing) {
@@ -813,56 +685,16 @@ export default function page() {
return; return;
} }
if (isLoading) { if (isLoading !== undefined) {
setIsBuffering(true); setIsBuffering(isLoading);
} }
}, },
[playbackManager, item?.Id, progress], [playbackManager, item?.Id, progress],
); );
/** Playback state handler for Android (VLC) */ /** PiP handler for MPV */
const onPlaybackStateChangedVlc = useCallback( const _onPictureInPictureChange = useCallback(
async (e: PlaybackStatePayload) => { (e: { nativeEvent: { isActive: boolean } }) => {
const {
state,
isBuffering: buffering,
isPlaying: playing,
} = e.nativeEvent;
if (state === "Playing" || playing) {
setIsPlaying(true);
setIsBuffering(false);
setHasPlaybackStarted(true);
if (item?.Id) {
playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo,
);
}
if (!Platform.isTV) await activateKeepAwakeAsync();
return;
}
if (state === "Paused") {
setIsPlaying(false);
if (item?.Id) {
playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo,
);
}
if (!Platform.isTV) await deactivateKeepAwake();
return;
}
if (state === "Buffering" || buffering) {
setIsBuffering(true);
}
},
[playbackManager, item?.Id, progress],
);
/** PiP handler for iOS (SfPlayer) */
const onPictureInPictureChangeSf = useCallback(
(e: { nativeEvent: SfOnPictureInPictureChangePayload }) => {
const { isActive } = e.nativeEvent; const { isActive } = e.nativeEvent;
setIsPipMode(isActive); setIsPipMode(isActive);
// Hide controls when entering PiP // Hide controls when entering PiP
@@ -873,19 +705,6 @@ export default function page() {
[], [],
); );
/** PiP handler for Android (VLC) */
const onPipStartedVlc = useCallback(
(e: { nativeEvent: { pipStarted: boolean } }) => {
const { pipStarted } = e.nativeEvent;
setIsPipMode(pipStarted);
// Hide controls when entering PiP
if (pipStarted) {
_setShowControls(false);
}
},
[],
);
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
// Add useEffect to handle mounting // Add useEffect to handle mounting
@@ -907,96 +726,132 @@ export default function page() {
videoRef.current?.pause?.(); videoRef.current?.pause?.();
}, []); }, []);
const seek = useCallback( const seek = useCallback((position: number) => {
(position: number) => { // MPV expects seconds, convert from ms
if (useVlcPlayer) {
// VLC expects milliseconds
videoRef.current?.seekTo?.(position);
} else {
// KSPlayer expects seconds, convert from ms
videoRef.current?.seekTo?.(position / 1000); videoRef.current?.seekTo?.(position / 1000);
}, []);
// Technical info toggle handler
const handleToggleTechnicalInfo = useCallback(() => {
setShowTechnicalInfo((prev) => !prev);
}, []);
// Get technical info from the player
const getTechnicalInfo = useCallback(async () => {
return (await videoRef.current?.getTechnicalInfo?.()) ?? {};
}, []);
// Determine play method based on stream URL and media source
const playMethod = useMemo<
"DirectPlay" | "DirectStream" | "Transcode" | undefined
>(() => {
if (!stream?.url) return undefined;
// Check if transcoding (m3u8 playlist or TranscodingUrl present)
if (stream.url.includes("m3u8") || stream.mediaSource?.TranscodingUrl) {
return "Transcode";
} }
},
[useVlcPlayer], // Check if direct play (no container remuxing needed)
); // Direct play means the file is being served as-is
if (stream.url.includes("/Videos/") && stream.url.includes("/stream")) {
return "DirectStream";
}
// Default to direct play if we're not transcoding
return "DirectPlay";
}, [stream?.url, stream?.mediaSource?.TranscodingUrl]);
// Extract transcode reasons from the TranscodingUrl
const transcodeReasons = useMemo<string[]>(() => {
const transcodingUrl = stream?.mediaSource?.TranscodingUrl;
if (!transcodingUrl) return [];
try {
// Parse the TranscodeReasons parameter from the URL
const url = new URL(transcodingUrl, "http://localhost");
const reasons = url.searchParams.get("TranscodeReasons");
if (reasons) {
return reasons.split(",").filter(Boolean);
}
} catch {
// If URL parsing fails, try regex fallback
const match = transcodingUrl.match(/TranscodeReasons=([^&]+)/);
if (match) {
return match[1].split(",").filter(Boolean);
}
}
return [];
}, [stream?.mediaSource?.TranscodingUrl]);
const handleZoomToggle = useCallback(async () => { const handleZoomToggle = useCallback(async () => {
// Zoom toggle only supported when using SfPlayer (KSPlayer)
if (useVlcPlayer) return;
const newZoomState = !isZoomedToFill; const newZoomState = !isZoomedToFill;
await videoRef.current?.setZoomedToFill?.(newZoomState);
setIsZoomedToFill(newZoomState); setIsZoomedToFill(newZoomState);
await (videoRef.current as SfPlayerViewRef)?.setVideoZoomToFill?.(
newZoomState,
);
}, [isZoomedToFill, useVlcPlayer]);
// VLC-specific handlers for aspect ratio and scale factor // Adjust subtitle position to compensate for video cropping when zoomed
const handleSetVideoAspectRatio = useCallback( if (newZoomState) {
async (newAspectRatio: string | null) => { // Get video dimensions from mediaSource
if (!useVlcPlayer) return; const videoStream = stream?.mediaSource?.MediaStreams?.find(
const ratio = (newAspectRatio ?? "default") as (s) => s.Type === "Video",
| "default"
| "16:9"
| "4:3"
| "1:1"
| "21:9";
setAspectRatio(ratio);
await (videoRef.current as VlcPlayerViewRef)?.setVideoAspectRatio?.(
newAspectRatio,
);
},
[useVlcPlayer],
); );
const videoWidth = videoStream?.Width ?? 1920;
const videoHeight = videoStream?.Height ?? 1080;
const handleSetVideoScaleFactor = useCallback( const videoAR = videoWidth / videoHeight;
async (newScaleFactor: number) => { const screenAR = screenWidth / screenHeight;
if (!useVlcPlayer) return;
setScaleFactor(
newScaleFactor as 0 | 0.25 | 0.5 | 0.75 | 1.0 | 1.25 | 1.5 | 2.0,
);
await (videoRef.current as VlcPlayerViewRef)?.setVideoScaleFactor?.(
newScaleFactor,
);
},
[useVlcPlayer],
);
// Apply KSPlayer global settings before video loads (only when using KSPlayer) if (screenAR > videoAR) {
useEffect(() => { // Screen is wider than video - video height extends beyond screen
if (Platform.OS === "ios" && !useVlcPlayer) { // Calculate how much of the video is cropped at the bottom (as % of video height)
setHardwareDecode(settings.ksHardwareDecode); const bottomCropPercent = 50 * (1 - videoAR / screenAR);
// Only adjust by 70% of the crop to keep a comfortable margin from the edge
// (subtitles already have some built-in padding from the bottom)
const adjustmentFactor = 0.7;
const newSubPos = Math.round(
100 - bottomCropPercent * adjustmentFactor,
);
await videoRef.current?.setSubtitlePosition?.(newSubPos);
} }
}, [settings.ksHardwareDecode, useVlcPlayer]); // If videoAR >= screenAR, sides are cropped but bottom is visible, no adjustment needed
} else {
// Restore to default position (bottom of video frame)
await videoRef.current?.setSubtitlePosition?.(100);
}
}, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]);
// Apply subtitle settings when video loads (SfPlayer-specific) // Apply subtitle settings when video loads
useEffect(() => { useEffect(() => {
if (useVlcPlayer || !isVideoLoaded || !videoRef.current) return; if (!isVideoLoaded || !videoRef.current) return;
const sfRef = videoRef.current as SfPlayerViewRef;
const applySubtitleSettings = async () => { const applySubtitleSettings = async () => {
if (settings.mpvSubtitleScale !== undefined) { if (settings.mpvSubtitleScale !== undefined) {
await sfRef?.setSubtitleScale?.(settings.mpvSubtitleScale); await videoRef.current?.setSubtitleScale?.(settings.mpvSubtitleScale);
} }
if (settings.mpvSubtitleMarginY !== undefined) { if (settings.mpvSubtitleMarginY !== undefined) {
await sfRef?.setSubtitleMarginY?.(settings.mpvSubtitleMarginY); await videoRef.current?.setSubtitleMarginY?.(
settings.mpvSubtitleMarginY,
);
} }
if (settings.mpvSubtitleAlignX !== undefined) { if (settings.mpvSubtitleAlignX !== undefined) {
await sfRef?.setSubtitleAlignX?.(settings.mpvSubtitleAlignX); await videoRef.current?.setSubtitleAlignX?.(settings.mpvSubtitleAlignX);
} }
if (settings.mpvSubtitleAlignY !== undefined) { if (settings.mpvSubtitleAlignY !== undefined) {
await sfRef?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY); await videoRef.current?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY);
} }
if (settings.mpvSubtitleFontSize !== undefined) { if (settings.mpvSubtitleFontSize !== undefined) {
await sfRef?.setSubtitleFontSize?.(settings.mpvSubtitleFontSize); await videoRef.current?.setSubtitleFontSize?.(
settings.mpvSubtitleFontSize,
);
} }
// Apply subtitle size from general settings // Apply subtitle size from general settings
if (settings.subtitleSize) { if (settings.subtitleSize) {
await sfRef?.setSubtitleFontSize?.(settings.subtitleSize); await videoRef.current?.setSubtitleFontSize?.(settings.subtitleSize);
} }
}; };
applySubtitleSettings(); applySubtitleSettings();
}, [isVideoLoaded, settings, useVlcPlayer]); }, [isVideoLoaded, settings]);
// Apply initial playback speed when video loads // Apply initial playback speed when video loads
useEffect(() => { useEffect(() => {
@@ -1005,20 +860,12 @@ export default function page() {
const applyInitialPlaybackSpeed = async () => { const applyInitialPlaybackSpeed = async () => {
if (initialPlaybackSpeed !== 1.0) { if (initialPlaybackSpeed !== 1.0) {
setCurrentPlaybackSpeed(initialPlaybackSpeed); setCurrentPlaybackSpeed(initialPlaybackSpeed);
if (useVlcPlayer) { await videoRef.current?.setSpeed?.(initialPlaybackSpeed);
await (videoRef.current as VlcPlayerViewRef)?.setRate?.(
initialPlaybackSpeed,
);
} else {
await (videoRef.current as SfPlayerViewRef)?.setSpeed?.(
initialPlaybackSpeed,
);
}
} }
}; };
applyInitialPlaybackSpeed(); applyInitialPlaybackSpeed();
}, [isVideoLoaded, initialPlaybackSpeed, useVlcPlayer]); }, [isVideoLoaded, initialPlaybackSpeed]);
// Show error UI first, before checking loading/missingdata // Show error UI first, before checking loading/missingdata
if (itemStatus.isError || streamStatus.isError) { if (itemStatus.isError || streamStatus.isError) {
@@ -1047,12 +894,14 @@ export default function page() {
); );
return ( return (
<OfflineModeProvider isOffline={offline}>
<PlayerProvider <PlayerProvider
playerRef={videoRef} playerRef={videoRef}
item={item} item={item}
mediaSource={stream?.mediaSource} mediaSource={stream?.mediaSource}
isVideoLoaded={isVideoLoaded} isVideoLoaded={isVideoLoaded}
tracksReady={tracksReady} tracksReady={tracksReady}
downloadedItem={downloadedItem}
> >
<VideoProvider> <VideoProvider>
<View <View
@@ -1073,35 +922,14 @@ export default function page() {
justifyContent: "center", justifyContent: "center",
}} }}
> >
{useVlcPlayer ? ( <MpvPlayerView
<VlcPlayerView ref={videoRef}
ref={videoRef as React.RefObject<VlcPlayerViewRef>} source={videoSource}
source={vlcVideoSource!}
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%" }}
onVideoProgress={onProgressVlc} onProgress={onProgress}
onVideoStateChange={onPlaybackStateChangedVlc} onPlaybackStateChange={onPlaybackStateChanged}
onPipStarted={onPipStartedVlc}
onVideoLoadEnd={() => setIsVideoLoaded(true)}
onVideoError={(e: PlaybackStatePayload) => {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
t("player.error"),
t("player.an_error_occured_while_playing_the_video"),
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
progressUpdateInterval={1000}
/>
) : (
<SfPlayerView
ref={videoRef as React.RefObject<SfPlayerViewRef>}
source={sfVideoSource}
style={{ width: "100%", height: "100%" }}
onProgress={onProgressSf}
onPlaybackStateChange={onPlaybackStateChangedSf}
onPictureInPictureChange={onPictureInPictureChangeSf}
onLoad={() => setIsVideoLoaded(true)} onLoad={() => setIsVideoLoaded(true)}
onError={(e: { nativeEvent: SfOnErrorEventPayload }) => { onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
console.error("Video Error:", e.nativeEvent); console.error("Video Error:", e.nativeEvent);
Alert.alert( Alert.alert(
t("player.error"), t("player.error"),
@@ -1113,7 +941,6 @@ export default function page() {
setTracksReady(true); setTracksReady(true);
}} }}
/> />
)}
{!hasPlaybackStarted && ( {!hasPlaybackStarted && (
<View <View
style={{ style={{
@@ -1148,22 +975,23 @@ export default function page() {
pause={pause} pause={pause}
seek={seek} seek={seek}
enableTrickplay={true} enableTrickplay={true}
offline={offline}
useVlcPlayer={useVlcPlayer}
aspectRatio={aspectRatio} aspectRatio={aspectRatio}
setVideoAspectRatio={handleSetVideoAspectRatio}
scaleFactor={scaleFactor}
setVideoScaleFactor={handleSetVideoScaleFactor}
isZoomedToFill={isZoomedToFill} isZoomedToFill={isZoomedToFill}
onZoomToggle={handleZoomToggle} onZoomToggle={handleZoomToggle}
api={api} api={api}
downloadedFiles={downloadedFiles} downloadedFiles={downloadedFiles}
playbackSpeed={currentPlaybackSpeed} playbackSpeed={currentPlaybackSpeed}
setPlaybackSpeed={handleSetPlaybackSpeed} setPlaybackSpeed={handleSetPlaybackSpeed}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={handleToggleTechnicalInfo}
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
/> />
)} )}
</View> </View>
</VideoProvider> </VideoProvider>
</PlayerProvider> </PlayerProvider>
</OfflineModeProvider>
); );
} }

View File

@@ -1,9 +1,10 @@
import "@/augmentations"; import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import NetInfo from "@react-native-community/netinfo";
import { DarkTheme, ThemeProvider } from "@react-navigation/native"; import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { QueryClient } from "@tanstack/react-query"; import { onlineManager, QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import * as BackgroundTask from "expo-background-task"; import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device"; import * as Device from "expo-device";
@@ -12,6 +13,7 @@ import { GlobalModal } from "@/components/GlobalModal";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider"; import { DownloadProvider } from "@/providers/DownloadProvider";
import { GlobalModalProvider } from "@/providers/GlobalModalProvider"; import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
import { IntroSheetProvider } from "@/providers/IntroSheetProvider";
import { import {
apiAtom, apiAtom,
getOrSetDeviceId, getOrSetDeviceId,
@@ -20,6 +22,7 @@ import {
import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider"; import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider"; import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { import {
@@ -45,7 +48,7 @@ import type {
NotificationResponse, NotificationResponse,
} from "expo-notifications/build/Notifications.types"; } from "expo-notifications/build/Notifications.types";
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types"; import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
import { router, Stack, useSegments } from "expo-router"; import { Stack, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen"; import * as SplashScreen from "expo-splash-screen";
import * as TaskManager from "expo-task-manager"; import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider, useAtom } from "jotai"; import { Provider as JotaiProvider, useAtom } from "jotai";
@@ -54,6 +57,7 @@ import { I18nextProvider } from "react-i18next";
import { Appearance } from "react-native"; import { Appearance } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import useRouter from "@/hooks/useAppRouter";
import { userAtom } from "@/providers/JellyfinProvider"; import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store"; import { store } from "@/utils/store";
import "react-native-reanimated"; import "react-native-reanimated";
@@ -78,14 +82,9 @@ SplashScreen.setOptions({
fade: true, fade: true,
}); });
function redirect(notification: typeof Notifications.Notification) {
const url = notification.request.content.data?.url;
if (url) {
router.push(url);
}
}
function useNotificationObserver() { function useNotificationObserver() {
const router = useRouter();
useEffect(() => { useEffect(() => {
if (Platform.isTV) return; if (Platform.isTV) return;
@@ -96,14 +95,17 @@ function useNotificationObserver() {
if (!isMounted || !response?.notification) { if (!isMounted || !response?.notification) {
return; return;
} }
redirect(response?.notification); const url = response?.notification.request.content.data?.url;
if (url) {
router.push(url);
}
}, },
); );
return () => { return () => {
isMounted = false; isMounted = false;
}; };
}, []); }, [router]);
} }
if (!Platform.isTV) { if (!Platform.isTV) {
@@ -187,11 +189,29 @@ export default function RootLayout() {
); );
} }
// Set up online manager for network-aware query behavior
onlineManager.setEventListener((setOnline) => {
return NetInfo.addEventListener((state) => {
setOnline(!!state.isConnected);
});
});
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 30000, // 30 seconds - data is fresh staleTime: 0, // Always stale - triggers background refetch on mount
gcTime: 1000 * 60 * 60 * 24, // 24 hours - keep in cache for persistence gcTime: 1000 * 60 * 60 * 24, // 24 hours - keep in cache for offline
networkMode: "offlineFirst", // Return cache first, refetch if online
refetchOnMount: true, // Refetch when component mounts
refetchOnReconnect: true, // Refetch when network reconnects
refetchOnWindowFocus: false, // Not needed for mobile
retry: (failureCount) => {
if (!onlineManager.isOnline()) return false;
return failureCount < 3;
},
},
mutations: {
networkMode: "online", // Only run mutations when online
}, },
}, },
}); });
@@ -210,6 +230,7 @@ function Layout() {
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const _segments = useSegments(); const _segments = useSegments();
const router = useRouter();
useEffect(() => { useEffect(() => {
i18n.changeLanguage( i18n.changeLanguage(
@@ -302,9 +323,6 @@ function Layout() {
responseListener.current = responseListener.current =
Notifications?.addNotificationResponseReceivedListener( Notifications?.addNotificationResponseReceivedListener(
(response: NotificationResponse) => { (response: NotificationResponse) => {
// redirect if internal notification
redirect(response?.notification);
// Currently the notifications supported by the plugin will send data for deep links. // Currently the notifications supported by the plugin will send data for deep links.
const { title, data } = response.notification.request.content; const { title, data } = response.notification.request.content;
writeInfoLog(`Notification ${title} opened`, data); writeInfoLog(`Notification ${title} opened`, data);
@@ -364,6 +382,7 @@ function Layout() {
}} }}
> >
<JellyfinProvider> <JellyfinProvider>
<ServerUrlProvider>
<NetworkStatusProvider> <NetworkStatusProvider>
<PlaySettingsProvider> <PlaySettingsProvider>
<LogProvider> <LogProvider>
@@ -372,6 +391,7 @@ function Layout() {
<MusicPlayerProvider> <MusicPlayerProvider>
<GlobalModalProvider> <GlobalModalProvider>
<BottomSheetModalProvider> <BottomSheetModalProvider>
<IntroSheetProvider>
<ThemeProvider value={DarkTheme}> <ThemeProvider value={DarkTheme}>
<SystemBars style='light' hidden={false} /> <SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'> <Stack initialRouteName='(auth)/(tabs)'>
@@ -425,6 +445,7 @@ function Layout() {
/> />
<GlobalModal /> <GlobalModal />
</ThemeProvider> </ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider> </BottomSheetModalProvider>
</GlobalModalProvider> </GlobalModalProvider>
</MusicPlayerProvider> </MusicPlayerProvider>
@@ -433,6 +454,7 @@ function Layout() {
</LogProvider> </LogProvider>
</PlaySettingsProvider> </PlaySettingsProvider>
</NetworkStatusProvider> </NetworkStatusProvider>
</ServerUrlProvider>
</JellyfinProvider> </JellyfinProvider>
</PersistQueryClientProvider> </PersistQueryClientProvider>
); );

View File

@@ -10,6 +10,7 @@ import {
Keyboard, Keyboard,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
Switch,
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
@@ -20,8 +21,13 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery"; import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList"; import { PreviousServersList } from "@/components/PreviousServersList";
import { SaveAccountModal } from "@/components/SaveAccountModal";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import type {
AccountSecurityType,
SavedServer,
} from "@/utils/secureCredentials";
const CredentialsSchema = z.object({ const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")), username: z.string().min(1, t("login.username_required")),
@@ -31,8 +37,14 @@ const Login: React.FC = () => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const navigation = useNavigation(); const navigation = useNavigation();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { setServer, login, removeServer, initiateQuickConnect } = const {
useJellyfin(); setServer,
login,
removeServer,
initiateQuickConnect,
loginWithSavedCredential,
loginWithPassword,
} = useJellyfin();
const { const {
apiUrl: _apiUrl, apiUrl: _apiUrl,
@@ -52,6 +64,14 @@ const Login: React.FC = () => {
password: _password || "", password: _password || "",
}); });
// Save account state
const [saveAccount, setSaveAccount] = useState(false);
const [showSaveModal, setShowSaveModal] = useState(false);
const [pendingLogin, setPendingLogin] = useState<{
username: string;
password: string;
} | null>(null);
/** /**
* A way to auto login based on a link * A way to auto login based on a link
*/ */
@@ -96,12 +116,34 @@ const Login: React.FC = () => {
const handleLogin = async () => { const handleLogin = async () => {
Keyboard.dismiss(); Keyboard.dismiss();
const result = CredentialsSchema.safeParse(credentials);
if (!result.success) return;
if (saveAccount) {
// Show save account modal to choose security type
setPendingLogin({
username: credentials.username,
password: credentials.password,
});
setShowSaveModal(true);
} else {
// Login without saving
await performLogin(credentials.username, credentials.password);
}
};
const performLogin = async (
username: string,
password: string,
options?: {
saveAccount?: boolean;
securityType?: AccountSecurityType;
pinCode?: string;
},
) => {
setLoading(true); setLoading(true);
try { try {
const result = CredentialsSchema.safeParse(credentials); await login(username, password, serverName, options);
if (result.success) {
await login(credentials.username, credentials.password);
}
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message); Alert.alert(t("login.connection_failed"), error.message);
@@ -113,6 +155,44 @@ const Login: React.FC = () => {
} }
} finally { } finally {
setLoading(false); setLoading(false);
setPendingLogin(null);
}
};
const handleSaveAccountConfirm = async (
securityType: AccountSecurityType,
pinCode?: string,
) => {
setShowSaveModal(false);
if (pendingLogin) {
await performLogin(pendingLogin.username, pendingLogin.password, {
saveAccount: true,
securityType,
pinCode,
});
}
};
const handleQuickLoginWithSavedCredential = async (
serverUrl: string,
userId: string,
) => {
await loginWithSavedCredential(serverUrl, userId);
};
const handlePasswordLogin = async (
serverUrl: string,
username: string,
password: string,
) => {
await loginWithPassword(serverUrl, username, password);
};
const handleAddAccount = (server: SavedServer) => {
// Server is already selected, go to credential entry
setServer({ address: server.address });
if (server.name) {
setServerName(server.name);
} }
}; };
@@ -274,7 +354,8 @@ const Login: React.FC = () => {
keyboardType='default' keyboardType='default'
returnKeyType='done' returnKeyType='done'
autoCapitalize='none' autoCapitalize='none'
textContentType='oneTimeCode' autoCorrect={false}
textContentType='username'
clearButtonMode='while-editing' clearButtonMode='while-editing'
maxLength={500} maxLength={500}
extraClassName='mb-4' extraClassName='mb-4'
@@ -380,9 +461,12 @@ const Login: React.FC = () => {
}} }}
/> />
<PreviousServersList <PreviousServersList
onServerSelect={async (s: any) => { onServerSelect={async (s) => {
await handleConnect(s.address); await handleConnect(s.address);
}} }}
onQuickLogin={handleQuickLoginWithSavedCredential}
onPasswordLogin={handlePasswordLogin}
onAddAccount={handleAddAccount}
/> />
</View> </View>
</View> </View>
@@ -430,9 +514,8 @@ const Login: React.FC = () => {
keyboardType='default' keyboardType='default'
returnKeyType='done' returnKeyType='done'
autoCapitalize='none' autoCapitalize='none'
// Changed from username to oneTimeCode because it is a known issue in RN autoCorrect={false}
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037 textContentType='username'
textContentType='oneTimeCode'
clearButtonMode='while-editing' clearButtonMode='while-editing'
maxLength={500} maxLength={500}
/> />
@@ -460,6 +543,21 @@ const Login: React.FC = () => {
clearButtonMode='while-editing' clearButtonMode='while-editing'
maxLength={500} maxLength={500}
/> />
<TouchableOpacity
onPress={() => setSaveAccount(!saveAccount)}
className='flex flex-row items-center py-2'
activeOpacity={0.7}
>
<Switch
value={saveAccount}
onValueChange={setSaveAccount}
trackColor={{ false: "#3f3f46", true: Colors.primary }}
thumbColor='white'
/>
<Text className='ml-3 text-neutral-300'>
{t("save_account.save_for_later")}
</Text>
</TouchableOpacity>
<View className='flex flex-row items-center justify-between'> <View className='flex flex-row items-center justify-between'>
<Button <Button
onPress={handleLogin} onPress={handleLogin}
@@ -535,11 +633,25 @@ const Login: React.FC = () => {
onServerSelect={async (s) => { onServerSelect={async (s) => {
await handleConnect(s.address); await handleConnect(s.address);
}} }}
onQuickLogin={handleQuickLoginWithSavedCredential}
onPasswordLogin={handlePasswordLogin}
onAddAccount={handleAddAccount}
/> />
</View> </View>
</View> </View>
)} )}
</KeyboardAvoidingView> </KeyboardAvoidingView>
{/* Save Account Modal */}
<SaveAccountModal
visible={showSaveModal}
onClose={() => {
setShowSaveModal(false);
setPendingLogin(null);
}}
onSave={handleSaveAccountConfirm}
username={pendingLogin?.username || credentials.username}
/>
</SafeAreaView> </SafeAreaView>
); );
}; };

View File

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

816
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,223 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import { Swipeable } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Colors } from "@/constants/Colors";
import {
deleteAccountCredential,
type SavedServer,
type SavedServerAccount,
} from "@/utils/secureCredentials";
import { Button } from "./Button";
import { Text } from "./common/Text";
interface AccountsSheetProps {
open: boolean;
setOpen: (open: boolean) => void;
server: SavedServer | null;
onAccountSelect: (account: SavedServerAccount) => void;
onAddAccount: () => void;
onAccountDeleted?: () => void;
}
export const AccountsSheet: React.FC<AccountsSheetProps> = ({
open,
setOpen,
server,
onAccountSelect,
onAddAccount,
onAccountDeleted,
}) => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const isAndroid = Platform.OS === "android";
const snapPoints = useMemo(
() => (isAndroid ? ["100%"] : ["50%"]),
[isAndroid],
);
useEffect(() => {
if (open) {
bottomSheetModalRef.current?.present();
} else {
bottomSheetModalRef.current?.dismiss();
}
}, [open]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index === -1) {
setOpen(false);
}
},
[setOpen],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleDeleteAccount = async (account: SavedServerAccount) => {
if (!server) return;
Alert.alert(
t("server.remove_saved_login"),
t("server.remove_account_description", { username: account.username }),
[
{ text: t("common.cancel"), style: "cancel" },
{
text: t("common.remove"),
style: "destructive",
onPress: async () => {
await deleteAccountCredential(server.address, account.userId);
onAccountDeleted?.();
},
},
],
);
};
const getSecurityIcon = (
securityType: SavedServerAccount["securityType"],
): keyof typeof Ionicons.glyphMap => {
switch (securityType) {
case "pin":
return "keypad";
case "password":
return "lock-closed";
default:
return "key";
}
};
const renderRightActions = (account: SavedServerAccount) => (
<TouchableOpacity
onPress={() => handleDeleteAccount(account)}
className='bg-red-600 justify-center items-center px-5'
>
<Ionicons name='trash' size={20} color='white' />
</TouchableOpacity>
);
if (!server) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
snapPoints={snapPoints}
onChange={handleSheetChanges}
handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundStyle={{ backgroundColor: "#171717" }}
backdropComponent={renderBackdrop}
>
<BottomSheetView
style={{
flex: 1,
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
paddingBottom: Math.max(16, insets.bottom),
}}
>
<View className='flex-1'>
{/* Header */}
<View className='mb-4'>
<Text className='font-bold text-2xl text-neutral-100'>
{t("server.select_account")}
</Text>
<Text className='text-neutral-400 mt-1'>
{server.name || server.address}
</Text>
</View>
{/* Account List */}
<View className='bg-neutral-800 rounded-xl overflow-hidden mb-4'>
{server.accounts.map((account, index) => (
<Swipeable
key={account.userId}
renderRightActions={() => renderRightActions(account)}
overshootRight={false}
>
<TouchableOpacity
onPress={() => {
setOpen(false);
onAccountSelect(account);
}}
className={`flex-row items-center p-4 bg-neutral-800 ${
index < server.accounts.length - 1
? "border-b border-neutral-700"
: ""
}`}
>
{/* Avatar */}
<View className='w-10 h-10 bg-neutral-700 rounded-full items-center justify-center mr-3'>
<Ionicons name='person' size={20} color='white' />
</View>
{/* Account Info */}
<View className='flex-1'>
<Text className='text-neutral-100 font-medium'>
{account.username}
</Text>
<Text className='text-neutral-500 text-sm'>
{account.securityType === "none"
? t("save_account.no_protection")
: account.securityType === "pin"
? t("save_account.pin_code")
: t("save_account.password")}
</Text>
</View>
{/* Security Icon */}
<Ionicons
name={getSecurityIcon(account.securityType)}
size={18}
color={Colors.primary}
/>
</TouchableOpacity>
</Swipeable>
))}
</View>
{/* Hint */}
<Text className='text-xs text-neutral-500 mb-4 ml-1'>
{t("server.swipe_to_remove")}
</Text>
{/* Add Account Button */}
<Button
onPress={() => {
setOpen(false);
onAddAccount();
}}
color='purple'
>
<View className='flex-row items-center justify-center'>
<Ionicons name='add' size={20} color='white' />
<Text className='text-white font-semibold ml-2'>
{t("server.add_account")}
</Text>
</View>
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
);
};

View File

@@ -1,4 +1,5 @@
import { View, type ViewProps } from "react-native"; import { Platform, StyleSheet, View, type ViewProps } from "react-native";
import { GlassEffectView } from "react-native-glass-effect-view";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
interface Props extends ViewProps { interface Props extends ViewProps {
@@ -13,6 +14,30 @@ export const Badge: React.FC<Props> = ({
variant = "purple", variant = "purple",
...props ...props
}) => { }) => {
const content = (
<View style={styles.content}>
{iconLeft && <View style={styles.iconLeft}>{iconLeft}</View>}
<Text
className={`
text-xs
${variant === "purple" && "text-white"}
`}
>
{text}
</Text>
</View>
);
if (Platform.OS === "ios") {
return (
<View {...props} style={[styles.container, props.style]}>
<GlassEffectView style={{ borderRadius: 100 }}>
{content}
</GlassEffectView>
</View>
);
}
return ( return (
<View <View
{...props} {...props}
@@ -34,3 +59,23 @@ export const Badge: React.FC<Props> = ({
</View> </View>
); );
}; };
const styles = StyleSheet.create({
container: {
overflow: "hidden",
alignSelf: "flex-start",
flexShrink: 1,
flexGrow: 0,
},
content: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 50,
backgroundColor: "transparent",
},
iconLeft: {
marginRight: 4,
},
});

View File

@@ -1,6 +1,7 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { Platform, TouchableOpacity } from "react-native"; import { Platform } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import GoogleCast, { import GoogleCast, {
CastButton, CastButton,
CastContext, CastContext,
@@ -44,7 +45,7 @@ export function Chromecast({
if (Platform.OS === "ios") { if (Platform.OS === "ios") {
return ( return (
<TouchableOpacity <Pressable
className='mr-4' className='mr-4'
onPress={() => { onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls(); if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
@@ -54,7 +55,7 @@ export function Chromecast({
> >
<AndroidCastButton /> <AndroidCastButton />
<Feather name='cast' size={22} color={"white"} /> <Feather name='cast' size={22} color={"white"} />
</TouchableOpacity> </Pressable>
); );
} }

View File

@@ -9,13 +9,14 @@ import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { type Href, router } from "expo-router"; import { type Href } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, Platform, Switch, View, type ViewProps } from "react-native"; import { Alert, Platform, Switch, View, type ViewProps } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import useRouter from "@/hooks/useAppRouter";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -62,6 +63,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [queue, _setQueue] = useAtom(queueAtom); const [queue, _setQueue] = useAtom(queueAtom);
const { settings } = useSettings(); const { settings } = useSettings();
const router = useRouter();
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false); const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
const { processes, startBackgroundDownload, downloadedItems } = useDownload(); const { processes, startBackgroundDownload, downloadedItems } = useDownload();
@@ -170,9 +172,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
firstItem.Type !== "Episode" firstItem.Type !== "Episode"
? "/downloads" ? "/downloads"
: ({ : ({
pathname: `/downloads/${firstItem.SeriesId}`, pathname: "/series/[id]",
params: { params: {
episodeSeasonIndex: firstItem.ParentIndexNumber, id: firstItem.SeriesId!,
seasonIndex: firstItem.ParentIndexNumber?.toString(),
offline: "true",
}, },
} as Href), } as Href),
); );
@@ -209,6 +213,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
subtitleStreamIndex: subtitleIndex ?? -1, subtitleStreamIndex: subtitleIndex ?? -1,
maxBitrate: selectedOptions?.bitrate || defaultBitrate, maxBitrate: selectedOptions?.bitrate || defaultBitrate,
deviceId: api.deviceInfo.id, deviceId: api.deviceInfo.id,
audioMode: settings?.audioTranscodeMode,
}); });
return { return {
@@ -236,11 +241,23 @@ export const DownloadItems: React.FC<DownloadProps> = ({
); );
continue; continue;
} }
// Get the audio/subtitle indices that were used for this download
const downloadAudioIndex =
itemsNotDownloaded.length > 1
? getDefaultPlaySettings(item, settings!).audioIndex
: selectedOptions?.audioIndex;
const downloadSubtitleIndex =
itemsNotDownloaded.length > 1
? getDefaultPlaySettings(item, settings!).subtitleIndex
: selectedOptions?.subtitleIndex;
await startBackgroundDownload( await startBackgroundDownload(
url, url,
item, item,
mediaSource, mediaSource,
selectedOptions?.bitrate || defaultBitrate, selectedOptions?.bitrate || defaultBitrate,
downloadAudioIndex,
downloadSubtitleIndex,
); );
} }
}, },

View File

@@ -1,11 +1,14 @@
// GenreTags.tsx // GenreTags.tsx
import type React from "react"; import type React from "react";
import { import {
Platform,
type StyleProp, type StyleProp,
StyleSheet,
type TextStyle, type TextStyle,
View, View,
type ViewProps, type ViewProps,
} from "react-native"; } from "react-native";
import { GlassEffectView } from "react-native-glass-effect-view";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
interface TagProps { interface TagProps {
@@ -20,6 +23,23 @@ export const Tag: React.FC<
textStyle?: StyleProp<TextStyle>; textStyle?: StyleProp<TextStyle>;
} & ViewProps } & ViewProps
> = ({ text, textClass, textStyle, ...props }) => { > = ({ text, textClass, textStyle, ...props }) => {
if (Platform.OS === "ios") {
return (
<View>
<GlassEffectView style={styles.glass}>
<View
style={{
paddingHorizontal: 8,
paddingVertical: 4,
}}
>
<Text>{text}</Text>
</View>
</GlassEffectView>
</View>
);
}
return ( return (
<View className='bg-neutral-800 rounded-full px-2 py-1' {...props}> <View className='bg-neutral-800 rounded-full px-2 py-1' {...props}>
<Text className={textClass} style={textStyle}> <Text className={textClass} style={textStyle}>
@@ -29,6 +49,16 @@ export const Tag: React.FC<
); );
}; };
const styles = StyleSheet.create({
container: {
overflow: "hidden",
borderRadius: 50,
},
glass: {
borderRadius: 50,
},
});
export const Tags: React.FC< export const Tags: React.FC<
TagProps & { tagProps?: ViewProps } & ViewProps TagProps & { tagProps?: ViewProps } & ViewProps
> = ({ tags, textClass = "text-xs", tagProps, ...props }) => { > = ({ tags, textClass = "text-xs", tagProps, ...props }) => {

203
components/IntroSheet.tsx Normal file
View File

@@ -0,0 +1,203 @@
import { Feather, Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { Image } from "expo-image";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Linking, Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { storage } from "@/utils/mmkv";
export interface IntroSheetRef {
present: () => void;
dismiss: () => void;
}
export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
const bottomSheetRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
useImperativeHandle(ref, () => ({
present: () => {
storage.set("hasShownIntro", true);
bottomSheetRef.current?.present();
},
dismiss: () => {
bottomSheetRef.current?.dismiss();
},
}));
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleDismiss = useCallback(() => {
bottomSheetRef.current?.dismiss();
}, []);
const handleGoToSettings = useCallback(() => {
bottomSheetRef.current?.dismiss();
router.push("/settings");
}, []);
return (
<BottomSheetModal
ref={bottomSheetRef}
enableDynamicSizing
backdropComponent={renderBackdrop}
backgroundStyle={{ backgroundColor: "#171717" }}
handleIndicatorStyle={{ backgroundColor: "#737373" }}
>
<BottomSheetScrollView
style={{
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
}}
>
<View className={Platform.isTV ? "py-5 space-y-4" : "py-4 space-y-6"}>
<View>
<Text className='text-3xl font-bold text-center mb-2'>
{t("home.intro.welcome_to_streamyfin")}
</Text>
<Text className='text-center'>
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
</Text>
</View>
<View>
<Text className='text-lg font-bold'>
{t("home.intro.features_title")}
</Text>
<Text className='text-xs'>
{t("home.intro.features_description")}
</Text>
<View className='flex flex-row items-center mt-4'>
<Image
source={require("@/assets/icons/jellyseerr-logo.svg")}
style={{
width: 50,
height: 50,
}}
/>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>Jellyseerr</Text>
<Text className='shrink text-xs'>
{t("home.intro.jellyseerr_feature_description")}
</Text>
</View>
</View>
{!Platform.isTV && (
<>
<View className='flex flex-row items-center mt-4'>
<View
style={{
width: 50,
height: 50,
}}
className='flex items-center justify-center'
>
<Ionicons
name='cloud-download-outline'
size={32}
color='white'
/>
</View>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>
{t("home.intro.downloads_feature_title")}
</Text>
<Text className='shrink text-xs'>
{t("home.intro.downloads_feature_description")}
</Text>
</View>
</View>
<View className='flex flex-row items-center mt-4'>
<View
style={{
width: 50,
height: 50,
}}
className='flex items-center justify-center'
>
<Feather name='cast' size={28} color={"white"} />
</View>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>Chromecast</Text>
<Text className='shrink text-xs'>
{t("home.intro.chromecast_feature_description")}
</Text>
</View>
</View>
</>
)}
<View className='flex flex-row items-center mt-4'>
<View
style={{
width: 50,
height: 50,
}}
className='flex items-center justify-center'
>
<Feather name='settings' size={28} color={"white"} />
</View>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>
{t("home.intro.centralised_settings_plugin_title")}
</Text>
<View className='flex-row flex-wrap items-baseline'>
<Text className='shrink text-xs'>
{t(
"home.intro.centralised_settings_plugin_description",
)}{" "}
</Text>
<TouchableOpacity
onPress={() => {
Linking.openURL(
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
);
}}
>
<Text className='text-xs text-purple-600 underline'>
{t("home.intro.read_more")}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</View>
<View>
<Button onPress={handleDismiss} className='mt-4'>
{t("home.intro.done_button")}
</Button>
<TouchableOpacity onPress={handleGoToSettings} className='mt-4'>
<Text className='text-purple-600 text-center'>
{t("home.intro.go_to_settings_button")}
</Text>
</TouchableOpacity>
</View>
<View style={{ height: insets.bottom }} />
</View>
</BottomSheetScrollView>
</BottomSheetModal>
);
});
IntroSheet.displayName = "IntroSheet";

View File

@@ -26,6 +26,7 @@ import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { AddToFavorites } from "./AddToFavorites"; import { AddToFavorites } from "./AddToFavorites";
@@ -45,13 +46,13 @@ export type SelectedOptions = {
interface ItemContentProps { interface ItemContentProps {
item: BaseItemDto; item: BaseItemDto;
isOffline: boolean;
itemWithSources?: BaseItemDto | null; itemWithSources?: BaseItemDto | null;
} }
export const ItemContent: React.FC<ItemContentProps> = React.memo( export const ItemContent: React.FC<ItemContentProps> = React.memo(
({ item, isOffline, itemWithSources }) => { ({ item, itemWithSources }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const isOffline = useOfflineMode();
const { settings } = useSettings(); const { settings } = useSettings();
const { orientation } = useOrientation(); const { orientation } = useOrientation();
const navigation = useNavigation(); const navigation = useNavigation();
@@ -228,7 +229,6 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
<PlayButton <PlayButton
selectedOptions={selectedOptions} selectedOptions={selectedOptions}
item={item} item={item}
isOffline={isOffline}
colors={itemColors} colors={itemColors}
/> />
<View className='w-1' /> <View className='w-1' />
@@ -243,11 +243,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
</View> </View>
</View> </View>
{item.Type === "Episode" && ( {item.Type === "Episode" && (
<SeasonEpisodesCarousel <SeasonEpisodesCarousel item={item} loading={loading} />
item={item}
loading={loading}
isOffline={isOffline}
/>
)} )}
{!isOffline && {!isOffline &&
@@ -264,7 +260,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
<CurrentSeries item={item} className='mb-2' /> <CurrentSeries item={item} className='mb-2' />
)} )}
<ItemPeopleSections item={item} isOffline={isOffline} /> <ItemPeopleSections item={item} />
{!isOffline && <SimilarItems itemId={item.Id} />} {!isOffline && <SimilarItems itemId={item.Id} />}
</> </>

View File

@@ -0,0 +1,231 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Alert,
Animated,
Keyboard,
Platform,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useHaptic } from "@/hooks/useHaptic";
import { verifyAccountPIN } from "@/utils/secureCredentials";
import { Button } from "./Button";
import { Text } from "./common/Text";
import { PinInput } from "./inputs/PinInput";
interface PINEntryModalProps {
visible: boolean;
onClose: () => void;
onSuccess: () => void;
onForgotPIN?: () => void;
serverUrl: string;
userId: string;
username: string;
}
export const PINEntryModal: React.FC<PINEntryModalProps> = ({
visible,
onClose,
onSuccess,
onForgotPIN,
serverUrl,
userId,
username,
}) => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [pinCode, setPinCode] = useState("");
const [error, setError] = useState<string | null>(null);
const [isVerifying, setIsVerifying] = useState(false);
const shakeAnimation = useRef(new Animated.Value(0)).current;
const errorHaptic = useHaptic("error");
const successHaptic = useHaptic("success");
const isAndroid = Platform.OS === "android";
const snapPoints = useMemo(
() => (isAndroid ? ["100%"] : ["50%"]),
[isAndroid],
);
useEffect(() => {
if (visible) {
bottomSheetModalRef.current?.present();
setPinCode("");
setError(null);
} else {
bottomSheetModalRef.current?.dismiss();
}
}, [visible]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index === -1) {
setPinCode("");
setError(null);
onClose();
}
},
[onClose],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const shake = () => {
Animated.sequence([
Animated.timing(shakeAnimation, {
toValue: 10,
duration: 50,
useNativeDriver: true,
}),
Animated.timing(shakeAnimation, {
toValue: -10,
duration: 50,
useNativeDriver: true,
}),
Animated.timing(shakeAnimation, {
toValue: 10,
duration: 50,
useNativeDriver: true,
}),
Animated.timing(shakeAnimation, {
toValue: 0,
duration: 50,
useNativeDriver: true,
}),
]).start();
};
const handlePinChange = async (value: string) => {
setPinCode(value);
setError(null);
// Auto-verify when 4 digits entered
if (value.length === 4) {
setIsVerifying(true);
try {
const isValid = await verifyAccountPIN(serverUrl, userId, value);
if (isValid) {
Keyboard.dismiss();
successHaptic();
onSuccess();
setPinCode("");
} else {
errorHaptic();
setError(t("pin.invalid_pin"));
shake();
setPinCode("");
}
} catch {
errorHaptic();
setError(t("pin.invalid_pin"));
shake();
setPinCode("");
} finally {
setIsVerifying(false);
}
}
};
const handleForgotPIN = () => {
Alert.alert(t("pin.forgot_pin"), t("pin.forgot_pin_desc"), [
{ text: t("common.cancel"), style: "cancel" },
{
text: t("common.continue"),
style: "destructive",
onPress: () => {
onClose();
onForgotPIN?.();
},
},
]);
};
return (
<BottomSheetModal
ref={bottomSheetModalRef}
snapPoints={snapPoints}
onChange={handleSheetChanges}
handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundStyle={{ backgroundColor: "#171717" }}
backdropComponent={renderBackdrop}
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
keyboardBlurBehavior='restore'
android_keyboardInputMode='adjustResize'
topInset={isAndroid ? 0 : undefined}
>
<BottomSheetView
style={{
flex: 1,
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
paddingBottom: Math.max(16, insets.bottom),
}}
>
<View className='flex-1'>
{/* Header */}
<View className='mb-6'>
<Text className='font-bold text-2xl text-neutral-100'>
{t("pin.enter_pin")}
</Text>
<Text className='text-neutral-400 mt-1'>
{t("pin.enter_pin_for", { username })}
</Text>
</View>
{/* PIN Input */}
<Animated.View
style={{ transform: [{ translateX: shakeAnimation }] }}
className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 mb-4'
>
<PinInput
value={pinCode}
onChangeText={handlePinChange}
length={4}
style={{ paddingHorizontal: 16 }}
autoFocus
/>
{error && (
<Text className='text-red-500 text-center mt-3'>{error}</Text>
)}
{isVerifying && (
<Text className='text-neutral-400 text-center mt-3'>
{t("common.verifying") || "Verifying..."}
</Text>
)}
</Animated.View>
{/* Forgot PIN */}
<TouchableOpacity onPress={handleForgotPIN} className='mb-4'>
<Text className='text-purple-400 text-center'>
{t("pin.forgot_pin")}
</Text>
</TouchableOpacity>
{/* Cancel Button */}
<Button onPress={onClose} color='black'>
{t("common.cancel")}
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
);
};

View File

@@ -0,0 +1,185 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useHaptic } from "@/hooks/useHaptic";
import { Button } from "./Button";
import { Text } from "./common/Text";
interface PasswordEntryModalProps {
visible: boolean;
onClose: () => void;
onSubmit: (password: string) => Promise<void>;
username: string;
}
export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
visible,
onClose,
onSubmit,
username,
}) => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const errorHaptic = useHaptic("error");
const isAndroid = Platform.OS === "android";
const snapPoints = useMemo(
() => (isAndroid ? ["100%"] : ["50%"]),
[isAndroid],
);
useEffect(() => {
if (visible) {
bottomSheetModalRef.current?.present();
setPassword("");
setError(null);
} else {
bottomSheetModalRef.current?.dismiss();
}
}, [visible]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index === -1) {
setPassword("");
setError(null);
onClose();
}
},
[onClose],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleSubmit = async () => {
if (!password) {
setError(t("password.enter_password"));
return;
}
setIsLoading(true);
setError(null);
try {
await onSubmit(password);
setPassword("");
} catch {
errorHaptic();
setError(t("password.invalid_password"));
} finally {
setIsLoading(false);
}
};
return (
<BottomSheetModal
ref={bottomSheetModalRef}
snapPoints={snapPoints}
onChange={handleSheetChanges}
handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundStyle={{ backgroundColor: "#171717" }}
backdropComponent={renderBackdrop}
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
keyboardBlurBehavior='restore'
android_keyboardInputMode='adjustResize'
topInset={isAndroid ? 0 : undefined}
>
<BottomSheetView
style={{
flex: 1,
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
paddingBottom: Math.max(16, insets.bottom),
}}
>
<View className='flex-1'>
{/* Header */}
<View className='mb-6'>
<Text className='font-bold text-2xl text-neutral-100'>
{t("password.enter_password")}
</Text>
<Text className='text-neutral-400 mt-1'>
{t("password.enter_password_for", { username })}
</Text>
</View>
{/* Password Input */}
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 mb-4'>
<Text className='text-neutral-400 text-sm mb-2'>
{t("login.password")}
</Text>
<BottomSheetTextInput
value={password}
onChangeText={(text) => {
setPassword(text);
setError(null);
}}
placeholder={t("login.password")}
placeholderTextColor='#6B7280'
secureTextEntry
autoFocus
autoCapitalize='none'
autoCorrect={false}
style={{
backgroundColor: "#1F2937",
borderRadius: 8,
padding: 12,
color: "white",
fontSize: 16,
}}
onSubmitEditing={handleSubmit}
returnKeyType='done'
/>
{error && <Text className='text-red-500 mt-2'>{error}</Text>}
</View>
{/* Buttons */}
<View className='flex-row gap-3'>
<Button
onPress={onClose}
color='black'
className='flex-1'
disabled={isLoading}
>
{t("common.cancel")}
</Button>
<Button
onPress={handleSubmit}
color='purple'
className='flex-1'
disabled={isLoading || !password}
>
{isLoading ? (
<ActivityIndicator size='small' color='white' />
) : (
t("login.login")
)}
</Button>
</View>
</View>
</BottomSheetView>
</BottomSheetModal>
);
};

View File

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

View File

@@ -2,13 +2,13 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons } from "@expo/vector-icons"; import { Feather, Ionicons } from "@expo/vector-icons";
import { BottomSheetView } from "@gorhom/bottom-sheet"; import { BottomSheetView } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, Platform, TouchableOpacity, View } from "react-native"; import { Alert, Platform, TouchableOpacity, View } from "react-native";
import CastContext, { import CastContext, {
CastButton, CastButton,
MediaStreamType,
PlayServicesState, PlayServicesState,
useMediaStatus, useMediaStatus,
useRemoteMediaClient, useRemoteMediaClient,
@@ -23,11 +23,13 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn"; import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { getDownloadedItemById } from "@/providers/Downloads/database"; import { getDownloadedItemById } from "@/providers/Downloads/database";
import { useGlobalModal } from "@/providers/GlobalModalProvider"; import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
@@ -43,7 +45,6 @@ import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof TouchableOpacity> { interface Props extends React.ComponentProps<typeof TouchableOpacity> {
item: BaseItemDto; item: BaseItemDto;
selectedOptions: SelectedOptions; selectedOptions: SelectedOptions;
isOffline?: boolean;
colors?: ThemeColors; colors?: ThemeColors;
} }
@@ -53,9 +54,9 @@ const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({ export const PlayButton: React.FC<Props> = ({
item, item,
selectedOptions, selectedOptions,
isOffline,
colors, colors,
}: Props) => { }: Props) => {
const isOffline = useOfflineMode();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient(); const client = useRemoteMediaClient();
const mediaStatus = useMediaStatus(); const mediaStatus = useMediaStatus();
@@ -185,11 +186,23 @@ export const PlayButton: React.FC<Props> = ({
return; return;
} }
// Calculate start time in seconds from playback position
const startTimeSeconds =
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
// Calculate stream duration in seconds from runtime
const streamDurationSeconds = item.RunTimeTicks
? item.RunTimeTicks / 10000000
: undefined;
client client
.loadMedia({ .loadMedia({
mediaInfo: { mediaInfo: {
contentId: item.Id,
contentUrl: data?.url, contentUrl: data?.url,
contentType: "video/mp4", contentType: "video/mp4",
streamType: MediaStreamType.BUFFERED,
streamDuration: streamDurationSeconds,
metadata: metadata:
item.Type === "Episode" item.Type === "Episode"
? { ? {
@@ -241,7 +254,7 @@ export const PlayButton: React.FC<Props> = ({
], ],
}, },
}, },
startTime: 0, startTime: startTimeSeconds,
}) })
.then(() => { .then(() => {
// state is already set when reopening current media, so skip it here. // state is already set when reopening current media, so skip it here.
@@ -287,6 +300,19 @@ export const PlayButton: React.FC<Props> = ({
// Check if item is downloaded // Check if item is downloaded
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined; const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
// If already in offline mode, play downloaded file directly
if (isOffline && downloadedItem) {
const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
return;
}
// If online but file is downloaded, ask user which version to play
if (downloadedItem) { if (downloadedItem) {
if (Platform.OS === "android") { if (Platform.OS === "android") {
// Show bottom sheet for Android // Show bottom sheet for Android

View File

@@ -1,6 +1,5 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
@@ -14,6 +13,7 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn"; import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";

View File

@@ -7,7 +7,6 @@ import { RoundButton } from "./RoundButton";
interface Props extends ViewProps { interface Props extends ViewProps {
items: BaseItemDto[]; items: BaseItemDto[];
isOffline?: boolean;
size?: "default" | "large"; size?: "default" | "large";
} }

View File

@@ -1,42 +1,266 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react"; import type React from "react";
import { useMemo } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { ActivityIndicator, Alert, TouchableOpacity, View } from "react-native";
import { Swipeable } from "react-native-gesture-handler";
import { useMMKVString } from "react-native-mmkv"; import { useMMKVString } from "react-native-mmkv";
import { Colors } from "@/constants/Colors";
import {
deleteAccountCredential,
getPreviousServers,
removeServerFromList,
type SavedServer,
type SavedServerAccount,
} from "@/utils/secureCredentials";
import { AccountsSheet } from "./AccountsSheet";
import { Text } from "./common/Text";
import { ListGroup } from "./list/ListGroup"; import { ListGroup } from "./list/ListGroup";
import { ListItem } from "./list/ListItem"; import { ListItem } from "./list/ListItem";
import { PasswordEntryModal } from "./PasswordEntryModal";
interface Server { import { PINEntryModal } from "./PINEntryModal";
address: string;
}
interface PreviousServersListProps { interface PreviousServersListProps {
onServerSelect: (server: Server) => void; onServerSelect: (server: SavedServer) => void;
onQuickLogin?: (serverUrl: string, userId: string) => Promise<void>;
onPasswordLogin?: (
serverUrl: string,
username: string,
password: string,
) => Promise<void>;
onAddAccount?: (server: SavedServer) => void;
} }
export const PreviousServersList: React.FC<PreviousServersListProps> = ({ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
onServerSelect, onServerSelect,
onQuickLogin,
onPasswordLogin,
onAddAccount,
}) => { }) => {
const [_previousServers, setPreviousServers] = const [_previousServers, setPreviousServers] =
useMMKVString("previousServers"); useMMKVString("previousServers");
const [loadingServer, setLoadingServer] = useState<string | null>(null);
// Modal states
const [accountsSheetOpen, setAccountsSheetOpen] = useState(false);
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
null,
);
const [pinModalVisible, setPinModalVisible] = useState(false);
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
const [selectedAccount, setSelectedAccount] =
useState<SavedServerAccount | null>(null);
const previousServers = useMemo(() => { const previousServers = useMemo(() => {
return JSON.parse(_previousServers || "[]") as Server[]; return JSON.parse(_previousServers || "[]") as SavedServer[];
}, [_previousServers]); }, [_previousServers]);
const { t } = useTranslation(); const { t } = useTranslation();
const refreshServers = () => {
const servers = getPreviousServers();
setPreviousServers(JSON.stringify(servers));
};
const handleAccountLogin = async (
server: SavedServer,
account: SavedServerAccount,
) => {
switch (account.securityType) {
case "none":
// Quick login without protection
if (onQuickLogin) {
setLoadingServer(server.address);
try {
await onQuickLogin(server.address, account.userId);
} catch {
Alert.alert(
t("server.session_expired"),
t("server.please_login_again"),
[{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
);
} finally {
setLoadingServer(null);
}
}
break;
case "pin":
// Show PIN entry modal
setSelectedServer(server);
setSelectedAccount(account);
setPinModalVisible(true);
break;
case "password":
// Show password entry modal
setSelectedServer(server);
setSelectedAccount(account);
setPasswordModalVisible(true);
break;
}
};
const handleServerPress = async (server: SavedServer) => {
if (loadingServer) return; // Prevent double-tap
const accountCount = server.accounts?.length || 0;
if (accountCount === 0) {
// No saved accounts, go to manual login
onServerSelect(server);
} else {
// Has accounts, show account sheet (allows adding new account too)
setSelectedServer(server);
setAccountsSheetOpen(true);
}
};
const handlePinSuccess = async () => {
setPinModalVisible(false);
if (selectedServer && selectedAccount && onQuickLogin) {
setLoadingServer(selectedServer.address);
try {
await onQuickLogin(selectedServer.address, selectedAccount.userId);
} catch {
Alert.alert(
t("server.session_expired"),
t("server.please_login_again"),
[
{
text: t("common.ok"),
onPress: () => onServerSelect(selectedServer),
},
],
);
} finally {
setLoadingServer(null);
setSelectedAccount(null);
setSelectedServer(null);
}
}
};
const handlePasswordSubmit = async (password: string) => {
if (selectedServer && selectedAccount && onPasswordLogin) {
await onPasswordLogin(
selectedServer.address,
selectedAccount.username,
password,
);
setPasswordModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}
};
const handleForgotPIN = async () => {
if (selectedServer && selectedAccount) {
await deleteAccountCredential(
selectedServer.address,
selectedAccount.userId,
);
refreshServers();
// Go to manual login
onServerSelect(selectedServer);
setSelectedAccount(null);
setSelectedServer(null);
}
};
const handleRemoveFirstCredential = async (serverUrl: string) => {
const server = previousServers.find((s) => s.address === serverUrl);
if (!server || server.accounts.length === 0) return;
Alert.alert(
t("server.remove_saved_login"),
t("server.remove_saved_login_description"),
[
{ text: t("common.cancel"), style: "cancel" },
{
text: t("common.remove"),
style: "destructive",
onPress: async () => {
// Remove first account
await deleteAccountCredential(serverUrl, server.accounts[0].userId);
refreshServers();
},
},
],
);
};
const handleRemoveServer = useCallback(
async (serverUrl: string) => {
await removeServerFromList(serverUrl);
refreshServers();
},
[setPreviousServers],
);
const renderRightActions = useCallback(
(serverUrl: string, swipeableRef: React.RefObject<Swipeable | null>) => (
<TouchableOpacity
onPress={() => {
swipeableRef.current?.close();
handleRemoveServer(serverUrl);
}}
className='bg-red-600 justify-center items-center px-5'
>
<Ionicons name='trash' size={20} color='white' />
</TouchableOpacity>
),
[handleRemoveServer],
);
const getServerSubtitle = (server: SavedServer): string | undefined => {
const accountCount = server.accounts?.length || 0;
if (accountCount > 1) {
return t("server.accounts_count", { count: accountCount });
}
if (accountCount === 1) {
return `${server.accounts[0].username}${t("server.saved")}`;
}
return server.name ? server.address : undefined;
};
const getSecurityIcon = (
server: SavedServer,
): keyof typeof Ionicons.glyphMap | null => {
const accountCount = server.accounts?.length || 0;
if (accountCount === 0) return null;
if (accountCount > 1) {
return "people";
}
const account = server.accounts[0];
switch (account.securityType) {
case "pin":
return "keypad";
case "password":
return "lock-closed";
default:
return "key";
}
};
if (!previousServers.length) return null; if (!previousServers.length) return null;
return ( return (
<View> <View>
<ListGroup title={t("server.previous_servers")} className='mt-4'> <ListGroup title={t("server.previous_servers")} className='mt-4'>
{previousServers.map((s) => ( {previousServers.map((s) => (
<ListItem <ServerItem
key={s.address} key={s.address}
onPress={() => onServerSelect(s)} server={s}
title={s.address} loadingServer={loadingServer}
showArrow onPress={() => handleServerPress(s)}
onRemoveCredential={() => handleRemoveFirstCredential(s.address)}
renderRightActions={renderRightActions}
subtitle={getServerSubtitle(s)}
securityIcon={getSecurityIcon(s)}
/> />
))} ))}
<ListItem <ListItem
@@ -47,6 +271,113 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
textColor='red' textColor='red'
/> />
</ListGroup> </ListGroup>
<Text className='text-xs text-neutral-500 mt-2 ml-4'>
{t("server.swipe_to_remove")}
</Text>
{/* Account Selection Sheet */}
<AccountsSheet
open={accountsSheetOpen}
setOpen={setAccountsSheetOpen}
server={selectedServer}
onAccountSelect={(account) => {
if (selectedServer) {
handleAccountLogin(selectedServer, account);
}
}}
onAddAccount={() => {
if (selectedServer && onAddAccount) {
onAddAccount(selectedServer);
}
}}
onAccountDeleted={refreshServers}
/>
{/* PIN Entry Modal */}
<PINEntryModal
visible={pinModalVisible}
onClose={() => {
setPinModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
onSuccess={handlePinSuccess}
onForgotPIN={handleForgotPIN}
serverUrl={selectedServer?.address || ""}
userId={selectedAccount?.userId || ""}
username={selectedAccount?.username || ""}
/>
{/* Password Entry Modal */}
<PasswordEntryModal
visible={passwordModalVisible}
onClose={() => {
setPasswordModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
onSubmit={handlePasswordSubmit}
username={selectedAccount?.username || ""}
/>
</View> </View>
); );
}; };
interface ServerItemProps {
server: SavedServer;
loadingServer: string | null;
onPress: () => void;
onRemoveCredential: () => void;
renderRightActions: (
serverUrl: string,
swipeableRef: React.RefObject<Swipeable | null>,
) => React.ReactNode;
subtitle?: string;
securityIcon: keyof typeof Ionicons.glyphMap | null;
}
const ServerItem: React.FC<ServerItemProps> = ({
server,
loadingServer,
onPress,
onRemoveCredential,
renderRightActions,
subtitle,
securityIcon,
}) => {
const swipeableRef = useRef<Swipeable>(null);
const hasAccounts = server.accounts?.length > 0;
return (
<Swipeable
ref={swipeableRef}
renderRightActions={() =>
renderRightActions(server.address, swipeableRef)
}
overshootRight={false}
>
<ListItem
onPress={onPress}
title={server.name || server.address}
subtitle={subtitle}
showArrow={loadingServer !== server.address}
disabled={loadingServer === server.address}
>
{loadingServer === server.address ? (
<ActivityIndicator size='small' color={Colors.primary} />
) : hasAccounts && securityIcon ? (
<TouchableOpacity
onPress={(e) => {
e.stopPropagation();
onRemoveCredential();
}}
className='p-1'
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name={securityIcon} size={16} color={Colors.primary} />
</TouchableOpacity>
) : null}
</ListItem>
</Swipeable>
);
};

View File

@@ -1,7 +1,8 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { Platform, TouchableOpacity, type ViewProps } from "react-native"; import { Platform, type ViewProps } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
interface Props extends ViewProps { interface Props extends ViewProps {
@@ -38,7 +39,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
if (Platform.OS === "ios") { if (Platform.OS === "ios") {
return ( return (
<TouchableOpacity <Pressable
onPress={handlePress} onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`} className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...(viewProps as any)} {...(viewProps as any)}
@@ -51,13 +52,13 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
/> />
) : null} ) : null}
{children ? children : null} {children ? children : null}
</TouchableOpacity> </Pressable>
); );
} }
if (fillColor) if (fillColor)
return ( return (
<TouchableOpacity <Pressable
onPress={handlePress} onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`} className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...(viewProps as any)} {...(viewProps as any)}
@@ -70,12 +71,12 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
/> />
) : null} ) : null}
{children ? children : null} {children ? children : null}
</TouchableOpacity> </Pressable>
); );
if (background === false) if (background === false)
return ( return (
<TouchableOpacity <Pressable
onPress={handlePress} onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`} className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...(viewProps as any)} {...(viewProps as any)}
@@ -88,12 +89,12 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
/> />
) : null} ) : null}
{children ? children : null} {children ? children : null}
</TouchableOpacity> </Pressable>
); );
if (Platform.OS === "android") if (Platform.OS === "android")
return ( return (
<TouchableOpacity <Pressable
onPress={handlePress} onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${ className={`rounded-full ${buttonSize} flex items-center justify-center ${
fillColor ? fillColorClass : "bg-transparent" fillColor ? fillColorClass : "bg-transparent"
@@ -108,11 +109,11 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
/> />
) : null} ) : null}
{children ? children : null} {children ? children : null}
</TouchableOpacity> </Pressable>
); );
return ( return (
<TouchableOpacity onPress={handlePress} {...(viewProps as any)}> <Pressable onPress={handlePress} {...(viewProps as any)}>
<BlurView <BlurView
intensity={90} intensity={90}
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`} className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
@@ -127,6 +128,6 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
) : null} ) : null}
{children ? children : null} {children ? children : null}
</BlurView> </BlurView>
</TouchableOpacity> </Pressable>
); );
}; };

View File

@@ -0,0 +1,252 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { AccountSecurityType } from "@/utils/secureCredentials";
import { Button } from "./Button";
import { Text } from "./common/Text";
import { PinInput } from "./inputs/PinInput";
interface SaveAccountModalProps {
visible: boolean;
onClose: () => void;
onSave: (securityType: AccountSecurityType, pinCode?: string) => void;
username: string;
}
interface SecurityOption {
type: AccountSecurityType;
titleKey: string;
descriptionKey: string;
icon: keyof typeof Ionicons.glyphMap;
}
const SECURITY_OPTIONS: SecurityOption[] = [
{
type: "none",
titleKey: "save_account.no_protection",
descriptionKey: "save_account.no_protection_desc",
icon: "flash-outline",
},
{
type: "pin",
titleKey: "save_account.pin_code",
descriptionKey: "save_account.pin_code_desc",
icon: "keypad-outline",
},
{
type: "password",
titleKey: "save_account.password",
descriptionKey: "save_account.password_desc",
icon: "lock-closed-outline",
},
];
export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
visible,
onClose,
onSave,
username,
}) => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [selectedType, setSelectedType] = useState<AccountSecurityType>("none");
const [pinCode, setPinCode] = useState("");
const [pinError, setPinError] = useState<string | null>(null);
const isAndroid = Platform.OS === "android";
const snapPoints = useMemo(
() => (isAndroid ? ["100%"] : ["70%"]),
[isAndroid],
);
useEffect(() => {
if (visible) {
bottomSheetModalRef.current?.present();
} else {
bottomSheetModalRef.current?.dismiss();
}
}, [visible]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index === -1) {
resetState();
onClose();
}
},
[onClose],
);
const resetState = () => {
setSelectedType("none");
setPinCode("");
setPinError(null);
};
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleOptionSelect = (type: AccountSecurityType) => {
setSelectedType(type);
setPinCode("");
setPinError(null);
};
const handleSave = () => {
if (selectedType === "pin") {
if (pinCode.length !== 4) {
setPinError(t("pin.enter_4_digits") || "Enter 4 digits");
return;
}
onSave("pin", pinCode);
} else {
onSave(selectedType);
}
resetState();
};
const handleCancel = () => {
resetState();
onClose();
};
const canSave = () => {
if (selectedType === "pin") {
return pinCode.length === 4;
}
return true;
};
return (
<BottomSheetModal
ref={bottomSheetModalRef}
snapPoints={snapPoints}
onChange={handleSheetChanges}
handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundStyle={{ backgroundColor: "#171717" }}
backdropComponent={renderBackdrop}
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
keyboardBlurBehavior='restore'
android_keyboardInputMode='adjustResize'
topInset={isAndroid ? 0 : undefined}
>
<BottomSheetView
style={{
flex: 1,
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
paddingBottom: Math.max(16, insets.bottom),
}}
>
<View className='flex-1'>
{/* Header */}
<View className='mb-4'>
<Text className='font-bold text-2xl text-neutral-100'>
{t("save_account.title")}
</Text>
<Text className='text-neutral-400 mt-1'>{username}</Text>
</View>
{/* PIN Entry Step */}
{selectedType === "pin" ? (
<View className='flex-1'>
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 mb-4'>
<Text className='text-neutral-100 text-center text-lg mb-4'>
{t("pin.setup_pin")}
</Text>
<PinInput
value={pinCode}
onChangeText={setPinCode}
length={4}
style={{ paddingHorizontal: 16 }}
autoFocus
/>
{pinError && (
<Text className='text-red-500 text-center mt-3'>
{pinError}
</Text>
)}
</View>
</View>
) : (
/* Security Options */
<View className='flex-1'>
<Text className='text-neutral-400 mb-3'>
{t("save_account.security_option")}
</Text>
<View className='bg-neutral-800 rounded-xl overflow-hidden'>
{SECURITY_OPTIONS.map((option, index) => (
<TouchableOpacity
key={option.type}
onPress={() => handleOptionSelect(option.type)}
className={`flex-row items-center p-4 ${
index < SECURITY_OPTIONS.length - 1
? "border-b border-neutral-700"
: ""
}`}
>
<View className='w-10 h-10 bg-neutral-700 rounded-full items-center justify-center mr-3'>
<Ionicons name={option.icon} size={20} color='white' />
</View>
<View className='flex-1'>
<Text className='text-neutral-100 font-medium'>
{t(option.titleKey)}
</Text>
<Text className='text-neutral-400 text-sm'>
{t(option.descriptionKey)}
</Text>
</View>
<View
className={`w-6 h-6 rounded-full border-2 items-center justify-center ${
selectedType === option.type
? "border-purple-500 bg-purple-500"
: "border-neutral-500"
}`}
>
{selectedType === option.type && (
<Ionicons name='checkmark' size={14} color='white' />
)}
</View>
</TouchableOpacity>
))}
</View>
</View>
)}
{/* Buttons */}
<View className='flex-row gap-3 mt-4'>
<Button onPress={handleCancel} color='black' className='flex-1'>
{t("save_account.cancel_button")}
</Button>
<Button
onPress={handleSave}
color='purple'
className='flex-1'
disabled={!canSave()}
>
{t("save_account.save_button")}
</Button>
</View>
</View>
</BottomSheetView>
</BottomSheetModal>
);
};

View File

@@ -7,7 +7,6 @@ import {
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
@@ -26,6 +25,7 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import useRouter from "@/hooks/useAppRouter";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";

View File

@@ -1,42 +1,36 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { BlurView, type BlurViewProps } from "expo-blur"; import { BlurView, type BlurViewProps } from "expo-blur";
import { useRouter } from "expo-router"; import { Platform } from "react-native";
import { import { Pressable, type PressableProps } from "react-native-gesture-handler";
Platform, import useRouter from "@/hooks/useAppRouter";
TouchableOpacity,
type TouchableOpacityProps,
} from "react-native";
interface Props extends BlurViewProps { interface Props extends BlurViewProps {
background?: "blur" | "transparent"; background?: "blur" | "transparent";
touchableOpacityProps?: TouchableOpacityProps; pressableProps?: Omit<PressableProps, "onPress">;
} }
export const HeaderBackButton: React.FC<Props> = ({ export const HeaderBackButton: React.FC<Props> = ({
background = "transparent", background = "transparent",
touchableOpacityProps, pressableProps,
...props ...props
}) => { }) => {
const router = useRouter(); const router = useRouter();
if (Platform.OS === "ios") { if (Platform.OS === "ios") {
return ( return (
<TouchableOpacity <Pressable
onPress={() => router.back()} onPress={() => router.back()}
className='flex items-center justify-center w-9 h-9' className='flex items-center justify-center w-9 h-9'
{...touchableOpacityProps} {...pressableProps}
> >
<Ionicons name='arrow-back' size={24} color='white' /> <Ionicons name='arrow-back' size={24} color='white' />
</TouchableOpacity> </Pressable>
); );
} }
if (background === "transparent" && Platform.OS !== "android") if (background === "transparent" && Platform.OS !== "android")
return ( return (
<TouchableOpacity <Pressable onPress={() => router.back()} {...pressableProps}>
onPress={() => router.back()}
{...touchableOpacityProps}
>
<BlurView <BlurView
{...props} {...props}
intensity={100} intensity={100}
@@ -49,14 +43,14 @@ export const HeaderBackButton: React.FC<Props> = ({
color='white' color='white'
/> />
</BlurView> </BlurView>
</TouchableOpacity> </Pressable>
); );
return ( return (
<TouchableOpacity <Pressable
onPress={() => router.back()} onPress={() => router.back()}
className=' rounded-full p-2' className=' rounded-full p-2'
{...touchableOpacityProps} {...pressableProps}
> >
<Ionicons <Ionicons
className='drop-shadow-2xl' className='drop-shadow-2xl'
@@ -64,6 +58,6 @@ export const HeaderBackButton: React.FC<Props> = ({
size={24} size={24}
color='white' color='white'
/> />
</TouchableOpacity> </Pressable>
); );
}; };

View File

@@ -1,7 +1,8 @@
import { useRouter, useSegments } from "expo-router"; import { useSegments } from "expo-router";
import type React from "react"; import type React from "react";
import { type PropsWithChildren } from "react"; import { type PropsWithChildren } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person"; import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";

View File

@@ -1,14 +1,16 @@
import { useActionSheet } from "@expo/react-native-action-sheet"; import { useActionSheet } from "@expo/react-native-action-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter, useSegments } from "expo-router"; import { useSegments } from "expo-router";
import { type PropsWithChildren, useCallback } from "react"; import { type PropsWithChildren, useCallback } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useFavorite } from "@/hooks/useFavorite"; import { useFavorite } from "@/hooks/useFavorite";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { useDownload } from "@/providers/DownloadProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
item: BaseItemDto; item: BaseItemDto;
isOffline?: boolean;
} }
export const itemRouter = (item: BaseItemDto, from: string) => { export const itemRouter = (item: BaseItemDto, from: string) => {
@@ -82,13 +84,49 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
}; };
} }
if (item.Type === "CollectionFolder" || item.Type === "Playlist") { if (item.Type === "CollectionFolder") {
return { return {
pathname: "/[libraryId]" as const, pathname: "/[libraryId]" as const,
params: { libraryId: item.Id! }, params: { libraryId: item.Id! },
}; };
} }
// Music types - use shared routes for proper back navigation
if (item.Type === "MusicArtist") {
return {
pathname: "/music/artist/[artistId]" as const,
params: { artistId: item.Id! },
};
}
if (item.Type === "MusicAlbum") {
return {
pathname: "/music/album/[albumId]" as const,
params: { albumId: item.Id! },
};
}
if (item.Type === "Audio") {
// Navigate to the album if available, otherwise to the item page
if (item.AlbumId) {
return {
pathname: "/music/album/[albumId]" as const,
params: { albumId: item.AlbumId },
};
}
return {
pathname: "/items/page" as const,
params: { id: item.Id! },
};
}
if (item.Type === "Playlist") {
return {
pathname: "/music/playlist/[playlistId]" as const,
params: { playlistId: item.Id! },
};
}
// Default case - items page // Default case - items page
return { return {
pathname: "/items/page" as const, pathname: "/items/page" as const,
@@ -98,26 +136,20 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
item, item,
isOffline = false,
children, children,
...props ...props
}) => { }) => {
const router = useRouter();
const segments = useSegments(); const segments = useSegments();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const markAsPlayedStatus = useMarkAsPlayed([item]); const markAsPlayedStatus = useMarkAsPlayed([item]);
const { isFavorite, toggleFavorite } = useFavorite(item); const { isFavorite, toggleFavorite } = useFavorite(item);
const router = useRouter();
const isOffline = useOfflineMode();
const { deleteFile } = useDownload();
const from = (segments as string[])[2] || "(home)"; const from = (segments as string[])[2] || "(home)";
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
// For offline mode, we still need to use query params
if (isOffline) {
const url = `${itemRouter(item, from)}&offline=true`;
router.push(url as any);
return;
}
// Force music libraries to navigate via the explicit string route. // Force music libraries to navigate via the explicit string route.
// This avoids losing the dynamic [libraryId] param when going through a nested navigator. // This avoids losing the dynamic [libraryId] param when going through a nested navigator.
if ("CollectionType" in item && item.CollectionType === "music") { if ("CollectionType" in item && item.CollectionType === "music") {
@@ -127,7 +159,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
const navigation = getItemNavigation(item, from); const navigation = getItemNavigation(item, from);
router.push(navigation as any); router.push(navigation as any);
}, [from, isOffline, item, router]); }, [from, item, router]);
const showActionSheet = useCallback(() => { const showActionSheet = useCallback(() => {
if ( if (
@@ -143,14 +175,19 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
"Mark as Played", "Mark as Played",
"Mark as Not Played", "Mark as Not Played",
isFavorite ? "Unmark as Favorite" : "Mark as Favorite", isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
...(isOffline ? ["Delete Download"] : []),
"Cancel", "Cancel",
]; ];
const cancelButtonIndex = options.length - 1; const cancelButtonIndex = options.length - 1;
const destructiveButtonIndex = isOffline
? cancelButtonIndex - 1
: undefined;
showActionSheetWithOptions( showActionSheetWithOptions(
{ {
options, options,
cancelButtonIndex, cancelButtonIndex,
destructiveButtonIndex,
}, },
async (selectedIndex) => { async (selectedIndex) => {
if (selectedIndex === 0) { if (selectedIndex === 0) {
@@ -159,6 +196,8 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
await markAsPlayedStatus(false); await markAsPlayedStatus(false);
} else if (selectedIndex === 2) { } else if (selectedIndex === 2) {
toggleFavorite(); toggleFavorite();
} else if (isOffline && selectedIndex === 3 && item.Id) {
deleteFile(item.Id);
} }
}, },
); );
@@ -167,6 +206,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
isFavorite, isFavorite,
markAsPlayedStatus, markAsPlayedStatus,
toggleFavorite, toggleFavorite,
isOffline,
deleteFile,
item.Id,
]); ]);
if ( if (

View File

@@ -1,7 +1,5 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useQueryClient } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
@@ -12,6 +10,8 @@ import {
} from "react-native"; } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator"; import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";
import { JobStatus } from "@/providers/Downloads/types"; import { JobStatus } from "@/providers/Downloads/types";
@@ -37,7 +37,7 @@ interface DownloadCardProps extends TouchableOpacityProps {
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => { export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { cancelDownload } = useDownload(); const { cancelDownload } = useDownload();
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useNetworkAwareQueryClient();
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
try { try {

View File

@@ -61,7 +61,6 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
return ( return (
<TouchableItemRouter <TouchableItemRouter
item={item} item={item}
isOffline={true}
onLongPress={showActionSheet} onLongPress={showActionSheet}
className='flex flex-col mb-4' className='flex flex-col mb-4'
> >

View File

@@ -67,7 +67,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
}, [showActionSheetWithOptions, handleDeleteFile]); }, [showActionSheetWithOptions, handleDeleteFile]);
return ( return (
<TouchableItemRouter onLongPress={showActionSheet} item={item} isOffline> <TouchableItemRouter onLongPress={showActionSheet} item={item}>
{base64Image ? ( {base64Image ? (
<View className='relative w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'> <View className='relative w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
<Image <Image

View File

@@ -2,11 +2,11 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { router } from "expo-router";
import type React from "react"; import type React from "react";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { DownloadSize } from "@/components/downloads/DownloadSize"; import { DownloadSize } from "@/components/downloads/DownloadSize";
import useRouter from "@/hooks/useAppRouter";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
@@ -14,6 +14,7 @@ import { Text } from "../common/Text";
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => { export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
const { deleteItems } = useDownload(); const { deleteItems } = useDownload();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const router = useRouter();
const base64Image = useMemo(() => { const base64Image = useMemo(() => {
return storage.getString(items[0].SeriesId!); return storage.getString(items[0].SeriesId!);
@@ -46,7 +47,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={() => router.push(`/downloads/${items[0].SeriesId}`)} onPress={() =>
router.push({
pathname: "/series/[id]",
params: { id: items[0].SeriesId!, offline: "true" },
})
}
onLongPress={showActionSheet} onLongPress={showActionSheet}
> >
{base64Image ? ( {base64Image ? (

View File

@@ -2,7 +2,6 @@ import type { Api } from "@jellyfin/sdk";
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
@@ -10,6 +9,7 @@ import { Text, View } from "react-native";
// PNG ASSET // PNG ASSET
import heart from "@/assets/icons/heart.fill.png"; import heart from "@/assets/icons/heart.fill.png";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { InfiniteScrollingCollectionList } from "./InfiniteScrollingCollectionList"; import { InfiniteScrollingCollectionList } from "./InfiniteScrollingCollectionList";

View File

@@ -12,7 +12,7 @@ import {
getUserViewsApi, getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { type QueryFunction, useQuery } from "@tanstack/react-query"; import { type QueryFunction, useQuery } from "@tanstack/react-query";
import { useNavigation, useRouter, useSegments } from "expo-router"; import { useNavigation, useSegments } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -21,9 +21,9 @@ import {
Platform, Platform,
RefreshControl, RefreshControl,
ScrollView, ScrollView,
TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
@@ -33,12 +33,16 @@ import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecom
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection"; import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { useIntroSheet } from "@/providers/IntroSheetProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
import { storage } from "@/utils/mmkv";
type InfiniteScrollingCollectionListSection = { type InfiniteScrollingCollectionListSection = {
type: "InfiniteScrollingCollectionList"; type: "InfiniteScrollingCollectionList";
@@ -48,6 +52,7 @@ type InfiniteScrollingCollectionListSection = {
orientation?: "horizontal" | "vertical"; orientation?: "horizontal" | "vertical";
pageSize?: number; pageSize?: number;
priority?: 1 | 2; // 1 = high priority (loads first), 2 = low priority priority?: 1 | 2; // 1 = high priority (loads first), 2 = low priority
parentId?: string; // Library ID for "See All" navigation
}; };
type MediaListSectionType = { type MediaListSectionType = {
@@ -79,6 +84,21 @@ export const Home = () => {
} = useNetworkStatus(); } = useNetworkStatus();
const invalidateCache = useInvalidatePlaybackProgressCache(); const invalidateCache = useInvalidatePlaybackProgressCache();
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set()); const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
const { showIntro } = useIntroSheet();
// Show intro modal on first launch
useEffect(() => {
const hasShownIntro = storage.getBoolean("hasShownIntro");
if (!hasShownIntro) {
const timer = setTimeout(() => {
showIntro();
}, 1000);
return () => {
clearTimeout(timer);
};
}
}, [showIntro]);
useEffect(() => { useEffect(() => {
if (isConnected && !prevIsConnected.current) { if (isConnected && !prevIsConnected.current) {
@@ -101,7 +121,7 @@ export const Home = () => {
} }
navigation.setOptions({ navigation.setOptions({
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity <Pressable
onPress={() => { onPress={() => {
router.push("/(auth)/downloads"); router.push("/(auth)/downloads");
}} }}
@@ -113,7 +133,7 @@ export const Home = () => {
color={hasDownloads ? Colors.primary : "white"} color={hasDownloads ? Colors.primary : "white"}
size={24} size={24}
/> />
</TouchableOpacity> </Pressable>
), ),
}); });
}, [navigation, router, hasDownloads]); }, [navigation, router, hasDownloads]);
@@ -213,6 +233,7 @@ export const Home = () => {
}, },
type: "InfiniteScrollingCollectionList", type: "InfiniteScrollingCollectionList",
pageSize, pageSize,
parentId,
}), }),
[api, user?.Id], [api, user?.Id],
); );
@@ -616,6 +637,18 @@ export const Home = () => {
) : null; ) : null;
if (section.type === "InfiniteScrollingCollectionList") { if (section.type === "InfiniteScrollingCollectionList") {
const isHighPriority = section.priority === 1; const isHighPriority = section.priority === 1;
const handleSeeAll = section.parentId
? () => {
router.push({
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
params: {
libraryId: section.parentId!,
sortBy: SortByOption.DateCreated,
sortOrder: SortOrderOption.Descending,
},
} as any);
}
: undefined;
return ( return (
<View key={index} className='flex flex-col space-y-4'> <View key={index} className='flex flex-col space-y-4'>
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
@@ -631,6 +664,7 @@ export const Home = () => {
? () => markSectionLoaded(section.queryKey) ? () => markSectionLoaded(section.queryKey)
: undefined : undefined
} }
onPressSeeAll={handleSeeAll}
/> />
{streamystatsSections} {streamystatsSections}
</View> </View>

View File

@@ -12,7 +12,7 @@ import {
getUserViewsApi, getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { type QueryFunction, useQuery } from "@tanstack/react-query"; import { type QueryFunction, useQuery } from "@tanstack/react-query";
import { useNavigation, useRouter, useSegments } from "expo-router"; import { useNavigation, useSegments } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -35,6 +35,7 @@ import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecom
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection"; import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";

View File

@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useRouter, useSegments } from "expo-router"; import { useSegments } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { Dimensions, View, type ViewProps } from "react-native"; import { Dimensions, View, type ViewProps } from "react-native";
@@ -16,6 +16,7 @@ import Carousel, {
type ICarouselInstance, type ICarouselInstance,
Pagination, Pagination,
} from "react-native-reanimated-carousel"; } from "react-native-reanimated-carousel";
import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";

View File

@@ -21,7 +21,6 @@ interface Props extends ViewProps {
queryKey: QueryKey; queryKey: QueryKey;
queryFn: QueryFunction<BaseItemDto[]>; queryFn: QueryFunction<BaseItemDto[]>;
hideIfEmpty?: boolean; hideIfEmpty?: boolean;
isOffline?: boolean;
scrollY?: number; // For lazy loading scrollY?: number; // For lazy loading
enableLazyLoading?: boolean; // Enable/disable lazy loading enableLazyLoading?: boolean; // Enable/disable lazy loading
} }
@@ -33,7 +32,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
queryFn, queryFn,
queryKey, queryKey,
hideIfEmpty = false, hideIfEmpty = false,
isOffline = false,
scrollY = 0, scrollY = 0,
enableLazyLoading = false, enableLazyLoading = false,
...props ...props
@@ -106,7 +104,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
<TouchableItemRouter <TouchableItemRouter
item={item} item={item}
key={item.Id} key={item.Id}
isOffline={isOffline}
className={`mr-2 className={`mr-2
${orientation === "horizontal" ? "w-44" : "w-28"} ${orientation === "horizontal" ? "w-44" : "w-28"}
`} `}

View File

@@ -4,8 +4,10 @@ import type {
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View, type ViewProps } from "react-native"; import { ScrollView, View, type ViewProps } from "react-native";
import { SectionHeader } from "@/components/common/SectionHeader"; import { SectionHeader } from "@/components/common/SectionHeader";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
@@ -33,6 +35,8 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const { settings } = useSettings(); const { settings } = useSettings();
const router = useRouter();
const { t } = useTranslation();
const { data: items, isLoading } = useQuery({ const { data: items, isLoading } = useQuery({
queryKey: [ queryKey: [
@@ -84,11 +88,23 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
return items?.map((_, index) => index * ITEM_WIDTH) ?? []; return items?.map((_, index) => index * ITEM_WIDTH) ?? [];
}, [items]); }, [items]);
const handleSeeAll = () => {
router.push({
pathname: "/(auth)/(tabs)/(watchlists)/[watchlistId]",
params: { watchlistId: watchlist.id.toString() },
} as any);
};
if (!isLoading && (!items || items.length === 0)) return null; if (!isLoading && (!items || items.length === 0)) return null;
return ( return (
<View {...props}> <View {...props}>
<SectionHeader title={watchlist.name} /> <SectionHeader
title={watchlist.name}
actionLabel={t("common.seeAll", { defaultValue: "See all" })}
actionDisabled={isLoading}
onPressAction={handleSeeAll}
/>
{isLoading ? ( {isLoading ? (
<View className='flex flex-row gap-2 px-4'> <View className='flex flex-row gap-2 px-4'>
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (

View File

@@ -8,17 +8,14 @@ import { InteractionManager, View, type ViewProps } from "react-native";
import { MoreMoviesWithActor } from "@/components/MoreMoviesWithActor"; import { MoreMoviesWithActor } from "@/components/MoreMoviesWithActor";
import { CastAndCrew } from "@/components/series/CastAndCrew"; import { CastAndCrew } from "@/components/series/CastAndCrew";
import { useItemPeopleQuery } from "@/hooks/useItemPeopleQuery"; import { useItemPeopleQuery } from "@/hooks/useItemPeopleQuery";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
interface Props extends ViewProps { interface Props extends ViewProps {
item: BaseItemDto; item: BaseItemDto;
isOffline: boolean;
} }
export const ItemPeopleSections: React.FC<Props> = ({ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
item, const isOffline = useOfflineMode();
isOffline,
...props
}) => {
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);
useEffect(() => { useEffect(() => {

View File

@@ -1,8 +1,9 @@
import { useRouter, useSegments } from "expo-router"; import { useSegments } from "expo-router";
import type React from "react"; import type React from "react";
import { TouchableOpacity, View, type ViewProps } from "react-native"; import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import Poster from "@/components/posters/Poster"; import Poster from "@/components/posters/Poster";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
interface Props { interface Props {

View File

@@ -1,9 +1,10 @@
import { router, useSegments } from "expo-router"; import { useSegments } from "expo-router";
import type React from "react"; import type React from "react";
import { useCallback } from "react"; import { useCallback } from "react";
import { TouchableOpacity, type ViewProps } from "react-native"; import { TouchableOpacity, type ViewProps } from "react-native";
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard"; import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide"; import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { import {
COMPANY_LOGO_IMAGE_FILTER, COMPANY_LOGO_IMAGE_FILTER,
@@ -16,6 +17,7 @@ const CompanySlide: React.FC<
> = ({ slide, data, ...props }) => { > = ({ slide, data, ...props }) => {
const segments = useSegments(); const segments = useSegments();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const router = useRouter();
const from = (segments as string[])[2] || "(home)"; const from = (segments as string[])[2] || "(home)";
const navigate = useCallback( const navigate = useCallback(

View File

@@ -1,10 +1,11 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { router, useSegments } from "expo-router"; import { useSegments } from "expo-router";
import type React from "react"; import type React from "react";
import { useCallback } from "react"; import { useCallback } from "react";
import { TouchableOpacity, type ViewProps } from "react-native"; import { TouchableOpacity, type ViewProps } from "react-native";
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard"; import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide"; import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
import useRouter from "@/hooks/useAppRouter";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr"; import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces"; import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
@@ -13,6 +14,7 @@ import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/consta
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => { const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const segments = useSegments(); const segments = useSegments();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const router = useRouter();
const from = (segments as string[])[2] || "(home)"; const from = (segments as string[])[2] || "(home)";
const navigate = useCallback( const navigate = useCallback(

View File

@@ -63,6 +63,10 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
_itemType = "Series"; _itemType = "Series";
} else if (library.CollectionType === "boxsets") { } else if (library.CollectionType === "boxsets") {
_itemType = "BoxSet"; _itemType = "BoxSet";
} else if (library.CollectionType === "homevideos") {
_itemType = "Video";
} else if (library.CollectionType === "musicvideos") {
_itemType = "MusicVideo";
} }
return _itemType; return _itemType;

View File

@@ -0,0 +1,98 @@
import React, { useEffect } from "react";
import { View } from "react-native";
import Animated, {
Easing,
interpolate,
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withTiming,
} from "react-native-reanimated";
interface Props {
color?: string;
barWidth?: number;
barCount?: number;
height?: number;
gap?: number;
}
const MIN_SCALE = 0.35;
const MAX_SCALE = 1;
const DURATIONS = [800, 650, 750];
const DELAYS = [0, 200, 100];
const Bar: React.FC<{
color: string;
barWidth: number;
height: number;
duration: number;
delay: number;
}> = ({ color, barWidth, height, duration, delay }) => {
const progress = useSharedValue(0);
useEffect(() => {
progress.value = withDelay(
delay,
withRepeat(
withTiming(1, { duration, easing: Easing.inOut(Easing.ease) }),
-1,
true,
),
);
}, []);
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{
scaleY: interpolate(progress.value, [0, 1], [MIN_SCALE, MAX_SCALE]),
},
],
}));
return (
<Animated.View
style={[
{
width: barWidth,
height,
backgroundColor: color,
borderRadius: 1,
},
animatedStyle,
]}
/>
);
};
export const AnimatedEqualizer: React.FC<Props> = ({
color = "#9334E9",
barWidth = 3,
barCount = 3,
height = 12,
gap = 2,
}) => {
return (
<View
style={{
flexDirection: "row",
alignItems: "flex-end",
height,
gap,
marginRight: 6,
}}
>
{Array.from({ length: barCount }).map((_, index) => (
<Bar
key={index}
color={color}
barWidth={barWidth}
height={height}
duration={DURATIONS[index % DURATIONS.length]}
delay={DELAYS[index % DELAYS.length]}
/>
))}
</View>
);
};

View File

@@ -1,6 +1,5 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { import {
@@ -23,10 +22,11 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { useMusicPlayer } from "@/providers/MusicPlayerProvider"; import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
const HORIZONTAL_MARGIN = Platform.OS === "android" ? 8 : 16; const HORIZONTAL_MARGIN = Platform.OS === "android" ? 12 : 20;
const BOTTOM_TAB_HEIGHT = Platform.OS === "android" ? 56 : 52; const BOTTOM_TAB_HEIGHT = Platform.OS === "android" ? 56 : 52;
const BAR_HEIGHT = Platform.OS === "android" ? 58 : 50; const BAR_HEIGHT = Platform.OS === "android" ? 58 : 50;

View File

@@ -1,10 +1,10 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
@@ -13,7 +13,7 @@ interface Props {
width?: number; width?: number;
} }
export const MusicAlbumCard: React.FC<Props> = ({ album, width = 150 }) => { export const MusicAlbumCard: React.FC<Props> = ({ album, width = 130 }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const router = useRouter(); const router = useRouter();

View File

@@ -0,0 +1,70 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
interface Props {
album: BaseItemDto;
}
const IMAGE_SIZE = 56;
export const MusicAlbumRowCard: React.FC<Props> = ({ album }) => {
const [api] = useAtom(apiAtom);
const router = useRouter();
const imageUrl = useMemo(
() => getPrimaryImageUrl({ api, item: album }),
[api, album],
);
const handlePress = useCallback(() => {
router.push({
pathname: "/music/album/[albumId]",
params: { albumId: album.Id! },
});
}, [router, album.Id]);
return (
<TouchableOpacity
onPress={handlePress}
className='flex-row items-center py-2'
>
<View
style={{
width: IMAGE_SIZE,
height: IMAGE_SIZE,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{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'>
<Text className='text-2xl'>🎵</Text>
</View>
)}
</View>
<View className='flex-1 ml-3'>
<Text numberOfLines={1} className='text-white text-base font-medium'>
{album.Name}
</Text>
<Text numberOfLines={1} className='text-neutral-400 text-sm mt-0.5'>
{album.AlbumArtist || album.Artists?.join(", ")}
</Text>
</View>
</TouchableOpacity>
);
};

View File

@@ -1,10 +1,10 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
@@ -13,7 +13,9 @@ interface Props {
size?: number; size?: number;
} }
export const MusicArtistCard: React.FC<Props> = ({ artist, size = 100 }) => { const IMAGE_SIZE = 48;
export const MusicArtistCard: React.FC<Props> = ({ artist }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const router = useRouter(); const router = useRouter();
@@ -32,14 +34,13 @@ export const MusicArtistCard: React.FC<Props> = ({ artist, size = 100 }) => {
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={handlePress} onPress={handlePress}
style={{ width: size }} className='flex-row items-center py-2'
className='flex flex-col items-center'
> >
<View <View
style={{ style={{
width: size, width: IMAGE_SIZE,
height: size, height: IMAGE_SIZE,
borderRadius: size / 2, borderRadius: IMAGE_SIZE / 2,
overflow: "hidden", overflow: "hidden",
backgroundColor: "#1a1a1a", backgroundColor: "#1a1a1a",
}} }}
@@ -53,13 +54,13 @@ export const MusicArtistCard: React.FC<Props> = ({ artist, size = 100 }) => {
/> />
) : ( ) : (
<View className='flex-1 items-center justify-center bg-neutral-800'> <View className='flex-1 items-center justify-center bg-neutral-800'>
<Text className='text-3xl'>👤</Text> <Text className='text-xl'>👤</Text>
</View> </View>
)} )}
</View> </View>
<Text <Text
numberOfLines={2} numberOfLines={1}
className='text-white text-xs font-medium mt-2 text-center' className='text-white text-base font-medium ml-3 flex-1'
> >
{artist.Name} {artist.Name}
</Text> </Text>

View File

@@ -7,7 +7,11 @@ import TrackPlayer, {
usePlaybackState, usePlaybackState,
useProgress, useProgress,
} from "react-native-track-player"; } from "react-native-track-player";
import { audioStorageEvents, getLocalPath } from "@/providers/AudioStorage"; import {
audioStorageEvents,
deleteTrack,
getLocalPath,
} from "@/providers/AudioStorage";
import { useMusicPlayer } from "@/providers/MusicPlayerProvider"; import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
export const MusicPlaybackEngine: React.FC = () => { export const MusicPlaybackEngine: React.FC = () => {
@@ -168,6 +172,51 @@ export const MusicPlaybackEngine: React.FC = () => {
}; };
}, []); }, []);
// Listen for playback errors (corrupted cache files)
useEffect(() => {
const subscription = TrackPlayer.addEventListener(
Event.PlaybackError,
async (event) => {
const activeTrack = await TrackPlayer.getActiveTrack();
if (!activeTrack?.url) return;
// Only handle local file errors
const url = activeTrack.url as string;
if (!url.startsWith("file://")) return;
console.warn(
`[MusicPlayer] Playback error for cached file: ${activeTrack.id}`,
event,
);
// Delete corrupted cache file
if (activeTrack.id) {
try {
await deleteTrack(activeTrack.id);
console.log(
`[MusicPlayer] Deleted corrupted cache file: ${activeTrack.id}`,
);
} catch (error) {
console.warn(
"[MusicPlayer] Failed to delete corrupted file:",
error,
);
}
}
// Skip to next track
try {
await TrackPlayer.skipToNext();
} catch {
// No next track available, stop playback
await TrackPlayer.stop();
}
},
);
return () => subscription.remove();
}, []);
// No visual component needed - TrackPlayer is headless // No visual component needed - TrackPlayer is headless
return null; return null;
}; };

View File

@@ -1,11 +1,15 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { apiAtom } from "@/providers/JellyfinProvider"; import useRouter from "@/hooks/useAppRouter";
import { getLocalPath } from "@/providers/AudioStorage";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
interface Props { interface Props {
@@ -13,11 +17,11 @@ interface Props {
width?: number; width?: number;
} }
export const MusicPlaylistCard: React.FC<Props> = ({ const IMAGE_SIZE = 56;
playlist,
width = 150, export const MusicPlaylistCard: React.FC<Props> = ({ playlist }) => {
}) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const router = useRouter(); const router = useRouter();
const imageUrl = useMemo( const imageUrl = useMemo(
@@ -25,6 +29,37 @@ export const MusicPlaylistCard: React.FC<Props> = ({
[api, playlist], [api, playlist],
); );
// Fetch playlist tracks to check download status
const { data: tracks } = useQuery({
queryKey: ["playlist-tracks-status", playlist.Id, user?.Id],
queryFn: async () => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: playlist.Id,
fields: ["MediaSources"],
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && !!playlist.Id,
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Calculate download status
const downloadStatus = useMemo(() => {
if (!tracks || tracks.length === 0) {
return { downloaded: 0, total: playlist.ChildCount || 0 };
}
const downloaded = tracks.filter(
(track) => !!getLocalPath(track.Id),
).length;
return { downloaded, total: tracks.length };
}, [tracks, playlist.ChildCount]);
const allDownloaded =
downloadStatus.total > 0 &&
downloadStatus.downloaded === downloadStatus.total;
const hasDownloads = downloadStatus.downloaded > 0;
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
router.push({ router.push({
pathname: "/music/playlist/[playlistId]", pathname: "/music/playlist/[playlistId]",
@@ -35,13 +70,12 @@ export const MusicPlaylistCard: React.FC<Props> = ({
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={handlePress} onPress={handlePress}
style={{ width }} className='flex-row items-center py-2'
className='flex flex-col'
> >
<View <View
style={{ style={{
width, width: IMAGE_SIZE,
height: width, height: IMAGE_SIZE,
borderRadius: 8, borderRadius: 8,
overflow: "hidden", overflow: "hidden",
backgroundColor: "#1a1a1a", backgroundColor: "#1a1a1a",
@@ -56,16 +90,31 @@ export const MusicPlaylistCard: React.FC<Props> = ({
/> />
) : ( ) : (
<View className='flex-1 items-center justify-center bg-neutral-800'> <View className='flex-1 items-center justify-center bg-neutral-800'>
<Text className='text-4xl'>🎶</Text> <Text className='text-2xl'>🎶</Text>
</View> </View>
)} )}
</View> </View>
<Text numberOfLines={1} className='text-white text-sm font-medium mt-2'> <View className='flex-1 ml-3'>
<Text numberOfLines={1} className='text-white text-base font-medium'>
{playlist.Name} {playlist.Name}
</Text> </Text>
<Text numberOfLines={1} className='text-neutral-400 text-xs'> <Text numberOfLines={1} className='text-neutral-400 text-sm mt-0.5'>
{playlist.ChildCount} tracks {playlist.ChildCount} tracks
</Text> </Text>
</View>
{/* Download status indicator */}
{allDownloaded ? (
<Ionicons
name='checkmark-circle'
size={18}
color='#22c55e'
style={{ marginRight: 4 }}
/>
) : hasDownloads ? (
<Text className='text-neutral-500 text-xs mr-1'>
{downloadStatus.downloaded}/{downloadStatus.total}
</Text>
) : null}
</TouchableOpacity> </TouchableOpacity>
); );
}; };

View File

@@ -5,10 +5,13 @@ import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native"; import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { AnimatedEqualizer } from "@/components/music/AnimatedEqualizer";
import { useHaptic } from "@/hooks/useHaptic";
import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { import {
audioStorageEvents, audioStorageEvents,
getLocalPath, getLocalPath,
isCached,
isPermanentDownloading, isPermanentDownloading,
isPermanentlyDownloaded, isPermanentlyDownloaded,
} from "@/providers/AudioStorage"; } from "@/providers/AudioStorage";
@@ -27,7 +30,7 @@ interface Props {
export const MusicTrackItem: React.FC<Props> = ({ export const MusicTrackItem: React.FC<Props> = ({
track, track,
index, index: _index,
queue, queue,
showArtwork = true, showArtwork = true,
onOptionsPress, onOptionsPress,
@@ -36,6 +39,7 @@ export const MusicTrackItem: React.FC<Props> = ({
const { playTrack, currentTrack, isPlaying, loadingTrackId } = const { playTrack, currentTrack, isPlaying, loadingTrackId } =
useMusicPlayer(); useMusicPlayer();
const { isConnected, serverConnected } = useNetworkStatus(); const { isConnected, serverConnected } = useNetworkStatus();
const haptic = useHaptic("light");
const imageUrl = useMemo(() => { const imageUrl = useMemo(() => {
const albumId = track.AlbumId || track.ParentId; const albumId = track.AlbumId || track.ParentId;
@@ -49,20 +53,20 @@ export const MusicTrackItem: React.FC<Props> = ({
const isTrackLoading = loadingTrackId === track.Id; const isTrackLoading = loadingTrackId === track.Id;
// Track download status with reactivity to completion events // Track download status with reactivity to completion events
// Only track permanent downloads - we don't show UI for auto-caching
const [downloadStatus, setDownloadStatus] = useState< const [downloadStatus, setDownloadStatus] = useState<
"none" | "downloading" | "downloaded" "none" | "downloading" | "downloaded" | "cached"
>(() => { >(() => {
if (isPermanentlyDownloaded(track.Id)) return "downloaded"; if (isPermanentlyDownloaded(track.Id)) return "downloaded";
if (isPermanentDownloading(track.Id)) return "downloading"; if (isPermanentDownloading(track.Id)) return "downloading";
if (isCached(track.Id)) return "cached";
return "none"; return "none";
}); });
// Listen for download completion/error events (only for permanent downloads) // Listen for download completion/error events
useEffect(() => { useEffect(() => {
const onComplete = (event: { itemId: string; permanent: boolean }) => { const onComplete = (event: { itemId: string; permanent: boolean }) => {
if (event.itemId === track.Id && event.permanent) { if (event.itemId === track.Id) {
setDownloadStatus("downloaded"); setDownloadStatus(event.permanent ? "downloaded" : "cached");
} }
}; };
const onError = (event: { itemId: string }) => { const onError = (event: { itemId: string }) => {
@@ -80,12 +84,18 @@ export const MusicTrackItem: React.FC<Props> = ({
}; };
}, [track.Id]); }, [track.Id]);
// Also check periodically if permanent download started (for when download is triggered externally) // Re-check status when track changes (for list item recycling)
useEffect(() => { useEffect(() => {
if (downloadStatus === "none" && isPermanentDownloading(track.Id)) { if (isPermanentlyDownloaded(track.Id)) {
setDownloadStatus("downloaded");
} else if (isPermanentDownloading(track.Id)) {
setDownloadStatus("downloading"); setDownloadStatus("downloading");
} else if (isCached(track.Id)) {
setDownloadStatus("cached");
} else {
setDownloadStatus("none");
} }
}); }, [track.Id]);
const _isDownloaded = downloadStatus === "downloaded"; const _isDownloaded = downloadStatus === "downloaded";
// Check if available locally (either cached or permanently downloaded) // Check if available locally (either cached or permanently downloaded)
@@ -109,8 +119,9 @@ export const MusicTrackItem: React.FC<Props> = ({
}, [onOptionsPress, track]); }, [onOptionsPress, track]);
const handleOptionsPress = useCallback(() => { const handleOptionsPress = useCallback(() => {
haptic();
onOptionsPress?.(track); onOptionsPress?.(track);
}, [onOptionsPress, track]); }, [haptic, onOptionsPress, track]);
return ( return (
<TouchableOpacity <TouchableOpacity
@@ -118,24 +129,15 @@ export const MusicTrackItem: React.FC<Props> = ({
onLongPress={handleLongPress} onLongPress={handleLongPress}
delayLongPress={300} delayLongPress={300}
disabled={isUnavailableOffline} disabled={isUnavailableOffline}
className={`flex flex-row items-center py-3 ${isCurrentTrack ? "bg-purple-900/20" : ""}`} className={`flex-row items-center py-1.5 pl-4 pr-3 ${isCurrentTrack ? "bg-purple-900/20" : ""}`}
style={isUnavailableOffline ? { opacity: 0.5 } : undefined} style={isUnavailableOffline ? { opacity: 0.5 } : undefined}
> >
{index !== undefined && ( {/* Album artwork */}
<View className='w-8 items-center'>
{isCurrentTrack && isPlaying ? (
<Ionicons name='musical-note' size={16} color='#9334E9' />
) : (
<Text className='text-neutral-500 text-sm'>{index}</Text>
)}
</View>
)}
{showArtwork && ( {showArtwork && (
<View <View
style={{ style={{
width: 48, width: 44,
height: 48, height: 44,
borderRadius: 4, borderRadius: 4,
overflow: "hidden", overflow: "hidden",
backgroundColor: "#1a1a1a", backgroundColor: "#1a1a1a",
@@ -151,7 +153,7 @@ export const MusicTrackItem: React.FC<Props> = ({
/> />
) : ( ) : (
<View className='flex-1 items-center justify-center bg-neutral-800'> <View className='flex-1 items-center justify-center bg-neutral-800'>
<Ionicons name='musical-note' size={20} color='#737373' /> <Ionicons name='musical-note' size={18} color='#737373' />
</View> </View>
)} )}
{isTrackLoading && ( {isTrackLoading && (
@@ -173,21 +175,23 @@ export const MusicTrackItem: React.FC<Props> = ({
</View> </View>
)} )}
{/* Track info */}
<View className='flex-1 mr-3'> <View className='flex-1 mr-3'>
<View className='flex-row items-center'>
{isCurrentTrack && isPlaying && <AnimatedEqualizer />}
<Text <Text
numberOfLines={1} numberOfLines={1}
className={`text-sm ${isCurrentTrack ? "text-purple-400 font-medium" : "text-white"}`} className={`flex-1 text-sm ${isCurrentTrack ? "text-purple-400 font-medium" : "text-white"}`}
> >
{track.Name} {track.Name}
</Text> </Text>
<Text numberOfLines={1} className='text-neutral-400 text-xs mt-0.5'> </View>
<Text numberOfLines={1} className='text-neutral-500 text-xs mt-0.5'>
{track.Artists?.join(", ") || track.AlbumArtist} {track.Artists?.join(", ") || track.AlbumArtist}
</Text> </Text>
</View> </View>
<Text className='text-neutral-500 text-xs mr-2'>{duration}</Text> {/* Download/cache status indicator */}
{/* Download status indicator */}
{downloadStatus === "downloading" && ( {downloadStatus === "downloading" && (
<ActivityIndicator <ActivityIndicator
size={14} size={14}
@@ -198,19 +202,23 @@ export const MusicTrackItem: React.FC<Props> = ({
{downloadStatus === "downloaded" && ( {downloadStatus === "downloaded" && (
<Ionicons <Ionicons
name='checkmark-circle' name='checkmark-circle'
size={16} size={14}
color='#22c55e' color='#22c55e'
style={{ marginRight: 8 }} style={{ marginRight: 8 }}
/> />
)} )}
{/* Duration */}
<Text className='text-neutral-500 text-xs'>{duration}</Text>
{/* Options button */}
{onOptionsPress && ( {onOptionsPress && (
<TouchableOpacity <TouchableOpacity
onPress={handleOptionsPress} onPress={handleOptionsPress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='p-1' className='pl-3 py-1'
> >
<Ionicons name='ellipsis-vertical' size={18} color='#737373' /> <Ionicons name='ellipsis-vertical' size={16} color='#737373' />
</TouchableOpacity> </TouchableOpacity>
)} )}
</TouchableOpacity> </TouchableOpacity>

View File

@@ -6,12 +6,12 @@ import {
BottomSheetView, BottomSheetView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter } from "expo-router";
import React, { useCallback, useEffect, useMemo, useRef } from "react"; import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, StyleSheet, TouchableOpacity, View } from "react-native"; import { Alert, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { useDeletePlaylist } from "@/hooks/usePlaylistMutations"; import { useDeletePlaylist } from "@/hooks/usePlaylistMutations";
interface Props { interface Props {

View File

@@ -0,0 +1,173 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
export type PlaylistSortOption = "SortName" | "DateCreated";
export type PlaylistSortOrder = "Ascending" | "Descending";
interface Props {
open: boolean;
setOpen: (open: boolean) => void;
sortBy: PlaylistSortOption;
sortOrder: PlaylistSortOrder;
onSortChange: (
sortBy: PlaylistSortOption,
sortOrder: PlaylistSortOrder,
) => void;
}
const SORT_OPTIONS: { key: PlaylistSortOption; label: string; icon: string }[] =
[
{ key: "SortName", label: "music.sort.alphabetical", icon: "text-outline" },
{
key: "DateCreated",
label: "music.sort.date_created",
icon: "time-outline",
},
];
export const PlaylistSortSheet: React.FC<Props> = ({
open,
setOpen,
sortBy,
sortOrder,
onSortChange,
}) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const snapPoints = useMemo(() => ["40%"], []);
useEffect(() => {
if (open) bottomSheetModalRef.current?.present();
else bottomSheetModalRef.current?.dismiss();
}, [open]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index === -1) {
setOpen(false);
}
},
[setOpen],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleSortSelect = useCallback(
(option: PlaylistSortOption) => {
// If selecting same option, toggle order; otherwise use sensible default
if (option === sortBy) {
onSortChange(
option,
sortOrder === "Ascending" ? "Descending" : "Ascending",
);
} else {
// Default order based on sort type
const defaultOrder: PlaylistSortOrder =
option === "SortName" ? "Ascending" : "Descending";
onSortChange(option, defaultOrder);
}
setOpen(false);
},
[sortBy, sortOrder, onSortChange, setOpen],
);
return (
<BottomSheetModal
ref={bottomSheetModalRef}
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
>
<BottomSheetView
style={{
flex: 1,
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
paddingBottom: insets.bottom,
}}
>
<Text className='text-white text-lg font-semibold mb-4'>
{t("music.sort.title")}
</Text>
<View className='flex-col rounded-xl overflow-hidden bg-neutral-800'>
{SORT_OPTIONS.map((option, index) => {
const isSelected = sortBy === option.key;
return (
<React.Fragment key={option.key}>
{index > 0 && <View style={styles.separator} />}
<TouchableOpacity
onPress={() => handleSortSelect(option.key)}
className='flex-row items-center px-4 py-3.5'
>
<Ionicons
name={option.icon as any}
size={22}
color={isSelected ? "#9334E9" : "#fff"}
/>
<Text
className={`ml-4 text-base flex-1 ${isSelected ? "text-purple-500 font-medium" : "text-white"}`}
>
{t(option.label)}
</Text>
{isSelected && (
<View className='flex-row items-center'>
<Ionicons
name={
sortOrder === "Ascending" ? "arrow-up" : "arrow-down"
}
size={18}
color='#9334E9'
/>
<Ionicons
name='checkmark'
size={22}
color='#9334E9'
style={{ marginLeft: 8 }}
/>
</View>
)}
</TouchableOpacity>
</React.Fragment>
);
})}
</View>
</BottomSheetView>
</BottomSheetModal>
);
};
const styles = StyleSheet.create({
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: "#404040",
},
});

View File

@@ -7,7 +7,6 @@ import {
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { import React, {
useCallback, useCallback,
@@ -25,9 +24,11 @@ import {
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { useFavorite } from "@/hooks/useFavorite"; import { useFavorite } from "@/hooks/useFavorite";
import { import {
audioStorageEvents, audioStorageEvents,
deleteTrack,
downloadTrack, downloadTrack,
isCached, isCached,
isPermanentDownloading, isPermanentDownloading,
@@ -80,13 +81,16 @@ export const TrackOptionsSheet: React.FC<Props> = ({
}; };
}, [track?.Id]); }, [track?.Id]);
// Force re-evaluation of cache status when track changes
useEffect(() => {
setStorageUpdateCounter((c) => c + 1);
}, [track?.Id]);
// Use a placeholder item for useFavorite when track is null // Use a placeholder item for useFavorite when track is null
const { isFavorite, toggleFavorite } = useFavorite( const { isFavorite, toggleFavorite } = useFavorite(
track ?? ({ Id: "", UserData: { IsFavorite: false } } as BaseItemDto), track ?? ({ Id: "", UserData: { IsFavorite: false } } as BaseItemDto),
); );
const snapPoints = useMemo(() => ["65%"], []);
// Check download status (storageUpdateCounter triggers re-evaluation when download completes) // Check download status (storageUpdateCounter triggers re-evaluation when download completes)
const isAlreadyDownloaded = useMemo( const isAlreadyDownloaded = useMemo(
() => isPermanentlyDownloaded(track?.Id), () => isPermanentlyDownloaded(track?.Id),
@@ -182,6 +186,13 @@ export const TrackOptionsSheet: React.FC<Props> = ({
setOpen(false); setOpen(false);
}, [track?.Id, api, user?.Id, isAlreadyDownloaded, setOpen]); }, [track?.Id, api, user?.Id, isAlreadyDownloaded, setOpen]);
const handleDelete = useCallback(async () => {
if (!track?.Id) return;
await deleteTrack(track.Id);
setStorageUpdateCounter((c) => c + 1);
setOpen(false);
}, [track?.Id, setOpen]);
const handleGoToArtist = useCallback(() => { const handleGoToArtist = useCallback(() => {
const artistId = track?.ArtistItems?.[0]?.Id; const artistId = track?.ArtistItems?.[0]?.Id;
if (artistId) { if (artistId) {
@@ -220,8 +231,7 @@ export const TrackOptionsSheet: React.FC<Props> = ({
return ( return (
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
index={0} enableDynamicSizing
snapPoints={snapPoints}
onChange={handleSheetChanges} onChange={handleSheetChanges}
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
handleIndicatorStyle={{ handleIndicatorStyle={{
@@ -391,6 +401,23 @@ export const TrackOptionsSheet: React.FC<Props> = ({
</View> </View>
</> </>
)} )}
{(isAlreadyDownloaded || isOnlyCached) && (
<>
<View style={styles.separator} />
<TouchableOpacity
onPress={handleDelete}
className='flex-row items-center px-4 py-3.5'
>
<Ionicons name='trash-outline' size={22} color='#ef4444' />
<Text className='text-red-500 ml-4 text-base'>
{isAlreadyDownloaded
? t("music.track_options.delete_download")
: t("music.track_options.delete_cache")}
</Text>
</TouchableOpacity>
</>
)}
</View> </View>
{/* Navigation Options */} {/* Navigation Options */}

View File

@@ -2,13 +2,14 @@ import type {
BaseItemDto, BaseItemDto,
BaseItemPerson, BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { router, useSegments } from "expo-router"; import { useSegments } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import type React from "react"; import type React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native"; import { TouchableOpacity, View, type ViewProps } from "react-native";
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values"; import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { HorizontalScroll } from "../common/HorizontalScroll"; import { HorizontalScroll } from "../common/HorizontalScroll";
@@ -24,6 +25,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const segments = useSegments(); const segments = useSegments();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
const from = (segments as string[])[2]; const from = (segments as string[])[2];
const destinctPeople = useMemo(() => { const destinctPeople = useMemo(() => {

View File

@@ -1,10 +1,10 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import type React from "react"; import type React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native"; import { TouchableOpacity, View, type ViewProps } from "react-native";
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values"; import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById"; import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { HorizontalScroll } from "../common/HorizontalScroll"; import { HorizontalScroll } from "../common/HorizontalScroll";
@@ -18,6 +18,7 @@ interface Props extends ViewProps {
export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => { export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
return ( return (
<View {...props}> <View {...props}>

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