Compare commits

...

58 Commits

Author SHA1 Message Date
Uruk
a16d73688f feat(home): refresh data on app foreground
adds a hook that refetches home data when the app
returns to the foreground or starts from a cold start.

this ensures the home screen always displays the
most up-to-date content.
2026-02-10 23:10:07 +01:00
Uruk
ac0d3fcb86 refactor(item): improves top people display logic
Improves the logic for displaying the top people in the item sections.

The previous implementation simply sliced the people array which
could result in duplicate people being displayed.

This change ensures that the top people displayed are unique by
filtering duplicates based on their id, limiting the number of
displayed people to three.
2026-02-10 19:13:44 +01:00
renovate[bot]
8a782fb0c9 chore(deps): Update dependency @react-native-community/cli to v20.1.1 (#1413)
Some checks failed
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 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
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:26:38 +01:00
renovate[bot]
7d0b6c37b8 chore(deps): Update dependency react-i18next to v16.5.4 (#1414)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:26:01 +01:00
renovate[bot]
5443222e4e chore(deps): Update actions/cache action to v5.0.2 (#1397)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:25:31 +01:00
renovate[bot]
a77e86be21 chore(deps): Update github/codeql-action action to v4.32.0 (#1410)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:25:22 +01:00
renovate[bot]
9d82549cd6 chore(deps): Update dependency @tanstack/react-query to v5.90.20 (#1405)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:25:12 +01:00
renovate[bot]
cb5cb8bff7 chore(deps): Update dependency lodash to v4.17.23 [SECURITY] (#1394)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:25:02 +01:00
renovate[bot]
0b61fb7d98 chore(deps): Update peter-evans/create-pull-request action to v8.1.0 (#1401)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:24:53 +01:00
renovate[bot]
f762b9017f chore(deps): Update crowdin/github-action action to v2.14.0 (#1400)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:24:43 +01:00
renovate[bot]
b063fd6405 chore(deps): Update oven-sh/setup-bun action to v2.1.2 (#1399)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:24:35 +01:00
renovate[bot]
88ac94ebeb chore(deps): Update actions/checkout action to v6.0.2 (#1398)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:24:24 +01:00
Fredrik Burmester
358e00d8b7 fix(player): resolve tvOS freeze on player exit by reordering mpv options
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
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-01-19 08:41:52 +01:00
lance chant
c7077bbcfe fix: subrip mpv (#1375)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (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
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-01-18 19:35:14 +01:00
Alex
c0f25a2b8b Add caching progress in seek slider bar (#1376) 2026-01-18 19:34:39 +01:00
renovate[bot]
36304ad58e chore(deps): Update dependency react-native-nitro-modules to v0.33.1 (#1340)
Some checks failed
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 16:48:51 +01:00
renovate[bot]
baeb83581e chore(deps): Update actions/setup-node action to v6.2.0 (#1372)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 15:22:19 +01:00
Fredrik Burmester
05b7a4c50d fix: downloads should work when connecting through QC 2026-01-15 07:54:08 +01:00
Fredrik Burmester
28b67f3ad6 fix(mpv): handle audio track selection for transcoded streams on iOS 2026-01-15 07:53:15 +01:00
Chris
51cd195bfe Reverting Jellyseer/Seer logo update
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Final logo not yet released, holding off until further updates
2026-01-14 20:23:39 +01:00
Chris
0184e266a0 Update Jellyseer logo to Seer logo
Updated logo in order to reflecting the new branding
2026-01-14 20:17:25 +01:00
Gauvain
ae658aa5b0 fix: remove Android emulator detection from MPV player (#1369) 2026-01-14 16:46:17 +01:00
Alex
81f79a54af fix(mpv): Add progress throttling for mpv (#1366)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-14 13:14:52 +01:00
renovate[bot]
ca1b640a61 chore(deps): Update dependency @babel/core to v7.28.6 (#1363)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 11:41:57 +01:00
renovate[bot]
e771949c95 chore(deps): Update dependency @tanstack/react-query to v5.90.17 (#1365)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 10:57:40 +01:00
renovate[bot]
78bfa68a17 chore(deps): Update dependency react-i18next to v16.5.3 (#1364)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 10:06:03 +01:00
renovate[bot]
ac59615d79 chore(deps): Update dependency jotai to v2.16.2 (#1329)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 09:17:32 +01:00
Fredrik Burmester
4dd80cd8f5 Merge branch 'develop' into fix/external-sub-selection
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-13 22:43:05 +01:00
renovate[bot]
db9f02b225 chore(deps): Update BRAINSia/free-disk-space action to v2.1.3 (#1358)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:42:18 +01:00
renovate[bot]
7a0bbb1084 chore(deps): Update actions/upload-artifact action to v6 (#1362)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:20:42 +01:00
renovate[bot]
05925530c0 chore(deps): Update actions/checkout action to v6 (#1361)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:20:28 +01:00
renovate[bot]
625a292e26 chore(deps): Update actions/cache action to v5 (#1360)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:20:15 +01:00
renovate[bot]
1acd3102ea chore(deps): Update oven-sh/setup-bun action to v2.1.0 (#1359)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:19:58 +01:00
renovate[bot]
543881dc41 chore(deps): Update BRAINSia/free-disk-space digest to 7ef2f7e (#1357)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:19:27 +01:00
Cristea Florian Victor
5d93483dc2 feat: xcode build script (#1296)
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2026-01-13 19:32:58 +01:00
Gauvain
d54a29020a fix(ci): code scanning alert no. 219 (#1353)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-01-13 18:41:56 +01:00
renovate[bot]
1d04e39b85 chore(deps): Update github/codeql-action action to v4.31.10 (#1352)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 15:05:27 +01:00
Alex Kim
ecc62259fc Re-add config version 2026-01-13 22:21:19 +11:00
Alex Kim
ffd96e05fe Merge branch 'fix/external-sub-selection' of github.com:streamyfin/streamyfin into fix/external-sub-selection 2026-01-13 22:19:58 +11:00
Alex Kim
8541ba02d4 Add android version for stopping auto selection of subtitles 2026-01-13 22:19:47 +11:00
Chris
6c955d8a2a Enhance README with Discord link and badge
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Added Discord link and badge to the README.
2026-01-12 22:06:18 +01:00
Fredrik Burmester
b0bb6c6c9a feat: add technical stream info overlay for MPV player 2026-01-12 21:55:32 +01:00
Fredrik Burmester
82abc291d4 Merge branch 'develop' into fix/external-sub-selection 2026-01-12 21:02:16 +01:00
renovate[bot]
3da4b42ca3 chore(deps): Update dependency @tanstack/react-query to v5.90.16 (#1328)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 15:16:38 +01:00
Alex Kim
16940075b2 Stop external subs from being selected when added 2026-01-12 22:50:36 +11:00
renovate[bot]
a3bbb1bc3a chore(deps): Update dependency @types/lodash to v4.17.23 (#1344)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 12:11:05 +01:00
renovate[bot]
1874c116a6 chore(deps): Update dependency react-i18next to v16.5.2 (#1345)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 11:05:17 +01:00
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
147 changed files with 6188 additions and 1348 deletions

View File

@@ -24,4 +24,4 @@ This file is auto-imported into CLAUDE.md and loaded at the start of each sessio
- **Mark as played flow**: The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`. _(2026-01-10)_ - **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)_ - **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)_

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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
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@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
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@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
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') }}
@@ -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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
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@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
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@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
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') }}
@@ -163,7 +187,7 @@ jobs:
steps: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
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
@@ -221,6 +242,63 @@ jobs:
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🏁 Initialize CodeQL - name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
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@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
- name: 🧪 Perform CodeQL Analysis - name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0

View File

@@ -23,12 +23,12 @@ jobs:
steps: steps:
- name: 📥 Checkout Repository - name: 📥 Checkout Repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 0 fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin - name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0 uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.0
with: with:
upload_sources: true upload_sources: true
upload_translations: true upload_translations: true

View File

@@ -51,7 +51,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
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

@@ -18,10 +18,10 @@ jobs:
steps: steps:
- name: 📥 Checkout repository - name: 📥 Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- 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@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.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

5
.gitignore vendored
View File

@@ -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

@@ -77,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.**
--- ---

View File

@@ -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,6 +29,9 @@
"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"
@@ -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"

View File

@@ -1,9 +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, View } from "react-native"; import { Platform, View } from "react-native";
import { Pressable } from "react-native-gesture-handler"; 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");
@@ -57,25 +58,6 @@ export default function IndexLayout() {
), ),
}} }}
/> />
<Stack.Screen
name='downloads/[seriesId]'
options={{
headerShown: true,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
title: t("home.downloads.tvseries"),
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen <Stack.Screen
name='sessions/index' name='sessions/index'
options={{ options={{
@@ -329,6 +311,24 @@ export default function IndexLayout() {
), ),
}} }}
/> />
<Stack.Screen
name='settings/network/page'
options={{
title: t("home.settings.network.title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} /> <Stack.Screen key={name} name={name} options={options} />
))} ))}

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,5 +1,5 @@
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";
@@ -13,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";
@@ -161,145 +163,99 @@ export default function page() {
); );
return ( return (
<ScrollView <OfflineModeProvider isOffline={true}>
showsVerticalScrollIndicator={false} <ScrollView
contentInsetAdjustmentBehavior='automatic' showsVerticalScrollIndicator={false}
> contentInsetAdjustmentBehavior='automatic'
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}> >
<View className='mb-4 flex flex-col space-y-4 px-4'> <View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
{/* Queue card - hidden */} <View className='mb-4 flex flex-col space-y-4 px-4'>
{/* <View className='bg-neutral-900 p-4 rounded-2xl'> <ActiveDownloads />
</View>
{movies.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'> <Text className='text-lg font-bold'>
{t("home.downloads.queue")} {t("home.downloads.movies")}
</Text> </Text>
<Text className='text-xs opacity-70 text-red-600'> <View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
{t("home.downloads.queue_hint")} <Text className='text-xs font-bold'>{movies?.length}</Text>
</Text> </View>
<View className='flex flex-col space-y-2 mt-2'> </View>
{queue.map((q, index) => ( <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<TouchableOpacity <View className='px-4 flex flex-row'>
onPress={() => {movies?.map((item) => (
router.push(`/(auth)/items/page?id=${q.item.Id}`) <TouchableItemRouter item={item.item} key={item.item.Id}>
} <MovieCard item={item.item} />
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between' </TouchableItemRouter>
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> </View>
</ScrollView>
{queue.length === 0 && (
<Text className='opacity-50'>
{t("home.downloads.no_items_in_queue")}
</Text>
)}
</View> */}
<ActiveDownloads />
</View>
{movies.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.movies")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{movies?.length}</Text>
</View>
</View> </View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}> )}
<View className='px-4 flex flex-row'> {groupedBySeries.length > 0 && (
{movies?.map((item) => ( <View className='mb-4'>
<TouchableItemRouter <View className='flex flex-row items-center justify-between mb-2 px-4'>
item={item.item} <Text className='text-lg font-bold'>
isOffline {t("home.downloads.tvseries")}
key={item.item.Id}
>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
</View>
</ScrollView>
</View>
)}
{groupedBySeries.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.tvseries")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{groupedBySeries?.length}
</Text> </Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{groupedBySeries?.length}
</Text>
</View>
</View> </View>
</View> <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
<View className='mb-2 last:mb-0' key={items[0].item.SeriesId}> className='mb-2 last:mb-0'
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId} key={items[0].item.SeriesId}
/> >
</View> <SeriesCard
))} items={items.map((i) => i.item)}
</View> key={items[0].item.SeriesId}
</ScrollView> />
</View> </View>
)} ))}
</View>
{otherMedia.length > 0 && ( </ScrollView>
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.other_media")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{otherMedia?.length}</Text>
</View>
</View> </View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}> )}
<View className='px-4 flex flex-row'>
{otherMedia?.map((item) => ( {otherMedia.length > 0 && (
<TouchableItemRouter <View className='mb-4'>
item={item.item} <View className='flex flex-row items-center justify-between mb-2 px-4'>
isOffline <Text className='text-lg font-bold'>
key={item.item.Id} {t("home.downloads.other_media")}
> </Text>
<MovieCard item={item.item} /> <View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
</TouchableItemRouter> <Text className='text-xs font-bold'>
))} {otherMedia?.length}
</Text>
</View>
</View> </View>
</ScrollView> <ScrollView horizontal showsHorizontalScrollIndicator={false}>
</View> <View className='px-4 flex flex-row'>
)} {otherMedia?.map((item) => (
{downloadedFiles?.length === 0 && ( <TouchableItemRouter item={item.item} key={item.item.Id}>
<View className='flex px-4'> <MovieCard item={item.item} />
<Text className='opacity-50'> </TouchableItemRouter>
{t("home.downloads.no_downloaded_items")} ))}
</Text> </View>
</View> </ScrollView>
)} </View>
</View> )}
</ScrollView> {downloadedFiles?.length === 0 && (
<View className='flex px-4'>
<Text className='opacity-50'>
{t("home.downloads.no_downloaded_items")}
</Text>
</View>
)}
</View>
</ScrollView>
</OfflineModeProvider>
); );
} }

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

@@ -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

@@ -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,39 +76,35 @@ const Page: React.FC = () => {
); );
return ( return (
<View className='flex flex-1 relative'> <OfflineModeProvider isOffline={isOffline}>
<Animated.View <View className='flex flex-1 relative'>
pointerEvents={"none"} <Animated.View
style={[animatedStyle]} pointerEvents={"none"}
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black' style={[animatedStyle]}
> className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black'
<View >
style={{ <View
height: item?.Type === "Episode" ? 300 : 450, style={{
}} height: item?.Type === "Episode" ? 300 : 450,
className='bg-transparent rounded-lg mb-4 w-full' }}
/> className='bg-transparent rounded-lg mb-4 w-full'
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' /> />
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' /> <View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' /> <View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
<View className='flex flex-row space-x-1 mb-8'> <View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' /> <View className='flex flex-row space-x-1 mb-8'>
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' /> <View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' /> <View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
</View> <View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' /> </View>
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' /> <View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' /> <View className='h-10 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-12 bg-neutral-900 rounded-lg w-full mb-2' />
</Animated.View> <View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
{item && ( </Animated.View>
<ItemContent {item && <ItemContent item={item} itemWithSources={itemWithSources} />}
item={item} </View>
isOffline={isOffline} </OfflineModeProvider>
itemWithSources={itemWithSources}
/>
)}
</View>
); );
}; };

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

@@ -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) {
api, return storage.getString(seriesId);
item, }
quality: 90, return null;
width: 1000, }, [isOffline, seriesId]);
}),
[item],
);
const logoUrl = useMemo( const backdropUrl = useMemo(() => {
() => if (isOffline && base64Image) {
getLogoImageUrlById({ return `data:image/jpeg;base64,${base64Image}`;
api, }
item, return getBackdropUrl({
}), api,
[item], item,
); quality: 90,
width: 1000,
});
}, [isOffline, base64Image, api, item]);
const logoUrl = useMemo(() => {
if (isOffline) {
return null; // No logo in offline mode
}
return getLogoImageUrlById({
api,
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,49 +152,64 @@ 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 (
<ParallaxScrollView <OfflineModeProvider isOffline={isOffline}>
headerHeight={400} <ParallaxScrollView
headerImage={ headerHeight={400}
<Image headerImage={
source={{ backdropUrl ? (
uri: backdropUrl, <Image
}} source={{
style={{ uri: backdropUrl,
width: "100%", }}
height: "100%", style={{
}} width: "100%",
/> height: "100%",
} }}
logo={ />
logoUrl ? ( ) : (
<Image <View
source={{ style={{
uri: logoUrl, width: "100%",
}} height: "100%",
style={{ backgroundColor: "#1a1a1a",
height: 130, }}
width: "100%", />
}} )
contentFit='contain' }
/> logo={
) : undefined logoUrl ? (
} <Image
> source={{
<View className='flex flex-col pt-4'> uri: logoUrl,
<SeriesHeader item={item} /> }}
<View className='mb-4'> style={{
<NextUp seriesId={seriesId} /> height: 130,
width: "100%",
}}
contentFit='contain'
/>
) : undefined
}
>
<View className='flex flex-col pt-4'>
<SeriesHeader item={item} />
{!isOffline && (
<View className='mb-4'>
<NextUp seriesId={seriesId} />
</View>
)}
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
</View> </View>
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} /> </ParallaxScrollView>
</View> </OfflineModeProvider>
</ParallaxScrollView>
); );
}; };

View File

@@ -209,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

@@ -7,7 +7,7 @@ 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 { Image } from "expo-image"; import { Image } from "expo-image";
import { router, useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { import {
useCallback, useCallback,
@@ -36,6 +36,7 @@ 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";
@@ -57,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);

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,9 +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 } from "react-native"; import { Platform } from "react-native";
import { Pressable } from "react-native-gesture-handler"; 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() {

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

@@ -6,7 +6,6 @@ import type {
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, { import React, {
useCallback, useCallback,
@@ -38,6 +37,7 @@ import { Text } from "@/components/common/Text";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal"; import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet"; import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet"; import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
import useRouter from "@/hooks/useAppRouter";
import { useFavorite } from "@/hooks/useFavorite"; import { useFavorite } from "@/hooks/useFavorite";
import { useMusicCast } from "@/hooks/useMusicCast"; import { useMusicCast } from "@/hooks/useMusicCast";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";

View File

@@ -10,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, useWindowDimensions, 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";
@@ -27,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";
@@ -44,6 +44,9 @@ import {
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 { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { import {
@@ -60,6 +63,7 @@ export default function page() {
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();
const { width: screenWidth, height: screenHeight } = useWindowDimensions(); const { width: screenWidth, height: screenHeight } = useWindowDimensions();
@@ -78,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);
@@ -87,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();
@@ -109,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;
@@ -445,7 +449,7 @@ export default function page() {
async (data: { nativeEvent: MpvOnProgressEventPayload }) => { async (data: { nativeEvent: MpvOnProgressEventPayload }) => {
if (isSeeking.get() || isPlaybackStopped) return; if (isSeeking.get() || isPlaybackStopped) return;
const { position } = data.nativeEvent; const { position, cacheSeconds } = data.nativeEvent;
// MPV reports position in seconds, convert to ms // MPV reports position in seconds, convert to ms
const currentTime = position * 1000; const currentTime = position * 1000;
@@ -455,6 +459,12 @@ export default function page() {
progress.set(currentTime); progress.set(currentTime);
// Update cache progress (current position + buffered seconds ahead)
if (cacheSeconds !== undefined && cacheSeconds > 0) {
const cacheEnd = currentTime + cacheSeconds * 1000;
cacheProgress.set(cacheEnd);
}
// Update URL immediately after seeking, or every 30 seconds during normal playback // Update URL immediately after seeking, or every 30 seconds during normal playback
const now = Date.now(); const now = Date.now();
const shouldUpdateUrl = wasJustSeeking.get(); const shouldUpdateUrl = wasJustSeeking.get();
@@ -527,7 +537,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
@@ -677,8 +691,8 @@ export default function page() {
return; return;
} }
if (isLoading) { if (isLoading !== undefined) {
setIsBuffering(true); setIsBuffering(isLoading);
} }
}, },
[playbackManager, item?.Id, progress], [playbackManager, item?.Id, progress],
@@ -723,6 +737,59 @@ export default function page() {
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";
}
// 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 () => {
const newZoomState = !isZoomedToFill; const newZoomState = !isZoomedToFill;
await videoRef.current?.setZoomedToFill?.(newZoomState); await videoRef.current?.setZoomedToFill?.(newZoomState);
@@ -833,99 +900,104 @@ export default function page() {
); );
return ( return (
<PlayerProvider <OfflineModeProvider isOffline={offline}>
playerRef={videoRef} <PlayerProvider
item={item} playerRef={videoRef}
mediaSource={stream?.mediaSource} item={item}
isVideoLoaded={isVideoLoaded} mediaSource={stream?.mediaSource}
tracksReady={tracksReady} isVideoLoaded={isVideoLoaded}
offline={offline} tracksReady={tracksReady}
downloadedItem={downloadedItem} downloadedItem={downloadedItem}
> >
<VideoProvider> <VideoProvider>
<View
style={{
flex: 1,
backgroundColor: "black",
height: "100%",
width: "100%",
}}
>
<View <View
style={{ style={{
display: "flex", flex: 1,
width: "100%", backgroundColor: "black",
height: "100%", height: "100%",
position: "relative", width: "100%",
flexDirection: "column",
justifyContent: "center",
}} }}
> >
<MpvPlayerView <View
ref={videoRef} style={{
source={videoSource} display: "flex",
style={{ width: "100%", height: "100%" }} width: "100%",
onProgress={onProgress} height: "100%",
onPlaybackStateChange={onPlaybackStateChanged} position: "relative",
onLoad={() => setIsVideoLoaded(true)} flexDirection: "column",
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => { justifyContent: "center",
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);
}} }}
onTracksReady={() => { >
setTracksReady(true); <MpvPlayerView
}} ref={videoRef}
/> source={videoSource}
{!hasPlaybackStarted && ( style={{ width: "100%", height: "100%" }}
<View onProgress={onProgress}
style={{ onPlaybackStateChange={onPlaybackStateChanged}
position: "absolute", onLoad={() => setIsVideoLoaded(true)}
top: 0, onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
left: 0, console.error("Video Error:", e.nativeEvent);
right: 0, Alert.alert(
bottom: 0, t("player.error"),
backgroundColor: "black", t("player.an_error_occured_while_playing_the_video"),
justifyContent: "center", );
alignItems: "center", writeToLog("ERROR", "Video Error", e.nativeEvent);
}} }}
> onTracksReady={() => {
<Loader /> setTracksReady(true);
</View> }}
/>
{!hasPlaybackStarted && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "black",
justifyContent: "center",
alignItems: "center",
}}
>
<Loader />
</View>
)}
</View>
{isMounted === true && item && !isPipMode && (
<Controls
mediaSource={stream?.mediaSource}
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
startPictureInPicture={startPictureInPicture}
play={play}
pause={pause}
seek={seek}
enableTrickplay={true}
aspectRatio={aspectRatio}
isZoomedToFill={isZoomedToFill}
onZoomToggle={handleZoomToggle}
api={api}
downloadedFiles={downloadedFiles}
playbackSpeed={currentPlaybackSpeed}
setPlaybackSpeed={handleSetPlaybackSpeed}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={handleToggleTechnicalInfo}
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
/>
)} )}
</View> </View>
{isMounted === true && item && !isPipMode && ( </VideoProvider>
<Controls </PlayerProvider>
mediaSource={stream?.mediaSource} </OfflineModeProvider>
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
startPictureInPicture={startPictureInPicture}
play={play}
pause={pause}
seek={seek}
enableTrickplay={true}
offline={offline}
aspectRatio={aspectRatio}
isZoomedToFill={isZoomedToFill}
onZoomToggle={handleZoomToggle}
api={api}
downloadedFiles={downloadedFiles}
playbackSpeed={currentPlaybackSpeed}
setPlaybackSpeed={handleSetPlaybackSpeed}
/>
)}
</View>
</VideoProvider>
</PlayerProvider>
); );
} }

View File

@@ -22,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 {
@@ -47,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";
@@ -56,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";
@@ -80,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;
@@ -98,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) {
@@ -230,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(
@@ -322,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);
@@ -384,77 +382,79 @@ function Layout() {
}} }}
> >
<JellyfinProvider> <JellyfinProvider>
<NetworkStatusProvider> <ServerUrlProvider>
<PlaySettingsProvider> <NetworkStatusProvider>
<LogProvider> <PlaySettingsProvider>
<WebSocketProvider> <LogProvider>
<DownloadProvider> <WebSocketProvider>
<MusicPlayerProvider> <DownloadProvider>
<GlobalModalProvider> <MusicPlayerProvider>
<BottomSheetModalProvider> <GlobalModalProvider>
<IntroSheetProvider> <BottomSheetModalProvider>
<ThemeProvider value={DarkTheme}> <IntroSheetProvider>
<SystemBars style='light' hidden={false} /> <ThemeProvider value={DarkTheme}>
<Stack initialRouteName='(auth)/(tabs)'> <SystemBars style='light' hidden={false} />
<Stack.Screen <Stack initialRouteName='(auth)/(tabs)'>
name='(auth)/(tabs)' <Stack.Screen
options={{ name='(auth)/(tabs)'
headerShown: false, options={{
title: "", headerShown: false,
header: () => null, title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}} }}
closeButton
/> />
<Stack.Screen <GlobalModal />
name='(auth)/player' </ThemeProvider>
options={{ </IntroSheetProvider>
headerShown: false, </BottomSheetModalProvider>
title: "", </GlobalModalProvider>
header: () => null, </MusicPlayerProvider>
}} </DownloadProvider>
/> </WebSocketProvider>
<Stack.Screen </LogProvider>
name='(auth)/now-playing' </PlaySettingsProvider>
options={{ </NetworkStatusProvider>
headerShown: false, </ServerUrlProvider>
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
<GlobalModal />
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</NetworkStatusProvider>
</JellyfinProvider> </JellyfinProvider>
</PersistQueryClientProvider> </PersistQueryClientProvider>
); );

672
bun.lock

File diff suppressed because it is too large Load Diff

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

@@ -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),
); );

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 }) => {

View File

@@ -6,13 +6,13 @@ import {
BottomSheetScrollView, BottomSheetScrollView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { router } from "expo-router";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Linking, Platform, TouchableOpacity, View } from "react-native"; import { Linking, Platform, 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 { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
export interface IntroSheetRef { export interface IntroSheetRef {
@@ -24,6 +24,7 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
const bottomSheetRef = useRef<BottomSheetModal>(null); const bottomSheetRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter();
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
present: () => { present: () => {

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

@@ -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,7 +2,6 @@ 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";
@@ -24,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";
@@ -44,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;
} }
@@ -54,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();
@@ -300,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

@@ -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,8 +1,8 @@
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 { Platform } from "react-native";
import { Pressable, type PressableProps } from "react-native-gesture-handler"; import { Pressable, type PressableProps } from "react-native-gesture-handler";
import useRouter from "@/hooks/useAppRouter";
interface Props extends BlurViewProps { interface Props extends BlurViewProps {
background?: "blur" | "transparent"; background?: "blur" | "transparent";

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) => {
@@ -134,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") {
@@ -163,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 (
@@ -179,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) {
@@ -195,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);
} }
}, },
); );
@@ -203,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,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 { t } from "i18next"; import { t } from "i18next";
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
@@ -11,6 +10,7 @@ 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 { 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";

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";
@@ -33,7 +33,9 @@ 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 { useRefetchHomeOnForeground } from "@/hooks/useRefetchHomeOnForeground";
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 { useIntroSheet } from "@/providers/IntroSheetProvider";
@@ -106,6 +108,9 @@ export const Home = () => {
prevIsConnected.current = isConnected; prevIsConnected.current = isConnected;
}, [isConnected, invalidateCache]); }, [isConnected, invalidateCache]);
// Refresh home data on mount (cold start) and when app returns to foreground
useRefetchHomeOnForeground();
const hasDownloads = useMemo(() => { const hasDownloads = useMemo(() => {
if (Platform.isTV) return false; if (Platform.isTV) return false;
return downloadedItems.length > 0; return downloadedItems.length > 0;

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,7 +35,9 @@ 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 { useRefetchHomeOnForeground } from "@/hooks/useRefetchHomeOnForeground";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -66,8 +68,7 @@ export const HomeWithCarousel = () => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [_loading, setLoading] = useState(false); const { settings } = useSettings();
const { settings, refreshStreamyfinPluginSettings } = useSettings();
const headerOverlayOffset = Platform.isTV ? 0 : 60; const headerOverlayOffset = Platform.isTV ? 0 : 60;
const navigation = useNavigation(); const navigation = useNavigation();
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>(); const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
@@ -90,6 +91,9 @@ export const HomeWithCarousel = () => {
prevIsConnected.current = isConnected; prevIsConnected.current = isConnected;
}, [isConnected, invalidateCache]); }, [isConnected, invalidateCache]);
// Refresh home data on mount (cold start) and when app returns to foreground
useRefetchHomeOnForeground();
const hasDownloads = useMemo(() => { const hasDownloads = useMemo(() => {
if (Platform.isTV) return false; if (Platform.isTV) return false;
return downloadedItems.length > 0; return downloadedItems.length > 0;
@@ -177,13 +181,6 @@ export const HomeWithCarousel = () => {
); );
}, [userViews]); }, [userViews]);
const _refetch = async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
};
const createCollectionConfig = useCallback( const createCollectionConfig = useCallback(
( (
title: string, title: string,

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

@@ -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(() => {
@@ -40,7 +37,18 @@ export const ItemPeopleSections: React.FC<Props> = ({
return { ...item, People: people } as BaseItemDto; return { ...item, People: people } as BaseItemDto;
}, [item, people]); }, [item, people]);
const topPeople = useMemo(() => people.slice(0, 3), [people]); const topPeople = useMemo(() => {
const seen = new Set<string>();
const unique: BaseItemPerson[] = [];
for (const person of people) {
if (person.Id && !seen.has(person.Id)) {
seen.add(person.Id);
unique.push(person);
}
if (unique.length >= 3) break;
}
return unique;
}, [people]);
const renderActorSection = useCallback( const renderActorSection = useCallback(
(person: BaseItemPerson, idx: number, total: number) => { (person: BaseItemPerson, idx: number, total: number) => {

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

@@ -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,6 +22,7 @@ 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";

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";

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";

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";

View File

@@ -3,11 +3,11 @@ 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 } 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 { getLocalPath } from "@/providers/AudioStorage"; import { getLocalPath } from "@/providers/AudioStorage";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";

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

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

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}>

View File

@@ -1,7 +1,7 @@
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 { 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 useRouter from "@/hooks/useAppRouter";
interface Props extends ViewProps { interface Props extends ViewProps {
item: BaseItemDto; item: BaseItemDto;

View File

@@ -2,9 +2,9 @@ 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 { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo } from "react"; import { useMemo } from "react";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Button } from "../Button"; import { Button } from "../Button";

View File

@@ -54,29 +54,28 @@ export const SeasonDropdown: React.FC<Props> = ({
[state, item, keys], [state, item, keys],
); );
// Always use IndexNumber for Season objects (not keys.index which is for the item)
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
Number(a[keys.index]) - Number(b[keys.index]); Number(a.IndexNumber) - Number(b.IndexNumber);
const optionGroups = useMemo( const optionGroups = useMemo(
() => [ () => [
{ {
options: options:
seasons?.sort(sortByIndex).map((season: any) => { seasons?.sort(sortByIndex).map((season: any) => {
const title = const title = season.Name || `Season ${season.IndexNumber}`;
season[keys.title] ||
season.Name ||
`Season ${season.IndexNumber}`;
return { return {
type: "radio" as const, type: "radio" as const,
label: title, label: title,
value: season.Id || season.IndexNumber, value: season.Id || season.IndexNumber,
selected: Number(season[keys.index]) === Number(seasonIndex), // Compare season's IndexNumber with the selected seasonIndex
selected: Number(season.IndexNumber) === Number(seasonIndex),
onPress: () => onSelect(season), onPress: () => onSelect(season),
}; };
}) || [], }) || [],
}, },
], ],
[seasons, keys, seasonIndex, onSelect], [seasons, seasonIndex, onSelect],
); );
useEffect(() => { useEffect(() => {

View File

@@ -1,12 +1,14 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, type ViewStyle } from "react-native"; import { TouchableOpacity, type ViewStyle } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { getDownloadedEpisodesBySeasonId } from "@/utils/downloads/offline-series";
import ContinueWatchingPoster from "../ContinueWatchingPoster"; import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { import {
HorizontalScroll, HorizontalScroll,
@@ -17,7 +19,6 @@ import { ItemCardText } from "../ItemCardText";
interface Props { interface Props {
item?: BaseItemDto | null; item?: BaseItemDto | null;
loading?: boolean; loading?: boolean;
isOffline?: boolean;
style?: ViewStyle; style?: ViewStyle;
containerStyle?: ViewStyle; containerStyle?: ViewStyle;
} }
@@ -25,17 +26,14 @@ interface Props {
export const SeasonEpisodesCarousel: React.FC<Props> = ({ export const SeasonEpisodesCarousel: React.FC<Props> = ({
item, item,
loading, loading,
isOffline,
style, style,
containerStyle, containerStyle,
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const isOffline = useOfflineMode();
const router = useRouter();
const { getDownloadedItems } = useDownload(); const { getDownloadedItems } = useDownload();
const downloadedFiles = useMemo(
() => getDownloadedItems(),
[getDownloadedItems],
);
const scrollRef = useRef<HorizontalScrollRef>(null); const scrollRef = useRef<HorizontalScrollRef>(null);
@@ -51,11 +49,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
queryKey: ["episodes", seasonId, isOffline], queryKey: ["episodes", seasonId, isOffline],
queryFn: async () => { queryFn: async () => {
if (isOffline) { if (isOffline) {
return downloadedFiles return getDownloadedEpisodesBySeasonId(getDownloadedItems(), seasonId!);
?.filter(
(f) => f.item.Type === "Episode" && f.item.SeasonId === seasonId,
)
.map((f) => f.item);
} }
if (!api || !user?.Id || !item?.SeriesId) return []; if (!api || !user?.Id || !item?.SeriesId) return [];
const response = await getTvShowsApi(api).getEpisodes({ const response = await getTvShowsApi(api).getEpisodes({
@@ -73,7 +67,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
}); });
return response.data.Items as BaseItemDto[]; return response.data.Items as BaseItemDto[];
}, },
enabled: !!api && !!user?.Id && !!seasonId, enabled: !!seasonId && (isOffline || (!!api && !!user?.Id)),
}); });
useEffect(() => { useEffect(() => {

View File

@@ -10,7 +10,13 @@ import {
SeasonDropdown, SeasonDropdown,
type SeasonIndexState, type SeasonIndexState,
} from "@/components/series/SeasonDropdown"; } from "@/components/series/SeasonDropdown";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import {
buildOfflineSeasons,
getDownloadedEpisodesForSeason,
} from "@/utils/downloads/offline-series";
import { runtimeTicksToSeconds } from "@/utils/time"; import { runtimeTicksToSeconds } from "@/utils/time";
import ContinueWatchingPoster from "../ContinueWatchingPoster"; import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
@@ -31,6 +37,8 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom); const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const isOffline = useOfflineMode();
const { getDownloadedItems, downloadedItems } = useDownload();
const seasonIndex = useMemo( const seasonIndex = useMemo(
() => seasonIndexState[item.Id ?? ""], () => seasonIndexState[item.Id ?? ""],
@@ -38,8 +46,12 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
); );
const { data: seasons } = useQuery({ const { data: seasons } = useQuery({
queryKey: ["seasons", item.Id], queryKey: ["seasons", item.Id, isOffline, downloadedItems.length],
queryFn: async () => { queryFn: async () => {
if (isOffline) {
return buildOfflineSeasons(getDownloadedItems(), item.Id!);
}
if (!api || !user?.Id || !item.Id) return []; if (!api || !user?.Id || !item.Id) return [];
const response = await api.axiosInstance.get( const response = await api.axiosInstance.get(
`${api.basePath}/Shows/${item.Id}/Seasons`, `${api.basePath}/Shows/${item.Id}/Seasons`,
@@ -58,8 +70,8 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
return response.data.Items; return response.data.Items;
}, },
staleTime: 60, staleTime: isOffline ? Infinity : 60,
enabled: !!api && !!user?.Id && !!item.Id, enabled: isOffline || (!!api && !!user?.Id && !!item.Id),
}); });
const selectedSeasonId: string | null = useMemo(() => { const selectedSeasonId: string | null = useMemo(() => {
@@ -73,9 +85,33 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
return season.Id!; return season.Id!;
}, [seasons, seasonIndex]); }, [seasons, seasonIndex]);
// For offline mode, we use season index number instead of ID
const selectedSeasonNumber = useMemo(() => {
if (!isOffline) return null;
const season = seasons?.find(
(s: BaseItemDto) =>
s.IndexNumber === seasonIndex || s.Name === seasonIndex,
);
return season?.IndexNumber ?? null;
}, [isOffline, seasons, seasonIndex]);
const { data: episodes, isPending } = useQuery({ const { data: episodes, isPending } = useQuery({
queryKey: ["episodes", item.Id, selectedSeasonId], queryKey: [
"episodes",
item.Id,
isOffline ? selectedSeasonNumber : selectedSeasonId,
isOffline,
downloadedItems.length,
],
queryFn: async () => { queryFn: async () => {
if (isOffline) {
return getDownloadedEpisodesForSeason(
getDownloadedItems(),
item.Id!,
selectedSeasonNumber!,
);
}
if (!api || !user?.Id || !item.Id || !selectedSeasonId) { if (!api || !user?.Id || !item.Id || !selectedSeasonId) {
return []; return [];
} }
@@ -85,7 +121,6 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
userId: user.Id, userId: user.Id,
seasonId: selectedSeasonId, seasonId: selectedSeasonId,
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"],
}); });
@@ -97,7 +132,10 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
return res.data.Items; return res.data.Items;
}, },
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId, staleTime: isOffline ? Infinity : 0,
enabled: isOffline
? !!item.Id && selectedSeasonNumber !== null
: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
}); });
// Used for height calculation // Used for height calculation
@@ -127,7 +165,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
})); }));
}} }}
/> />
{episodes?.length ? ( {episodes?.length && !isOffline ? (
<View className='flex flex-row items-center space-x-2'> <View className='flex flex-row items-center space-x-2'>
<DownloadItems <DownloadItems
title={t("item_card.download.download_season")} title={t("item_card.download.download_season")}
@@ -180,9 +218,11 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
{runtimeTicksToSeconds(e.RunTimeTicks)} {runtimeTicksToSeconds(e.RunTimeTicks)}
</Text> </Text>
</View> </View>
<View className='self-start ml-auto -mt-0.5'> {!isOffline && (
<DownloadSingleItem item={e} /> <View className='self-start ml-auto -mt-0.5'>
</View> <DownloadSingleItem item={e} />
</View>
)}
</View> </View>
<Text <Text

View File

@@ -1,9 +1,9 @@
import { useRouter } from "expo-router";
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 { Linking, Switch } from "react-native"; import { Linking, Switch } from "react-native";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
import useRouter from "@/hooks/useAppRouter";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";

View File

@@ -1,6 +1,6 @@
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions"; import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";

View File

@@ -0,0 +1,224 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Switch, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native";
import { useWifiSSID } from "@/hooks/useWifiSSID";
import { useServerUrl } from "@/providers/ServerUrlProvider";
import { storage } from "@/utils/mmkv";
import {
getServerLocalConfig,
type LocalNetworkConfig,
updateServerLocalConfig,
} from "@/utils/secureCredentials";
import { Button } from "../Button";
import { Input } from "../common/Input";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
const DEFAULT_CONFIG: LocalNetworkConfig = {
localUrl: "",
homeWifiSSIDs: [],
enabled: false,
};
interface StatusDisplayProps {
currentSSID: string | null;
isUsingLocalUrl: boolean;
t: (key: string) => string;
}
function StatusDisplay({
currentSSID,
isUsingLocalUrl,
t,
}: StatusDisplayProps): React.ReactElement {
const wifiStatus = currentSSID ?? t("home.settings.network.not_connected");
const urlType = isUsingLocalUrl
? t("home.settings.network.local")
: t("home.settings.network.remote");
const urlTypeColor = isUsingLocalUrl ? "text-green-500" : "text-blue-500";
return (
<View className='px-4 py-2 bg-neutral-900 rounded-xl mt-4'>
<View className='flex-row justify-between items-center py-1'>
<Text className='text-neutral-400'>
{t("home.settings.network.current_wifi")}
</Text>
<Text>{wifiStatus}</Text>
</View>
<View className='flex-row justify-between items-center py-1'>
<Text className='text-neutral-400'>
{t("home.settings.network.using_url")}
</Text>
<Text className={urlTypeColor}>{urlType}</Text>
</View>
</View>
);
}
export function LocalNetworkSettings(): React.ReactElement | null {
const { t } = useTranslation();
const { permissionStatus, requestPermission } = useWifiSSID();
const { isUsingLocalUrl, currentSSID, refreshUrlState } = useServerUrl();
const remoteUrl = storage.getString("serverUrl");
const [config, setConfig] = useState<LocalNetworkConfig>(DEFAULT_CONFIG);
useEffect(() => {
if (remoteUrl) {
const existingConfig = getServerLocalConfig(remoteUrl);
if (existingConfig) {
setConfig(existingConfig);
}
}
}, [remoteUrl]);
const saveConfig = useCallback(
(newConfig: LocalNetworkConfig) => {
if (!remoteUrl) return;
setConfig(newConfig);
updateServerLocalConfig(remoteUrl, newConfig);
// Trigger URL re-evaluation after config change
refreshUrlState();
},
[remoteUrl, refreshUrlState],
);
const handleToggleEnabled = useCallback(
async (enabled: boolean) => {
if (enabled && permissionStatus !== "granted") {
const granted = await requestPermission();
if (!granted) {
toast.error(t("home.settings.network.permission_denied"));
return;
}
}
saveConfig({ ...config, enabled });
},
[config, permissionStatus, requestPermission, saveConfig, t],
);
const handleLocalUrlChange = useCallback(
(localUrl: string) => {
saveConfig({ ...config, localUrl });
},
[config, saveConfig],
);
const handleAddCurrentNetwork = useCallback(() => {
if (!currentSSID) {
toast.error(t("home.settings.network.no_wifi_connected"));
return;
}
if (config.homeWifiSSIDs.includes(currentSSID)) {
toast.info(t("home.settings.network.network_already_added"));
return;
}
saveConfig({
...config,
homeWifiSSIDs: [...config.homeWifiSSIDs, currentSSID],
});
toast.success(t("home.settings.network.network_added"));
}, [config, currentSSID, saveConfig, t]);
const handleRemoveNetwork = useCallback(
(ssidToRemove: string) => {
saveConfig({
...config,
homeWifiSSIDs: config.homeWifiSSIDs.filter((s) => s !== ssidToRemove),
});
},
[config, saveConfig],
);
if (!remoteUrl) return null;
const addNetworkButtonText = currentSSID
? t("home.settings.network.add_current_network", { ssid: currentSSID })
: t("home.settings.network.not_connected_to_wifi");
return (
<View>
<ListGroup title={t("home.settings.network.local_network")}>
<ListItem
title={t("home.settings.network.auto_switch_enabled")}
subtitle={t("home.settings.network.auto_switch_description")}
>
<Switch value={config.enabled} onValueChange={handleToggleEnabled} />
</ListItem>
</ListGroup>
{config.enabled && (
<View className='pt-4'>
<ListGroup
title={t("home.settings.network.local_url")}
description={
<Text className='text-[#8E8D91] text-xs'>
{t("home.settings.network.local_url_hint")}
</Text>
}
>
<View className=''>
<Input
placeholder={t("home.settings.network.local_url_placeholder")}
value={config.localUrl}
onChangeText={handleLocalUrlChange}
keyboardType='url'
autoCapitalize='none'
autoCorrect={false}
/>
</View>
</ListGroup>
<ListGroup
title={t("home.settings.network.home_wifi_networks")}
className='mt-4'
>
{config.homeWifiSSIDs.map((wifiSSID) => (
<ListItem key={wifiSSID} title={wifiSSID}>
<TouchableOpacity
onPress={() => handleRemoveNetwork(wifiSSID)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name='close-circle' size={22} color='#EF4444' />
</TouchableOpacity>
</ListItem>
))}
{config.homeWifiSSIDs.length === 0 && (
<ListItem
title={t("home.settings.network.no_networks_configured")}
subtitle={t("home.settings.network.add_network_hint")}
/>
)}
</ListGroup>
<View className='py-2'>
<Button
onPress={handleAddCurrentNetwork}
disabled={!currentSSID || permissionStatus !== "granted"}
>
{addNetworkButtonText}
</Button>
</View>
<StatusDisplay
currentSSID={currentSSID}
isUsingLocalUrl={isUsingLocalUrl}
t={t}
/>
</View>
)}
{permissionStatus === "denied" && (
<View className='py-2'>
<Text className='text-xs text-red-500'>
{t("home.settings.network.permission_denied_explanation")}
</Text>
</View>
)}
</View>
);
}

View File

@@ -1,5 +1,4 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { TFunction } from "i18next"; import { TFunction } from "i18next";
import type React from "react"; import type React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
@@ -8,6 +7,7 @@ import { Linking, Switch, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector"; import { BITRATES } from "@/components/BitrateSelector";
import { PlatformDropdown } from "@/components/PlatformDropdown"; import { PlatformDropdown } from "@/components/PlatformDropdown";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
import useRouter from "@/hooks/useAppRouter";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings"; import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text"; import { Text } from "../common/Text";

View File

@@ -29,6 +29,7 @@ export const PlaybackControlsSettings: React.FC = () => {
const orientations = [ const orientations = [
ScreenOrientation.OrientationLock.DEFAULT, ScreenOrientation.OrientationLock.DEFAULT,
ScreenOrientation.OrientationLock.PORTRAIT_UP, ScreenOrientation.OrientationLock.PORTRAIT_UP,
ScreenOrientation.OrientationLock.LANDSCAPE,
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT, ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT, ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
]; ];
@@ -39,6 +40,8 @@ export const PlaybackControlsSettings: React.FC = () => {
"home.settings.other.orientations.DEFAULT", "home.settings.other.orientations.DEFAULT",
[ScreenOrientation.OrientationLock.PORTRAIT_UP]: [ScreenOrientation.OrientationLock.PORTRAIT_UP]:
"home.settings.other.orientations.PORTRAIT_UP", "home.settings.other.orientations.PORTRAIT_UP",
[ScreenOrientation.OrientationLock.LANDSCAPE]:
"home.settings.other.orientations.LANDSCAPE",
[ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]: [ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]:
"home.settings.other.orientations.LANDSCAPE_LEFT", "home.settings.other.orientations.LANDSCAPE_LEFT",
[ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]: [ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]:

View File

@@ -1,5 +1,5 @@
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useRouter from "@/hooks/useAppRouter";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";

View File

@@ -1,9 +1,9 @@
import { useRouter } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import React from "react"; import React from "react";
import { View } from "react-native"; import { View } from "react-native";
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 { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export interface ContinueWatchingOverlayProps { export interface ContinueWatchingOverlayProps {
@@ -23,7 +23,7 @@ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
settings.maxAutoPlayEpisodeCount.value ? ( settings.maxAutoPlayEpisodeCount.value ? (
<View <View
className={ className={
"absolute top-0 bottom-0 left-0 right-0 flex flex-col px-4 items-center justify-center bg-[#000000B3]" "absolute top-0 bottom-0 left-0 right-0 z-50 flex flex-col px-4 items-center justify-center bg-[#000000B3]"
} }
> >
<Text className='text-2xl font-bold text-white py-4 '> <Text className='text-2xl font-bold text-white py-4 '>

View File

@@ -3,7 +3,7 @@ import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { type FC, useCallback, useEffect, useState } from "react"; import { type FC, useCallback, useEffect, useState } from "react";
import { StyleSheet, useWindowDimensions, View } from "react-native"; import { StyleSheet, useWindowDimensions, View } from "react-native";
import Animated, { import Animated, {
@@ -15,12 +15,15 @@ import Animated, {
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay"; import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
import useRouter from "@/hooks/useAppRouter";
import { useCreditSkipper } from "@/hooks/useCreditSkipper"; import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper"; import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay"; import { useTrickplay } from "@/hooks/useTrickplay";
import type { TechnicalInfo } from "@/modules/mpv-player";
import { DownloadedItem } from "@/providers/Downloads/types"; import { DownloadedItem } from "@/providers/Downloads/types";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { ticksToMs } from "@/utils/time"; import { ticksToMs } from "@/utils/time";
@@ -34,6 +37,7 @@ import { useRemoteControl } from "./hooks/useRemoteControl";
import { useVideoNavigation } from "./hooks/useVideoNavigation"; import { useVideoNavigation } from "./hooks/useVideoNavigation";
import { useVideoSlider } from "./hooks/useVideoSlider"; import { useVideoSlider } from "./hooks/useVideoSlider";
import { useVideoTime } from "./hooks/useVideoTime"; import { useVideoTime } from "./hooks/useVideoTime";
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
import { useControlsTimeout } from "./useControlsTimeout"; import { useControlsTimeout } from "./useControlsTimeout";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings"; import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
import { type AspectRatio } from "./VideoScalingModeSelector"; import { type AspectRatio } from "./VideoScalingModeSelector";
@@ -49,7 +53,6 @@ interface Props {
enableTrickplay?: boolean; enableTrickplay?: boolean;
togglePlay: () => void; togglePlay: () => void;
setShowControls: (shown: boolean) => void; setShowControls: (shown: boolean) => void;
offline?: boolean;
mediaSource?: MediaSourceInfo | null; mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void; seek: (ticks: number) => void;
startPictureInPicture?: () => Promise<void>; startPictureInPicture?: () => Promise<void>;
@@ -63,6 +66,12 @@ interface Props {
// Playback speed props // Playback speed props
playbackSpeed?: number; playbackSpeed?: number;
setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void; setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
// Technical info props
showTechnicalInfo?: boolean;
onToggleTechnicalInfo?: () => void;
getTechnicalInfo?: () => Promise<TechnicalInfo>;
playMethod?: "DirectPlay" | "DirectStream" | "Transcode";
transcodeReasons?: string[];
} }
export const Controls: FC<Props> = ({ export const Controls: FC<Props> = ({
@@ -83,12 +92,17 @@ export const Controls: FC<Props> = ({
aspectRatio = "default", aspectRatio = "default",
isZoomedToFill = false, isZoomedToFill = false,
onZoomToggle, onZoomToggle,
offline = false,
api = null, api = null,
downloadedFiles = undefined, downloadedFiles = undefined,
playbackSpeed = 1.0, playbackSpeed = 1.0,
setPlaybackSpeed, setPlaybackSpeed,
showTechnicalInfo = false,
onToggleTechnicalInfo,
getTechnicalInfo,
playMethod,
transcodeReasons,
}) => { }) => {
const offline = useOfflineMode();
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const router = useRouter(); const router = useRouter();
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
@@ -110,7 +124,9 @@ export const Controls: FC<Props> = ({
} = useTrickplay(item); } = useTrickplay(item);
const min = useSharedValue(0); const min = useSharedValue(0);
const max = useSharedValue(ticksToMs(item.RunTimeTicks || 0)); // Regular value for use during render (avoids Reanimated warning)
const maxMs = ticksToMs(item.RunTimeTicks || 0);
const max = useSharedValue(maxMs);
// Animation values for controls // Animation values for controls
const controlsOpacity = useSharedValue(showControls ? 1 : 0); const controlsOpacity = useSharedValue(showControls ? 1 : 0);
@@ -303,7 +319,7 @@ export const Controls: FC<Props> = ({
offline, offline,
api, api,
downloadedFiles, downloadedFiles,
max.value, maxMs,
); );
const goToItemCommon = useCallback( const goToItemCommon = useCallback(
@@ -457,6 +473,16 @@ export const Controls: FC<Props> = ({
onSkipForward={handleSkipForward} onSkipForward={handleSkipForward}
onSkipBackward={handleSkipBackward} onSkipBackward={handleSkipBackward}
/> />
{/* Technical Info Overlay - rendered outside animated views to stay visible */}
{getTechnicalInfo && (
<TechnicalInfoOverlay
showControls={showControls}
visible={showTechnicalInfo}
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
/>
)}
<Animated.View <Animated.View
style={headerAnimatedStyle} style={headerAnimatedStyle}
pointerEvents={showControls ? "auto" : "none"} pointerEvents={showControls ? "auto" : "none"}
@@ -477,6 +503,8 @@ export const Controls: FC<Props> = ({
onZoomToggle={onZoomToggle} onZoomToggle={onZoomToggle}
playbackSpeed={playbackSpeed} playbackSpeed={playbackSpeed}
setPlaybackSpeed={setPlaybackSpeed} setPlaybackSpeed={setPlaybackSpeed}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={onToggleTechnicalInfo}
/> />
</Animated.View> </Animated.View>
<Animated.View <Animated.View

View File

@@ -2,11 +2,10 @@ 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 { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useGlobalSearchParams } from "expo-router";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { import {
HorizontalScroll, HorizontalScroll,
@@ -19,10 +18,16 @@ import {
type SeasonIndexState, type SeasonIndexState,
} from "@/components/series/SeasonDropdown"; } from "@/components/series/SeasonDropdown";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import type { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
getDownloadedEpisodesForSeason,
getDownloadedSeasonNumbers,
} from "@/utils/downloads/offline-series";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { runtimeTicksToSeconds } from "@/utils/time"; import { runtimeTicksToSeconds } from "@/utils/time";
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
type Props = { type Props = {
item: BaseItemDto; item: BaseItemDto;
@@ -40,10 +45,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
const scrollToIndex = (index: number) => { const scrollToIndex = (index: number) => {
scrollViewRef.current?.scrollToIndex(index, 100); scrollViewRef.current?.scrollToIndex(index, 100);
}; };
const { offline } = useGlobalSearchParams<{ const isOffline = useOfflineMode();
offline: string; const { settings } = useSettings();
}>(); const insets = useSafeAreaInsets();
const isOffline = offline === "true";
// Set the initial season index // Set the initial season index
useEffect(() => { useEffect(() => {
@@ -56,10 +60,6 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
}, []); }, []);
const { getDownloadedItems } = useDownload(); const { getDownloadedItems } = useDownload();
const downloadedFiles = useMemo(
() => getDownloadedItems(),
[getDownloadedItems],
);
const seasonIndex = seasonIndexState[item.ParentId ?? ""]; const seasonIndex = seasonIndexState[item.ParentId ?? ""];
@@ -68,15 +68,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
queryFn: async () => { queryFn: async () => {
if (isOffline) { if (isOffline) {
if (!item.SeriesId) return []; if (!item.SeriesId) return [];
const seriesEpisodes = downloadedFiles?.filter( const seasonNumbers = getDownloadedSeasonNumbers(
(f: DownloadedItem) => f.item.SeriesId === item.SeriesId, getDownloadedItems(),
); item.SeriesId,
const seasonNumbers = Array.from(
new Set(
seriesEpisodes
?.map((f: DownloadedItem) => f.item.ParentIndexNumber)
.filter(Boolean),
),
); );
// Create fake season objects // Create fake season objects
return seasonNumbers.map((seasonNumber) => ({ return seasonNumbers.map((seasonNumber) => ({
@@ -117,14 +111,12 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
queryKey: ["episodes", item.SeriesId, selectedSeasonId], queryKey: ["episodes", item.SeriesId, selectedSeasonId],
queryFn: async () => { queryFn: async () => {
if (isOffline) { if (isOffline) {
if (!item.SeriesId) return []; if (!item.SeriesId || typeof seasonIndex !== "number") return [];
return downloadedFiles return getDownloadedEpisodesForSeason(
?.filter( getDownloadedItems(),
(f: DownloadedItem) => item.SeriesId,
f.item.SeriesId === item.SeriesId && seasonIndex,
f.item.ParentIndexNumber === seasonIndex, );
)
.map((f: DownloadedItem) => f.item);
} }
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return []; if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
const res = await getTvShowsApi(api).getEpisodes({ const res = await getTvShowsApi(api).getEpisodes({
@@ -153,6 +145,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
useEffect(() => { useEffect(() => {
// Don't prefetch when offline - data is already local
if (isOffline) return;
for (const e of episodes || []) { for (const e of episodes || []) {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["item", e.Id], queryKey: ["item", e.Id],
@@ -168,7 +163,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
staleTime: 60 * 5 * 1000, staleTime: 60 * 5 * 1000,
}); });
} }
}, [episodes]); }, [episodes, isOffline]);
// Scroll to the current item when episodes are fetched // Scroll to the current item when episodes are fetched
useEffect(() => { useEffect(() => {
@@ -181,15 +176,24 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
}, [episodes, item.Id]); }, [episodes, item.Id]);
return ( return (
<SafeAreaView <View
style={{ style={{
position: "absolute", position: "absolute",
backgroundColor: "black", backgroundColor: "black",
height: "100%", height: "100%",
width: "100%", width: "100%",
paddingTop:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
paddingLeft:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
paddingRight:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
}} }}
> >
<View className='flex-row items-center p-4 z-10'> <View
style={{ padding: HEADER_LAYOUT.CONTAINER_PADDING }}
className='flex-row items-center z-10'
>
{seasons && seasons.length > 0 && !episodesLoading && episodes && ( {seasons && seasons.length > 0 && !episodesLoading && episodes && (
<SeasonDropdown <SeasonDropdown
item={item} item={item}
@@ -207,9 +211,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
onPress={async () => { onPress={async () => {
close(); close();
}} }}
className='aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2 ml-auto' className='aspect-square flex flex-col rounded-xl items-center justify-center p-2 ml-auto'
> >
<Ionicons name='close' size={24} color='white' /> <Ionicons name='close' size={ICON_SIZES.HEADER} color='white' />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -274,6 +278,6 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
/> />
)} )}
</SafeAreaView> </View>
); );
}; };

View File

@@ -3,15 +3,15 @@ import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { type FC, useCallback, useState } from "react"; import { type FC, useCallback, useState } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
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 { OrientationLock } from "@/packages/expo-screen-orientation"; import { OrientationLock } from "@/packages/expo-screen-orientation";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { ICON_SIZES } from "./constants"; import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
import DropdownView from "./dropdown/DropdownView"; import DropdownView from "./dropdown/DropdownView";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings"; import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
import { type AspectRatio } from "./VideoScalingModeSelector"; import { type AspectRatio } from "./VideoScalingModeSelector";
@@ -34,6 +34,9 @@ interface HeaderControlsProps {
// Playback speed props // Playback speed props
playbackSpeed?: number; playbackSpeed?: number;
setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void; setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
// Technical info props
showTechnicalInfo?: boolean;
onToggleTechnicalInfo?: () => void;
} }
export const HeaderControls: FC<HeaderControlsProps> = ({ export const HeaderControls: FC<HeaderControlsProps> = ({
@@ -52,6 +55,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
onZoomToggle, onZoomToggle,
playbackSpeed = 1.0, playbackSpeed = 1.0,
setPlaybackSpeed, setPlaybackSpeed,
showTechnicalInfo = false,
onToggleTechnicalInfo,
}) => { }) => {
const { settings } = useSettings(); const { settings } = useSettings();
const router = useRouter(); const router = useRouter();
@@ -98,17 +103,20 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0, left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
right: right:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0, (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
padding: HEADER_LAYOUT.CONTAINER_PADDING,
}, },
]} ]}
pointerEvents={showControls ? "auto" : "none"} pointerEvents={showControls ? "auto" : "none"}
className='flex flex-row justify-between' className='flex flex-row justify-between'
> >
<View className='mr-auto p-2' pointerEvents='box-none'> <View className='mr-auto' pointerEvents='box-none'>
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && ( {!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
<View pointerEvents='auto'> <View pointerEvents='auto'>
<DropdownView <DropdownView
playbackSpeed={playbackSpeed} playbackSpeed={playbackSpeed}
setPlaybackSpeed={setPlaybackSpeed} setPlaybackSpeed={setPlaybackSpeed}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={onToggleTechnicalInfo}
/> />
</View> </View>
)} )}

View File

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

View File

@@ -15,3 +15,7 @@ export const ICON_SIZES = {
HEADER: 24, HEADER: 24,
CENTER: 50, CENTER: 50,
} as const; } as const;
export const HEADER_LAYOUT = {
CONTAINER_PADDING: 8, // p-2 = 8px (matches HeaderControls)
} as const;

View File

@@ -18,7 +18,6 @@ interface PlayerContextProps {
mediaSource: MediaSourceInfo | null | undefined; mediaSource: MediaSourceInfo | null | undefined;
isVideoLoaded: boolean; isVideoLoaded: boolean;
tracksReady: boolean; tracksReady: boolean;
offline: boolean;
downloadedItem: DownloadedItem | null; downloadedItem: DownloadedItem | null;
} }
@@ -31,7 +30,6 @@ interface PlayerProviderProps {
mediaSource: MediaSourceInfo | null | undefined; mediaSource: MediaSourceInfo | null | undefined;
isVideoLoaded: boolean; isVideoLoaded: boolean;
tracksReady: boolean; tracksReady: boolean;
offline?: boolean;
downloadedItem?: DownloadedItem | null; downloadedItem?: DownloadedItem | null;
} }
@@ -42,7 +40,6 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
mediaSource, mediaSource,
isVideoLoaded, isVideoLoaded,
tracksReady, tracksReady,
offline = false,
downloadedItem = null, downloadedItem = null,
}) => { }) => {
const value = useMemo( const value = useMemo(
@@ -52,18 +49,9 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
mediaSource, mediaSource,
isVideoLoaded, isVideoLoaded,
tracksReady, tracksReady,
offline,
downloadedItem, downloadedItem,
}), }),
[ [playerRef, item, mediaSource, isVideoLoaded, tracksReady, downloadedItem],
playerRef,
item,
mediaSource,
isVideoLoaded,
tracksReady,
offline,
downloadedItem,
],
); );
return ( return (

View File

@@ -47,7 +47,7 @@
*/ */
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client"; import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
import { router, useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import type React from "react"; import type React from "react";
import { import {
createContext, createContext,
@@ -57,7 +57,9 @@ import {
useMemo, useMemo,
useState, useState,
} from "react"; } from "react";
import useRouter from "@/hooks/useAppRouter";
import type { MpvAudioTrack } from "@/modules"; import type { MpvAudioTrack } from "@/modules";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils"; import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
import type { Track } from "../types"; import type { Track } from "../types";
import { usePlayerContext, usePlayerControls } from "./PlayerContext"; import { usePlayerContext, usePlayerControls } from "./PlayerContext";
@@ -75,9 +77,10 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null); const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null); const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
const { tracksReady, mediaSource, offline, downloadedItem } = const { tracksReady, mediaSource, downloadedItem } = usePlayerContext();
usePlayerContext();
const playerControls = usePlayerControls(); const playerControls = usePlayerControls();
const offline = useOfflineMode();
const router = useRouter();
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } = const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
useLocalSearchParams<{ useLocalSearchParams<{

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, useMemo, useRef } from "react"; import { useCallback, useMemo, useRef } from "react";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector"; import { BITRATES } from "@/components/BitrateSelector";
@@ -8,6 +8,8 @@ import {
PlatformDropdown, PlatformDropdown,
} from "@/components/PlatformDropdown"; } from "@/components/PlatformDropdown";
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector"; import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
import useRouter from "@/hooks/useAppRouter";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { usePlayerContext } from "../contexts/PlayerContext"; import { usePlayerContext } from "../contexts/PlayerContext";
import { useVideoContext } from "../contexts/VideoContext"; import { useVideoContext } from "../contexts/VideoContext";
@@ -28,18 +30,23 @@ const SUBTITLE_SIZE_PRESETS = [
interface DropdownViewProps { interface DropdownViewProps {
playbackSpeed?: number; playbackSpeed?: number;
setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void; setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
showTechnicalInfo?: boolean;
onToggleTechnicalInfo?: () => void;
} }
const DropdownView = ({ const DropdownView = ({
playbackSpeed = 1.0, playbackSpeed = 1.0,
setPlaybackSpeed, setPlaybackSpeed,
showTechnicalInfo = false,
onToggleTechnicalInfo,
}: DropdownViewProps) => { }: DropdownViewProps) => {
const { subtitleTracks, audioTracks } = useVideoContext(); const { subtitleTracks, audioTracks } = useVideoContext();
const { item, mediaSource } = usePlayerContext(); const { item, mediaSource } = usePlayerContext();
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const router = useRouter(); const router = useRouter();
const isOffline = useOfflineMode();
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } = const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
useLocalSearchParams<{ useLocalSearchParams<{
itemId: string; itemId: string;
audioIndex: string; audioIndex: string;
@@ -47,15 +54,12 @@ const DropdownView = ({
mediaSourceId: string; mediaSourceId: string;
bitrateValue: string; bitrateValue: string;
playbackPosition: string; playbackPosition: string;
offline: string;
}>(); }>();
// Use ref to track playbackPosition without causing re-renders // Use ref to track playbackPosition without causing re-renders
const playbackPositionRef = useRef(playbackPosition); const playbackPositionRef = useRef(playbackPosition);
playbackPositionRef.current = playbackPosition; playbackPositionRef.current = playbackPosition;
const isOffline = offline === "true";
// Stabilize IDs to prevent unnecessary recalculations // Stabilize IDs to prevent unnecessary recalculations
const itemIdRef = useRef(item.Id); const itemIdRef = useRef(item.Id);
const mediaSourceIdRef = useRef(mediaSource?.Id); const mediaSourceIdRef = useRef(mediaSource?.Id);
@@ -161,6 +165,21 @@ const DropdownView = ({
}); });
} }
// Technical Info (at bottom)
if (onToggleTechnicalInfo) {
groups.push({
options: [
{
type: "action" as const,
label: showTechnicalInfo
? "Hide Technical Info"
: "Show Technical Info",
onPress: onToggleTechnicalInfo,
},
],
});
}
return groups; return groups;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
@@ -175,6 +194,8 @@ const DropdownView = ({
updateSettings, updateSettings,
playbackSpeed, playbackSpeed,
setPlaybackSpeed, setPlaybackSpeed,
showTechnicalInfo,
onToggleTechnicalInfo,
// Note: subtitleTracks and audioTracks are intentionally excluded // Note: subtitleTracks and audioTracks are intentionally excluded
// because we use subtitleTracksKey and audioTracksKey for stability // because we use subtitleTracksKey and audioTracksKey for stability
]); ]);

View File

@@ -6,7 +6,6 @@ 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, { import React, {
forwardRef, forwardRef,
useCallback, useCallback,
@@ -23,6 +22,7 @@ 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 { import {
useAddToWatchlist, useAddToWatchlist,
useRemoveFromWatchlist, useRemoveFromWatchlist,

86
hooks/useAppRouter.ts Normal file
View File

@@ -0,0 +1,86 @@
import { useRouter } from "expo-router";
import { useCallback, useMemo } from "react";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
/**
* Drop-in replacement for expo-router's useRouter that automatically
* preserves offline state across navigation.
*
* - For object-form navigation, automatically adds offline=true when in offline context
* - For string URLs, passes through unchanged (caller handles offline param)
*
* @example
* import useRouter from "@/hooks/useAppRouter";
*
* const router = useRouter();
* router.push({ pathname: "/items/page", params: { id: item.Id } }); // offline added automatically
*/
export function useAppRouter() {
const router = useRouter();
const isOffline = useOfflineMode();
const push = useCallback(
(href: Parameters<typeof router.push>[0]) => {
if (typeof href === "string") {
router.push(href as any);
} else {
const callerParams = (href.params ?? {}) as Record<string, unknown>;
const hasExplicitOffline = "offline" in callerParams;
router.push({
...href,
params: {
// Only add offline if caller hasn't explicitly set it
...(isOffline && !hasExplicitOffline && { offline: "true" }),
...callerParams,
},
} as any);
}
},
[router, isOffline],
);
const replace = useCallback(
(href: Parameters<typeof router.replace>[0]) => {
if (typeof href === "string") {
router.replace(href as any);
} else {
const callerParams = (href.params ?? {}) as Record<string, unknown>;
const hasExplicitOffline = "offline" in callerParams;
router.replace({
...href,
params: {
// Only add offline if caller hasn't explicitly set it
...(isOffline && !hasExplicitOffline && { offline: "true" }),
...callerParams,
},
} as any);
}
},
[router, isOffline],
);
const setParams = useCallback(
(params: Parameters<typeof router.setParams>[0]) => {
const callerParams = (params ?? {}) as Record<string, unknown>;
const hasExplicitOffline = "offline" in callerParams;
router.setParams({
// Only add offline if caller hasn't explicitly set it
...(isOffline && !hasExplicitOffline && { offline: "true" }),
...callerParams,
});
},
[router, isOffline],
);
return useMemo(
() => ({
...router,
push,
replace,
setParams,
}),
[router, push, replace, setParams],
);
}
export default useAppRouter;

View File

@@ -1,6 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useCallback } from "react"; import { useCallback } from "react";
import useRouter from "@/hooks/useAppRouter";
import { usePlaySettings } from "@/providers/PlaySettingsProvider"; import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";

View File

@@ -36,12 +36,26 @@ export function useNetworkAwareQueryClient(): NetworkAwareQueryClient {
); );
return useMemo(() => { return useMemo(() => {
// Create a proxy-like object that inherits from queryClient // Use a Proxy to wrap the queryClient and override invalidateQueries.
// but overrides invalidateQueries // Object.create doesn't work because QueryClient uses private fields (#)
const wrapped = Object.create(queryClient) as NetworkAwareQueryClient; // which can only be accessed on the exact instance they were defined on.
wrapped.invalidateQueries = networkAwareInvalidate; const forceInvalidate = queryClient.invalidateQueries.bind(queryClient);
wrapped.forceInvalidateQueries =
queryClient.invalidateQueries.bind(queryClient); return new Proxy(queryClient, {
return wrapped; get(target, prop) {
if (prop === "invalidateQueries") {
return networkAwareInvalidate;
}
if (prop === "forceInvalidateQueries") {
return forceInvalidate;
}
const value = Reflect.get(target, prop, target);
// Bind methods to the original target to preserve private field access
if (typeof value === "function") {
return value.bind(target);
}
return value;
},
}) as NetworkAwareQueryClient;
}, [queryClient, networkAwareInvalidate]); }, [queryClient, networkAwareInvalidate]);
} }

View File

@@ -21,6 +21,8 @@ const orientationToOrientationLock = (
return OrientationLock.LANDSCAPE_RIGHT; return OrientationLock.LANDSCAPE_RIGHT;
case Orientation.PORTRAIT_UP: case Orientation.PORTRAIT_UP:
return OrientationLock.PORTRAIT_UP; return OrientationLock.PORTRAIT_UP;
case Orientation.UNKNOWN:
return OrientationLock.LANDSCAPE;
default: default:
return OrientationLock.PORTRAIT_UP; return OrientationLock.PORTRAIT_UP;
} }

View File

@@ -3,7 +3,7 @@ import type {
PlaybackProgressInfo, PlaybackProgressInfo,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { getPlaystateApi, getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { getPlaystateApi, getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useMemo } from "react"; import { useMemo } from "react";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
@@ -69,6 +69,7 @@ export const usePlaybackManager = ({
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const { isConnected } = useNetworkStatus(); const { isConnected } = useNetworkStatus();
const queryClient = useQueryClient();
const { getDownloadedItemById, updateDownloadedItem, getDownloadedItems } = const { getDownloadedItemById, updateDownloadedItem, getDownloadedItems } =
useDownload(); useDownload();
@@ -186,6 +187,9 @@ export const usePlaybackManager = ({
}, },
}, },
}); });
// Force invalidate queries so they refetch from updated local database
queryClient.invalidateQueries({ queryKey: ["item", itemId] });
queryClient.invalidateQueries({ queryKey: ["episodes"] });
} }
// Handle remote state update if online // Handle remote state update if online
@@ -226,6 +230,9 @@ export const usePlaybackManager = ({
}, },
}, },
}); });
// Force invalidate queries so they refetch from updated local database
queryClient.invalidateQueries({ queryKey: ["item", itemId] });
queryClient.invalidateQueries({ queryKey: ["episodes"] });
} }
// Handle remote state update if online // Handle remote state update if online
@@ -268,6 +275,9 @@ export const usePlaybackManager = ({
}, },
}, },
}); });
// Force invalidate queries so they refetch from updated local database
queryClient.invalidateQueries({ queryKey: ["item", itemId] });
queryClient.invalidateQueries({ queryKey: ["episodes"] });
} }
// Handle remote state update if online // Handle remote state update if online

View File

@@ -0,0 +1,32 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { AppState } from "react-native";
/**
* Refetches active home queries on mount (cold start) and whenever
* the app returns to the foreground from background.
*/
export function useRefetchHomeOnForeground() {
const queryClient = useQueryClient();
useEffect(() => {
// On mount (cold start), use cancelRefetch: false so we don't cancel
// an in-flight initial fetch that React Query already started.
queryClient.refetchQueries(
{ queryKey: ["home"], type: "active" },
{ cancelRefetch: false },
);
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") {
// On foreground return, force a fresh refetch
queryClient.refetchQueries(
{ queryKey: ["home"], type: "active" },
{ cancelRefetch: true },
);
}
});
return () => subscription.remove();
}, [queryClient]);
}

View File

@@ -1,7 +1,7 @@
import { useRouter } from "expo-router";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert } from "react-native"; import { Alert } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useWebSocketContext } from "@/providers/WebSocketProvider"; import { useWebSocketContext } from "@/providers/WebSocketProvider";
interface UseWebSocketProps { interface UseWebSocketProps {

97
hooks/useWifiSSID.ts Normal file
View File

@@ -0,0 +1,97 @@
import * as Location from "expo-location";
import { useCallback, useEffect, useState } from "react";
import { getSSID } from "@/modules/wifi-ssid";
export type PermissionStatus =
| "granted"
| "denied"
| "undetermined"
| "unavailable";
export interface UseWifiSSIDReturn {
ssid: string | null;
permissionStatus: PermissionStatus;
requestPermission: () => Promise<boolean>;
isLoading: boolean;
}
function mapLocationStatus(
status: Location.PermissionStatus,
): PermissionStatus {
switch (status) {
case Location.PermissionStatus.GRANTED:
return "granted";
case Location.PermissionStatus.DENIED:
return "denied";
default:
return "undetermined";
}
}
export function useWifiSSID(): UseWifiSSIDReturn {
const [ssid, setSSID] = useState<string | null>(null);
const [permissionStatus, setPermissionStatus] =
useState<PermissionStatus>("undetermined");
const [isLoading, setIsLoading] = useState(true);
const fetchSSID = useCallback(async () => {
const result = await getSSID();
console.log("[WiFi Debug] Native module SSID:", result);
setSSID(result);
}, []);
const requestPermission = useCallback(async (): Promise<boolean> => {
try {
const { status } = await Location.requestForegroundPermissionsAsync();
const newStatus = mapLocationStatus(status);
setPermissionStatus(newStatus);
if (newStatus === "granted") {
await fetchSSID();
}
return newStatus === "granted";
} catch {
setPermissionStatus("unavailable");
return false;
}
}, [fetchSSID]);
useEffect(() => {
async function initialize() {
setIsLoading(true);
try {
const { status } = await Location.getForegroundPermissionsAsync();
const mappedStatus = mapLocationStatus(status);
setPermissionStatus(mappedStatus);
if (mappedStatus === "granted") {
await fetchSSID();
}
} catch {
setPermissionStatus("unavailable");
}
setIsLoading(false);
}
initialize();
}, [fetchSSID]);
// Refresh SSID when permission status changes to granted
useEffect(() => {
if (permissionStatus === "granted") {
fetchSSID();
// Also set up an interval to periodically check SSID
const interval = setInterval(fetchSSID, 10000); // Check every 10 seconds
return () => clearInterval(interval);
}
}, [permissionStatus, fetchSSID]);
return {
ssid,
permissionStatus,
requestPermission,
isLoading,
};
}

Binary file not shown.

View File

@@ -1,10 +1,13 @@
package expo.modules.mpvplayer package expo.modules.mpvplayer
import android.content.Context import android.content.Context
import android.content.res.AssetManager
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.view.Surface import android.view.Surface
import java.io.File
import java.io.FileOutputStream
/** /**
* MPV renderer that wraps libmpv for video playback. * MPV renderer that wraps libmpv for video playback.
@@ -26,7 +29,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
} }
interface Delegate { interface Delegate {
fun onPositionChanged(position: Double, duration: Double) fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double)
fun onPauseChanged(isPaused: Boolean) fun onPauseChanged(isPaused: Boolean)
fun onLoadingChanged(isLoading: Boolean) fun onLoadingChanged(isLoading: Boolean)
fun onReadyToSeek() fun onReadyToSeek()
@@ -46,10 +49,28 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
// Cached state // Cached state
private var cachedPosition: Double = 0.0 private var cachedPosition: Double = 0.0
private var cachedDuration: Double = 0.0 private var cachedDuration: Double = 0.0
private var cachedCacheSeconds: Double = 0.0
private var _isPaused: Boolean = true private var _isPaused: Boolean = true
private var _isLoading: Boolean = false private var _isLoading: Boolean = false
private var _playbackSpeed: Double = 1.0 private var _playbackSpeed: Double = 1.0
private var isReadyToSeek: Boolean = false private var isReadyToSeek: Boolean = false
// Progress update throttling - CRITICAL for performance!
// DO NOT REMOVE THIS THROTTLE - it is essential for battery life and CPU efficiency.
//
// Without throttling, time-pos fires every video frame (24+ times/sec at 24fps).
// Each update crosses the React Native JS bridge, which is expensive on mobile.
// Even if the JS side does nothing, 24+ bridge calls/sec wastes CPU and battery.
//
// Throttling to 1 update/sec during normal playback is sufficient for:
// - Progress bar updates (users can't perceive 1-second granularity)
// - Playback position tracking
// - Any JS-side logic that needs current position
//
// During seeking, we bypass the throttle for responsive scrubbing.
// This optimization reduced CPU usage by ~50% for downloaded file playback.
private var lastProgressUpdateTime: Long = 0
private var _isSeeking: Boolean = false
// Video dimensions // Video dimensions
private var _videoWidth: Int = 0 private var _videoWidth: Int = 0
@@ -84,6 +105,52 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.create(context) MPVLib.create(context)
MPVLib.addObserver(this) MPVLib.addObserver(this)
/**
* Create mpv config directory and copy font files to ensure SubRip subtitles load properly on Android.
*
* Technical Background:
* ====================
* On Android, mpv requires access to a font file to render text-based subtitles, particularly SubRip (.srt)
* format subtitles. Without an available font in the config directory, mpv will fail to display subtitles
* even when subtitle tracks are properly detected and loaded.
*
* Why This Is Necessary:
* =====================
* 1. Android's font system is isolated from native libraries like mpv. While Android has system fonts,
* mpv cannot access them directly due to sandboxing and library isolation.
*
* 2. SubRip subtitles require a font to render text overlay on video. When no font is available in the
* configured directory, mpv either:
* - Fails silently (subtitles don't appear)
* - Falls back to a default font that may not support the required character set
* - Crashes or produces rendering errors
*
* 3. By placing a font file (font.ttf) in mpv's config directory and setting that directory via
* MPVLib.setOptionString("config-dir", ...), we ensure mpv has a known, accessible font source.
*
* Reference:
* =========
* This workaround is documented in the mpv-android project:
* https://github.com/mpv-android/mpv-android/issues/96
*
* The issue discusses that without a font in the config directory, SubRip subtitles fail to load
* properly on Android, and the solution is to copy a font file to a known location that mpv can access.
*/
// Create mpv config directory and copy font files
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
//Log.i(TAG, "mpv config dir: $mpvDir")
if (!mpvDir.exists()) mpvDir.mkdirs()
// This needs to be named `subfont.ttf` else it won't work
arrayOf("subfont.ttf").forEach { fileName ->
val file = File(mpvDir, fileName)
if (file.exists()) return@forEach
context.assets
.open(fileName, AssetManager.ACCESS_STREAMING)
.copyTo(FileOutputStream(file))
}
MPVLib.setOptionString("config", "yes")
MPVLib.setOptionString("config-dir", mpvDir.path)
// Configure mpv options before initialization (based on Findroid) // Configure mpv options before initialization (based on Findroid)
MPVLib.setOptionString("vo", "gpu") MPVLib.setOptionString("vo", "gpu")
MPVLib.setOptionString("gpu-context", "android") MPVLib.setOptionString("gpu-context", "android")
@@ -107,7 +174,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.setOptionString("hr-seek-framedrop", "yes") MPVLib.setOptionString("hr-seek-framedrop", "yes")
// Subtitle settings // Subtitle settings
MPVLib.setOptionString("sub-scale-with-window", "yes") MPVLib.setOptionString("sub-scale-with-window", "no")
MPVLib.setOptionString("sub-use-margins", "no") MPVLib.setOptionString("sub-use-margins", "no")
MPVLib.setOptionString("subs-match-os-language", "yes") MPVLib.setOptionString("subs-match-os-language", "yes")
MPVLib.setOptionString("subs-fallback", "yes") MPVLib.setOptionString("subs-fallback", "yes")
@@ -266,6 +333,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG) MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64) MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64)
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG) MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
MPVLib.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
// Video dimensions for PiP aspect ratio // Video dimensions for PiP aspect ratio
MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64) MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64)
MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64) MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64)
@@ -430,6 +498,57 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.setPropertyDouble("panscan", panscanValue) MPVLib.setPropertyDouble("panscan", panscanValue)
} }
// MARK: - Technical Info
fun getTechnicalInfo(): Map<String, Any> {
val info = mutableMapOf<String, Any>()
// Video dimensions
MPVLib.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
info["videoWidth"] = it
}
MPVLib.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
info["videoHeight"] = it
}
// Video codec
MPVLib.getPropertyString("video-format")?.let {
info["videoCodec"] = it
}
// Audio codec
MPVLib.getPropertyString("audio-codec-name")?.let {
info["audioCodec"] = it
}
// FPS (container fps)
MPVLib.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
info["fps"] = it
}
// Video bitrate (bits per second)
MPVLib.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
info["videoBitrate"] = it
}
// Audio bitrate (bits per second)
MPVLib.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
info["audioBitrate"] = it
}
// Demuxer cache duration (seconds of video buffered)
MPVLib.getPropertyDouble("demuxer-cache-duration")?.let {
info["cacheSeconds"] = it
}
// Dropped frames
MPVLib.getPropertyInt("frame-drop-count")?.let {
info["droppedFrames"] = it
}
return info
}
// MARK: - MPVLib.EventObserver // MARK: - MPVLib.EventObserver
override fun eventProperty(property: String) { override fun eventProperty(property: String) {
@@ -493,11 +612,20 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
when (property) { when (property) {
"duration" -> { "duration" -> {
cachedDuration = value cachedDuration = value
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) } mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration, cachedCacheSeconds) }
} }
"time-pos" -> { "time-pos" -> {
cachedPosition = value cachedPosition = value
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) } // Always update immediately when seeking, otherwise throttle to once per second
val now = System.currentTimeMillis()
val shouldUpdate = _isSeeking || (now - lastProgressUpdateTime >= 1000)
if (shouldUpdate) {
lastProgressUpdateTime = now
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration, cachedCacheSeconds) }
}
}
"demuxer-cache-duration" -> {
cachedCacheSeconds = value
} }
} }
} }
@@ -507,8 +635,10 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.MPV_EVENT_FILE_LOADED -> { MPVLib.MPV_EVENT_FILE_LOADED -> {
// Add external subtitles now that file is loaded // Add external subtitles now that file is loaded
if (pendingExternalSubtitles.isNotEmpty()) { if (pendingExternalSubtitles.isNotEmpty()) {
for (subUrl in pendingExternalSubtitles) { pendingExternalSubtitles.forEachIndexed { index, subUrl ->
MPVLib.command(arrayOf("sub-add", subUrl)) android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl")
// "auto" flag = add without auto-selecting (order preserved, MPVLib.command is sync)
MPVLib.command(arrayOf("sub-add", subUrl, "auto"))
} }
pendingExternalSubtitles = emptyList() pendingExternalSubtitles = emptyList()
@@ -527,7 +657,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
} }
} }
MPVLib.MPV_EVENT_SEEK -> { MPVLib.MPV_EVENT_SEEK -> {
// Seek started - show loading indicator // Seek started - show loading indicator and enable immediate progress updates
_isSeeking = true
if (!_isLoading) { if (!_isLoading) {
_isLoading = true _isLoading = true
mainHandler.post { delegate?.onLoadingChanged(true) } mainHandler.post { delegate?.onLoadingChanged(true) }
@@ -535,6 +666,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
} }
MPVLib.MPV_EVENT_PLAYBACK_RESTART -> { MPVLib.MPV_EVENT_PLAYBACK_RESTART -> {
// Video playback has started/restarted (including after seek) // Video playback has started/restarted (including after seek)
_isSeeking = false
if (_isLoading) { if (_isLoading) {
_isLoading = false _isLoading = false
mainHandler.post { delegate?.onLoadingChanged(false) } mainHandler.post { delegate?.onLoadingChanged(false) }

View File

@@ -173,6 +173,11 @@ class MpvPlayerModule : Module() {
view.isZoomedToFill() view.isZoomedToFill()
} }
// Technical info function
AsyncFunction("getTechnicalInfo") { view: MpvPlayerView ->
view.getTechnicalInfo()
}
// Defines events that the view can send to JavaScript // Defines events that the view can send to JavaScript
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady") Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
} }

View File

@@ -33,23 +33,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
companion object { companion object {
private const val TAG = "MpvPlayerView" private const val TAG = "MpvPlayerView"
/**
* Detect if running on an Android emulator.
* MPV player has EGL/OpenGL compatibility issues on emulators.
*/
private fun isEmulator(): Boolean {
return (Build.FINGERPRINT.startsWith("generic")
|| Build.FINGERPRINT.startsWith("unknown")
|| Build.MODEL.contains("google_sdk")
|| Build.MODEL.contains("Emulator")
|| Build.MODEL.contains("Android SDK built for x86")
|| Build.MANUFACTURER.contains("Genymotion")
|| (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic"))
|| "google_sdk" == Build.PRODUCT
|| Build.HARDWARE.contains("goldfish")
|| Build.HARDWARE.contains("ranchu"))
}
} }
// Event dispatchers // Event dispatchers
@@ -104,21 +87,14 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
} }
// Start the renderer (skip on emulators to avoid EGL crashes) // Start the renderer
if (isEmulator()) { try {
Log.w(TAG, "Running on emulator - MPV player disabled due to EGL/OpenGL compatibility issues") renderer?.start()
// Don't start renderer on emulator, will show error when trying to play } catch (e: Exception) {
} else { Log.e(TAG, "Failed to start renderer: ${e.message}")
try { onError(mapOf("error" to "Failed to start renderer: ${e.message}"))
renderer?.start()
} catch (e: Exception) {
Log.e(TAG, "Failed to start renderer: ${e.message}")
onError(mapOf("error" to "Failed to start renderer: ${e.message}"))
}
} }
} }
private var isOnEmulator: Boolean = isEmulator()
// MARK: - SurfaceHolder.Callback // MARK: - SurfaceHolder.Callback
@@ -149,13 +125,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// MARK: - Video Loading // MARK: - Video Loading
fun loadVideo(config: VideoLoadConfig) { fun loadVideo(config: VideoLoadConfig) {
// Block video loading on emulators
if (isOnEmulator) {
Log.w(TAG, "Cannot load video on emulator - MPV player not supported")
onError(mapOf("error" to "MPV player is not supported on emulators. Please test on a real device."))
return
}
// Skip reload if same URL is already playing // Skip reload if same URL is already playing
if (currentUrl == config.url) { if (currentUrl == config.url) {
return return
@@ -330,9 +299,15 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
return _isZoomedToFill return _isZoomedToFill
} }
// MARK: - Technical Info
fun getTechnicalInfo(): Map<String, Any> {
return renderer?.getTechnicalInfo() ?: emptyMap()
}
// MARK: - MPVLayerRenderer.Delegate // MARK: - MPVLayerRenderer.Delegate
override fun onPositionChanged(position: Double, duration: Double) { override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) {
cachedPosition = position cachedPosition = position
cachedDuration = duration cachedDuration = duration
@@ -344,7 +319,8 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
onProgress(mapOf( onProgress(mapOf(
"position" to position, "position" to position,
"duration" to duration, "duration" to duration,
"progress" to if (duration > 0) position / duration else 0.0 "progress" to if (duration > 0) position / duration else 0.0,
"cacheSeconds" to cacheSeconds
)) ))
} }

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