Compare commits

..

115 Commits

Author SHA1 Message Date
68ee4405bc utils/profiles/download.js aktualisiert
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Successful in 15m46s
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Successful in 15m8s
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Failing after 5s
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been skipped
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been skipped
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Failing after 4s
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Failing after 4s
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Successful in 20m45s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Failing after 36s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Failing after 27s
2025-08-21 08:21:31 +02:00
a7b06b8773 .github/workflows/build-android.yml aktualisiert
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Successful in 30m47s
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Successful in 17m29s
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Failing after 5s
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been skipped
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been skipped
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Failing after 4s
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Failing after 4s
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Successful in 23s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Failing after 5s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Failing after 4s
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (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
2025-08-20 21:55:49 +02:00
405503c3e8 .github/workflows/build-android.yml aktualisiert
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Failing after 30m27s
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Failing after 27m48s
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Failing after 5s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Failing after 5s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Failing after 4s
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been skipped
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been skipped
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Failing after 4s
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Failing after 5s
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (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
2025-08-20 20:06:52 +02:00
b774b3ceab .github/workflows/build-android.yml aktualisiert
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Failing after 13s
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Failing after 4s
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Failing after 4s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Failing after 4s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Failing after 4s
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been skipped
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been skipped
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Failing after 4s
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Failing after 5s
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (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
2025-08-20 20:05:29 +02:00
3f292d2d91 .github/workflows/build-android.yml aktualisiert
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Failing after 6s
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Failing after 6s
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Failing after 6s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Failing after 5s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Failing after 6s
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been skipped
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been skipped
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Failing after 6s
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Failing after 6s
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (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
2025-08-20 20:02:33 +02:00
91ea6301cf utils/profiles/download.js aktualisiert
Some checks failed
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Failing after 4s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Failing after 5s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Failing after 4s
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been skipped
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been skipped
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Failing after 6s
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Failing after 5s
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Failing after 16m57s
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Failing after 5s
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (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
2025-08-20 18:39:06 +02:00
Fredrik Burmester
7cab50750f chore: version
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Failing after 5s
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Failing after 5s
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Failing after 4s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Failing after 4s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Failing after 4s
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been skipped
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been skipped
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Failing after 4s
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Failing after 5s
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (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
2025-08-20 10:30:57 +02:00
Fredrik Burmester
d795e82581 fix: trickplay and re-rendering issues 2025-08-20 09:59:03 +02:00
Fredrik Burmester
e7161bc9ab fix: revert fade in controls 2025-08-20 08:21:01 +02:00
Fredrik Burmester
8e74363f32 Revert "chore: refactor controls (#946)"
This reverts commit 8389404975.
2025-08-20 08:18:12 +02:00
Alex
1cb28788d6 Fix selecting bit rate on whole series downloads (#956)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-08-20 00:12:51 +10:00
renovate[bot]
ff9f855d4c chore(deps): update amannn/action-semantic-pull-request action to v6.1.0 (#953) 2025-08-19 13:37:47 +02:00
Fredrik Burmester
13df2d1077 chore: version 2025-08-19 10:01:34 +02:00
Fredrik Burmester
8389404975 chore: refactor controls (#946)
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-08-19 09:02:56 +02:00
Fredrik Burmester
cd920e2d84 fix: small design change 2025-08-19 08:10:54 +02:00
Gauvain
92a11c18e0 docs: add new contributors to README (#951) 2025-08-19 04:25:49 +02:00
Gauvain
e05f10fe42 ci: add actions language to CodeQL analysis matrix
Expands security scanning to include GitHub Actions workflows alongside existing JavaScript/TypeScript analysis for more comprehensive code security coverage
2025-08-19 01:09:23 +02:00
renovate[bot]
2540ae22ce chore(deps): update actions/dependency-review-action action to v4.7.2 (#950)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 01:08:50 +02:00
Gauvain
f490957091 ci: add iOS 18.0 SDK installation step (#949) 2025-08-19 01:06:28 +02:00
renovate[bot]
a146fc8810 chore(deps): update dependency @biomejs/biome to v2.2.0 (#934)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Uruk <contact@uruk.dev>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-08-18 23:00:33 +02:00
renovate[bot]
100d7e0830 chore(deps): update github/codeql-action action to v3.29.10 (#948)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-18 21:18:54 +02:00
Fredrik Burmester
ebcdd5bbf7 feat: show when the stream ends, not only remaining time (#944) 2025-08-18 14:57:02 +02:00
lance chant
18b33884e6 fix: settings storage calc (#943)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2025-08-18 14:53:58 +02:00
Fredrik Burmester
9410239c48 feat: scale factor and aspect ratio (#942) 2025-08-18 14:24:45 +02:00
Fredrik Burmester
4fed25a3ab chore: version 2025-08-18 09:17:24 +02:00
Fredrik Burmester
a8810cae8a Merge branch 'feat/fade-in-controls' into develop 2025-08-18 09:16:38 +02:00
Fredrik Burmester
aff009de92 chore: version 2025-08-18 07:48:57 +02:00
Alex
1924efbef2 Fix more bugs (#939)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-08-17 15:25:51 +10:00
Alex
3b53d76a18 Hotfix/offline playback remaining bugs (#937)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-08-16 18:11:55 +10:00
Fredrik Burmester
b7221e5599 chore: version bump 2025-08-15 21:45:37 +02:00
Fredrik Burmester
5384c34b27 feat: infinite scrolling in favorites tab (#929)
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-08-15 21:34:36 +02:00
Alex
ca92f61900 refactor: Feature/offline mode rework (#859)
Co-authored-by: lostb1t <coding-mosses0z@icloud.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
Co-authored-by: Gauvino <uruknarb20@gmail.com>
Co-authored-by: storm1er <le.storm1er@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Chris <182387676+whoopsi-daisy@users.noreply.github.com>
Co-authored-by: arch-fan <55891793+arch-fan@users.noreply.github.com>
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-08-15 21:34:22 +02:00
Uruk
4fba558c33 refactor: remove gradle optimization properties
Removes daemon, parallel processing, and configure-on-demand gradle properties to simplify configuration and potentially avoid build conflicts.

These optimizations may cause issues in certain build environments or with specific project configurations.
2025-08-15 05:06:06 +02:00
Uruk
d82767f5df ci: simplify bun cache key in iOS build workflow
Removes architecture-specific and branch-specific components from the cache key to improve cache hit rates across different runners and branches.

The simplified key structure reduces cache fragmentation while maintaining cache effectiveness through the bun.lock hash.
2025-08-15 04:56:01 +02:00
Uruk
e56fc93b14 ci: remove redundant node_modules caching step
Eliminates unnecessary node_modules cache configuration since bun handles dependency caching more efficiently through its own mechanisms.

Reduces workflow complexity and potential cache conflicts while maintaining build performance.
2025-08-15 04:44:19 +02:00
Uruk
1e399297bd ci: remove Expo CLI cache step from iOS build workflow
Eliminates unnecessary caching of Expo CLI in the iOS build pipeline to streamline the workflow and reduce potential cache-related issues.
2025-08-15 04:30:43 +02:00
Gauvain
feaf82fa3f ci: remove CocoaPods cache and update EAS to latest
Removes CocoaPods caching step which may cause build inconsistencies and updates EAS CLI to use latest version instead of pinned version for improved tooling and bug fixes
2025-08-15 04:21:25 +02:00
Gauvain
781d199546 refactor: simplify renovate configuration and revert kotlin (#933)
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2025-08-14 16:07:49 +02:00
liamwibo
3013251285 fix(readme): change discord invite link to discord badge since old link expired (#913)
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2025-08-14 14:14:18 +02:00
Gauvain
0e1ed71dc1 refactor: biome update and fix renovate and ci (#932) 2025-08-14 10:43:01 +02:00
renovate[bot]
5a781ba62c chore(deps): update amannn/action-semantic-pull-request action to v6 (#931)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-13 23:14:26 +02:00
renovate[bot]
0cea614423 chore(deps): update github/codeql-action action to v3.29.9 (#930)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-13 22:15:32 +02:00
Gauvain
24d006742b Merge branch 'develop' into feat/fade-in-controls 2025-08-13 20:32:53 +02:00
Gauvain
c7f0c2ec83 refactor(ci): Improves CI build performance with enhanced caching (#923)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-13 15:43:22 +02:00
Fredrik Burmester
c34c7fbe83 feat: fade in the controls (instead of on/off toggle) 2025-08-13 15:27:47 +02:00
renovate[bot]
57bbb59874 chore(deps): update actions/checkout action to v5 (#926)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Failing after 11s
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Failing after 4s
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Failing after 4s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Failing after 4s
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been skipped
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been skipped
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Failing after 4s
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Failing after 4s
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (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
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 16:51:54 +02:00
renovate[bot]
e90d2e2244 chore(deps): update dependency @react-native-community/cli to v20 (#924)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 14:05:51 +02:00
renovate[bot]
917dabc4be chore(deps): update actions/checkout action to v4.3.0 (#925)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 14:05:17 +02:00
renovate[bot]
bc2defc8ef chore(deps): update dependency @biomejs/biome to v2.1.4 (#921)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 00:46:20 +02:00
Gauvain
3ce1480e10 fix: Adds Biome version management to Renovate config (#920) 2025-08-11 00:18:19 +02:00
renovate[bot]
9597b40726 chore(deps): update github/codeql-action action to v3.29.8 (#917)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-10 22:51:29 +02:00
Gauvain
1e6408d5be chore: resolve final biome warning with explicit type annotations (#908) 2025-08-08 14:38:00 +02:00
Gauvain
c2f6897f47 fix(ci): Disables fail-fast for CI build matrices (#910) 2025-08-08 14:37:43 +02:00
Jaakko Rantamäki
eaf3682384 fix: Android adaptive and themed icons (#762) 2025-08-08 10:30:00 +02:00
renovate[bot]
f3c7b636a8 chore(deps): update ci dependencies (#911) 2025-08-07 21:09:41 +02:00
Gauvain
64d34a9354 feat: Adds separate Android TV and iOS TV build workflows (#907) 2025-08-07 16:08:40 +02:00
Edmond
2a2ecf0526 feat: Add new translation for Traditional Chinese (zh-TW) (#796) 2025-08-07 13:26:02 +02:00
Ferran
a77c7e8e3c feat(lang): add Catalan localization support (#873)
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-08-07 13:19:48 +02:00
Gauvain
88791eccf9 fix: Adds conditional check to validate PR title job (#901) 2025-08-07 13:01:32 +02:00
Gauvain
515f7ea26d fix: only run iOS build if it’s on a branch of the repo (#872) 2025-08-07 13:01:22 +02:00
Nguyen Quang Huy
e83bbf3121 feat: Added Vietnamese translation (#834) 2025-08-07 13:01:01 +02:00
lance chant
89b34eddc1 fix: tv playback (#820)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Signed-off-by: lancechant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: Uruk <contact@uruk.dev>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-08-07 10:12:40 +02:00
Gauvain
89fd7f0e34 fix: add expo-doctor, fixed a warning (#895) 2025-08-06 21:46:16 +02:00
Gauvain
ab9ae5b620 fix(deps): update biome (#894) 2025-08-06 21:45:58 +02:00
retardgerman
a9c519971e fix: loading conditionals (#753) (#805) 2025-08-05 11:23:14 +02:00
renovate[bot]
e51b7351f8 chore(deps): update ci dependencies (#892)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-04 23:42:17 +02:00
renovate[bot]
e0f9d6ea1c chore(deps): update github/codeql-action action to v3.29.4 (#874) 2025-07-29 16:21:34 +02:00
Fredrik Burmester
1817c5dbd2 chore: update deps (#886) 2025-07-29 15:55:41 +02:00
Fredrik Burmester
0619c8c9c4 fix: update bottom tabs (#885) 2025-07-28 13:07:19 +02:00
Chris
d6ed318eb8 docs: readme-rehaul
- Added a Streamyfin banner at the top
- Aligned the descriptive text for Streamyfin
- Moved the "Buy Me a Coffee" button to the top for better visibility and contrast
- The four screenshots are now fully aligned across the entire page grid instead of being left-aligned
2025-07-24 22:28:16 +07:00
Gauvain
5f39622ad6 fix: bump biome and fix error (#864) 2025-07-21 09:44:24 +02:00
renovate[bot]
3b2a6bd40a chore(deps): update marocchino/sticky-pull-request-comment action to v2.9.4 (#866)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-20 02:03:05 +02:00
Chris
8d3e165edf docs: README.md
Implemented general improvements and introduced sponsorship.
2025-07-18 21:19:13 +07:00
lance chant
f3a9fc9d1c fix: always transcode with profile 5 (#703)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2025-07-15 08:41:52 +02:00
Emre Sanden
820af06419 feat(lang): add Norwegian localization support (#670)
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2025-07-15 08:40:28 +02:00
Miro Rauhala
80192e65c4 feat(lang): add Finnish localization support (#676)
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2025-07-15 08:39:38 +02:00
Endrit Beqiri
ff930e2ad2 feat: Added Albanian and Danish translations (#657)
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-07-15 08:38:00 +02:00
djmeero
fafc2e65ac feat(i18n): add Romanian language support via ro.json (#706) 2025-07-15 08:37:37 +02:00
Fredrik Burmester
61c783fb55 chore 2025-07-14 15:48:17 +02:00
Fredrik Burmester
59df18621b chore: version 2025-07-14 15:12:56 +02:00
Alex
0021b94e00 Fix correct time not being reported when switching quality in the beg… (#855)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-07-14 20:41:24 +10:00
Alex
53b43edc2a Revert "fix: Recently Added isn't updating correctly." (#852) 2025-07-13 04:47:26 +10:00
Alex
64e8514985 Changed || to ?? to account for 0 values (#851)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-07-13 02:57:49 +10:00
Alex
a3d9207bca Change to ts (#848)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-07-12 13:18:08 +10:00
Alex
ef0880695e Update package json from expo doctor update (#846)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-07-12 04:38:16 +10:00
Fredrik Burmester
073110fac9 chore: remove unnessesary file 2025-07-10 21:42:52 +02:00
Fredrik Burmester
2d58157cf7 chore: version 2025-07-10 21:42:35 +02:00
Fredrik Burmester
571be9840f chore: version 2025-07-10 21:40:10 +02:00
Fredrik Burmester
a2cbc722c7 chore 2025-07-10 15:34:33 +02:00
Fredrik Burmester
bc7c612cca feat: add CodeRabbit configuration for React Native project 2025-07-10 15:34:09 +02:00
Alex
fe8f07336a Fix orientation race condition (#841)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-07-10 15:25:57 +02:00
arch-fan
305b06f781 fix: expo issue by updating deps (#823) 2025-07-10 21:19:48 +10:00
renovate[bot]
7d57cf1a69 chore(deps): update github/codeql-action action to v3.29.2 (#821)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 20:02:02 +02:00
renovate[bot]
3c56544a24 fix(deps): update dependency com.android.tools.build:gradle to v8.11.0 (#819) 2025-06-28 16:26:43 +02:00
renovate[bot]
d6696cc84e chore(deps): update github/codeql-action action to v3.29.1 (#818) 2025-06-28 15:01:55 +02:00
renovate[bot]
bf97e419ae fix(deps): update dependency react-native-safe-area-context to v5.5.0 (#774)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-23 22:40:09 +02:00
renovate[bot]
1e8fe46f17 chore(deps): update dependency @react-native-community/cli to v18 (#783)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-23 22:38:42 +02:00
renovate[bot]
73317e9781 fix(deps): update dependency @shopify/flash-list to v1.8.3 (#736)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-23 22:38:12 +02:00
renovate[bot]
eba0bbc9cf chore(deps): update dependency @biomejs/biome to v2 (#811)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Gauvino <uruknarb20@gmail.com>
2025-06-23 20:18:51 +02:00
renovate[bot]
c69ec61656 chore(deps): update dependency @types/jest to v30 (#812)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-23 20:05:05 +02:00
renovate[bot]
de12e2b0a2 chore(deps): update marocchino/sticky-pull-request-comment action to v2.9.3 (#810)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-23 19:48:19 +02:00
Chris
87dc57a576 docs: Update README.md (#802) 2025-06-19 16:35:10 +02:00
Chris
52c8b99dd5 docs: Clarify legal use of Streamyfin with a piracy disclaimer in README (#801) 2025-06-18 11:02:56 +02:00
renovate[bot]
7beabe4702 fix(deps): update dependency i18next to v25 (#784) 2025-06-13 12:37:47 +02:00
renovate[bot]
415d7d6e9a fix(deps): update dependency com.android.tools.build:gradle to v8 (#772) 2025-06-13 12:37:15 +02:00
renovate[bot]
51b47971e2 chore(deps): update dependency lint-staged to v16 (#771)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-13 10:37:43 +02:00
Gauvain
90b0d413bc fix: remove pull request target 2025-06-13 09:32:19 +02:00
renovate[bot]
a18bcae0fb chore(deps): update github/codeql-action action to v3.29.0 (#769)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-12 16:28:14 +02:00
Gauvain
4ccffad3e7 fix: pr build 2025-06-12 11:44:25 +02:00
renovate[bot]
46b08007a4 chore(deps): update dependency node to v22 (#766)
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (push) Failing after 4s
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Failing after 1s
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been skipped
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Successful in 7s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Failing after 32s
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-11 15:05:49 +02:00
renovate[bot]
7b05fe43cf chore(deps): update github/codeql-action action to v3.28.19 (#763)
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (push) Failing after 4s
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Failing after 1s
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been skipped
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Failing after 2s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Failing after 7s
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-11 00:01:41 +02:00
Gauvain
8f7749160e fix: add dashboard for renovate 2025-06-10 23:55:57 +02:00
storm1er
d4c51697d4 feat: Persist ignore safe area accross stream and app restart (#701) 2025-06-06 11:00:52 +02:00
Gauvino
7091502667 fix: remove git commit from release sonce it's already present in artifact menu 2025-06-04 18:47:19 +02:00
Gauvino
d6c7246cd1 fix: put @main instead of v8 to fix cache problem 2025-06-04 13:31:06 +02:00
286 changed files with 11687 additions and 7334 deletions

View File

@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(bun install:*)",
"Bash(bunx expo prebuild:*)",
"Bash(bunx expo run:*)",
"Bash(npx expo prebuild:*)",
"Bash(npx expo run:*)",
"Bash(xcodebuild:*)"
],
"deny": []
}
}

View File

@@ -0,0 +1,7 @@
---
description: Don't write code directly in the ios folder.
globs:
alwaysApply: true
---
We never write code directly in the ios folder. This code is generated by expo plugins.

View File

@@ -1,3 +0,0 @@
{
"extends": ["next/core-web-vitals"]
}

View File

@@ -43,6 +43,7 @@ body:
label: Version label: Version
description: What version of Streamyfin are you running? description: What version of Streamyfin are you running?
options: options:
- 0.29.0
- 0.28.0 - 0.28.0
- 0.27.0 - 0.27.0
- 0.26.1 - 0.26.1

View File

@@ -1,4 +1,4 @@
name: 🤖 Android APK Build name: 🤖 Android APK Build (Phone + TV)
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
@@ -12,68 +12,91 @@ on:
branches: [develop, master] branches: [develop, master]
jobs: jobs:
build: build-android:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
name: 🏗️ Build Android APK name: 🏗️ Build Android APK
permissions: permissions:
contents: read contents: read
strategy:
fail-fast: false
matrix:
target: [phone, tv]
steps: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@v4.3.0 # v5.0.0
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
submodules: recursive
fetch-depth: 0 fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with: with:
bun-version: '1.2.15' bun-version: latest
- name: ☕ Setup JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: 'zulu'
java-version: '17'
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 uses: actions/cache@v4.2.4 # v4.2.4
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-bun-cache- ${{ runner.os }}-${{ runner.arch }}-bun-develop
${{ runner.os }}-bun-develop
- name: 📦 Install dependencies - name: 💾 Cache node_modules
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: node_modules
key: ${{ runner.os }}-${{ runner.arch }}-modules-latest-develop-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-modules-latest-develop
${{ runner.os }}-${{ runner.arch }}-modules-develop
${{ runner.os }}-modules-develop
- name: 📦 Install dependencies and reload submodules
run: | run: |
bun install --frozen-lockfile bun install --frozen-lockfile
bun run submodule-reload bun run submodule-reload
- name: 💾 Cache Android dependencies - name: 💾 Cache Gradle global
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: | path: |
android/.gradle ~/.gradle/caches
key: ${{ runner.os }}-android-deps-${{ hashFiles('android/**/build.gradle') }} ~/.gradle/wrapper
restore-keys: | key: ${{ runner.os }}-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
${{ runner.os }}-android-deps- restore-keys: ${{ runner.os }}-gradle-develop
- name: 🛠️ Generate project files - name: 🛠️ Generate project files
run: bun run prebuild run: |
if [ "${{ matrix.target }}" = "tv" ]; then
bun run prebuild:tv
else
bun run prebuild
fi
- name: 🚀 Build APK via Bun - name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-android-gradle-develop
- name: 🚀 Build APK
env:
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
run: bun run build:android:local run: bun run build:android:local
- name: 📅 Set date tag - name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact - name: 📤 Upload APK artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v3 # v4.6.2
with: with:
name: streamyfin-apk-${{ github.sha }}-${{ env.DATE_TAG }} name: streamyfin-android-${{ matrix.target }}-apk-${{ env.DATE_TAG }}
path: | path: |
android/app/build/outputs/apk/release/*.apk android/app/build/outputs/apk/release/*.apk
android/app/build/outputs/bundle/release/*.aab
retention-days: 7 retention-days: 7

View File

@@ -1,4 +1,4 @@
name: 🤖 iOS IPA Build name: 🤖 iOS IPA Build (Phone + TV)
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
@@ -12,51 +12,71 @@ on:
branches: [develop, master] branches: [develop, master]
jobs: jobs:
build: build-ios:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'
runs-on: macos-15 runs-on: macos-15
name: 🏗️ Build iOS IPA name: 🏗️ Build iOS IPA
permissions: permissions:
contents: read contents: read
strategy:
fail-fast: false
matrix:
target: [phone, tv]
steps: steps:
- name: 📥 Check out repository - name: 📥 Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
submodules: recursive
fetch-depth: 0 fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with: with:
bun-version: '1.2.15' bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
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') }}
restore-keys: | restore-keys: |
${{ runner.os }}-bun-cache- ${{ runner.os }}-bun-cache
- name: 📦 Install & Prepare - name: 📦 Install dependencies and reload submodules
run: | run: |
bun install --frozen-lockfile bun install --frozen-lockfile
bun run submodule-reload bun run submodule-reload
- name: 🛠️ Generate project files - name: 🛠️ Generate project files
run: bun run prebuild run: |
if [ "${{ matrix.target }}" = "tv" ]; then
bun run prebuild:tv
else
bun run prebuild
fi
- name: 🏗 Setup EAS - name: 🏗 Setup EAS
uses: expo/expo-github-action@v8 uses: expo/expo-github-action@main
with: with:
eas-version: 16.7.1 eas-version: latest
token: ${{ secrets.EXPO_TOKEN }} token: ${{ secrets.EXPO_TOKEN }}
- name: 🏗 Build iOS app - name: Ensure iOS/tvOS SDKs installed
run: | run: |
eas build -p ios --local --non-interactive if [ "${{ matrix.target }}" = "tv" ]; then
xcodebuild -downloadPlatform tvOS
else
xcodebuild -downloadPlatform iOS
fi
- name: 🚀 Build iOS app
env:
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
run: eas build -p ios --local --non-interactive
- name: 📅 Set date tag - name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
@@ -64,7 +84,6 @@ jobs:
- name: 📤 Upload IPA artifact - name: 📤 Upload IPA artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: streamyfin-ipa-${{ github.sha }}-${{ env.DATE_TAG }} name: streamyfin-ios-${{ matrix.target }}-ipa-${{ env.DATE_TAG }}
path: | path: build-*.ipa
build-*.ipa
retention-days: 7 retention-days: 7

View File

@@ -19,7 +19,7 @@ jobs:
steps: steps:
- name: 📥 Checkout repository - name: 📥 Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
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
@@ -29,10 +29,10 @@ jobs:
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with: with:
bun-version: '1.2.15' bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: | path: |
~/.bun/install/cache ~/.bun/install/cache

View File

@@ -20,24 +20,24 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
language: [ 'javascript-typescript' ] language: [ 'javascript-typescript', 'actions' ]
steps: steps:
- name: 📥 Checkout repository - name: 📥 Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
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
fetch-depth: 0 fetch-depth: 0
- name: 🏁 Initialize CodeQL - name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 uses: github/codeql-action/init@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild - name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 uses: github/codeql-action/autobuild@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10
- name: 🧪 Perform CodeQL Analysis - name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 uses: github/codeql-action/analyze@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10

View File

@@ -1,10 +1,12 @@
name: 🚦 Security & Quality Gate name: 🚦 Security & Quality Gate
on: on:
pull_request_target: pull_request:
types: [opened, edited, synchronize, reopened] types: [opened, edited, synchronize, reopened]
branches: [develop, master] branches: [develop, master]
workflow_dispatch: workflow_dispatch:
push:
branches: [develop]
permissions: permissions:
contents: read contents: read
@@ -12,17 +14,18 @@ permissions:
jobs: jobs:
validate_pr_title: validate_pr_title:
name: "📝 Validate PR Title" name: "📝 Validate PR Title"
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions: permissions:
pull-requests: write pull-requests: write
contents: read contents: read
steps: steps:
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3 - uses: amannn/action-semantic-pull-request@7f33ba792281b034f64e96f4c0b5496782dd3b37 # v6.1.0
id: lint_pr_title id: lint_pr_title
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2 - uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
if: always() && (steps.lint_pr_title.outputs.error_message != null) if: always() && (steps.lint_pr_title.outputs.error_message != null)
with: with:
header: pr-title-lint-error header: pr-title-lint-error
@@ -36,7 +39,7 @@ jobs:
``` ```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }} - if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2 uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
with: with:
header: pr-title-lint-error header: pr-title-lint-error
delete: true delete: true
@@ -48,19 +51,41 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0 fetch-depth: 0
- name: Dependency Review - name: Dependency Review
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1 uses: actions/dependency-review-action@bc41886e18ea39df68b1b1245f4184881938e050 # v4.7.2
with: with:
fail-on-severity: high fail-on-severity: high
deny-licenses: GPL-3.0, AGPL-3.0 deny-licenses: GPL-3.0, AGPL-3.0
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }} base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
head-ref: ${{ github.event.pull_request.head.sha || github.ref }} head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
expo-doctor:
name: 🚑 Expo Doctor Check
runs-on: ubuntu-24.04
steps:
- name: 🛒 Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha }}
submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: latest
- name: 📦 Install dependencies (bun)
run: bun install --frozen-lockfile
- name: 🚑 Run Expo Doctor
run: bun expo-doctor
code_quality: code_quality:
name: "🔍 Lint & Test (${{ matrix.command }})" name: "🔍 Lint & Test (${{ matrix.command }})"
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -70,9 +95,10 @@ jobs:
command: command:
- "lint" - "lint"
- "check" - "check"
- "format"
steps: steps:
- name: "📥 Checkout PR code" - name: "📥 Checkout PR code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
submodules: recursive submodules: recursive
@@ -81,12 +107,12 @@ jobs:
- name: "🟢 Setup Node.js" - name: "🟢 Setup Node.js"
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: '20.x' node-version: '24.x'
- name: "🍞 Setup Bun" - name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with: with:
bun-version: '1.2.15' bun-version: latest
- name: "📦 Install dependencies" - name: "📦 Install dependencies"
run: bun install --frozen-lockfile run: bun install --frozen-lockfile

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: 🛎️ Notify Discord - name: 🛎️ Notify Discord
uses: Ilshidur/action-discord@0c4b27844ba47cb1c7bee539c8eead5284ce9fa9 # 0.3.2 uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # 0.4.0
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640 DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640

2
.gitignore vendored
View File

@@ -10,7 +10,6 @@ npm-debug.*
*.orig.* *.orig.*
web-build/ web-build/
modules/vlc-player/android/build modules/vlc-player/android/build
modules/vlc-player/android/.gradle
# macOS # macOS
.DS_Store .DS_Store
@@ -46,3 +45,4 @@ streamyfin-4fec1-firebase-adminsdk.json
.env .env
.env.local .env.local
*.aab *.aab
/version-backup-*

114
README.md
View File

@@ -1,15 +1,24 @@
# 📺 Streamyfin
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a> <a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
Welcome to Streamyfin, a simple and user-friendly Jellyfin video streaming client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
<div style="display: flex; flex-direction: row; gap: 8px"> <p align="center">
<img width=150 src="./assets/images/screenshots/screenshot1.png" /> <img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
<img width=150 src="./assets/images/screenshots/screenshot3.png" /> </p>
<img width=150 src="./assets/images/screenshots/screenshot2.png" />
<img width=159 src="./assets/images/jellyseerr.PNG"/> **Streamyfin is a simple, user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
</div>
---
<p align="center">
<img src="./assets/images/screenshots/screenshot1.png" width="22%">
&nbsp;
<img src="./assets/images/screenshots/screenshot3.png" width="22%">
&nbsp;
<img src="./assets/images/screenshots/screenshot2.png" width="22%">
&nbsp;
<img src="./assets/images/jellyseerr.PNG" width="23%">
</p>
## 🌟 Features ## 🌟 Features
@@ -23,17 +32,17 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin video streaming clien
## 🧪 Experimental Features ## 🧪 Experimental Features
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These are still in development, and we appreciate your patience and feedback as we work to improve them. Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These features are still in development, and your patience and feedback are much appreciated as we work to improve them.
### Downloading ### 📥 Downloading
Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode. Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
### Chromecast ### 🎥 Chromecast
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos, but we're working on adding support for subtitles and other features. Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos, but we're working on adding support for subtitles and other features.
### Streamyfin Plugin ### 🧩 Streamyfin Plugin
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example: The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
@@ -41,21 +50,21 @@ The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that ho
- Choose the default languages - Choose the default languages
- Set download method and search provider - Set download method and search provider
- Customize home screen - Customize home screen
- And more... - And much more...
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin) [Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
### Jellysearch ### 🔍 Jellysearch
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀 [Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin!
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients. > A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
## Roadmap for V1 ## 🛣️ Roadmap for V1
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open for feedback and suggestions, so please let us know if you have any ideas or feature requests. Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
## Get it now ## 📥 Get it now
<div style="display: flex; gap: 5px;"> <div style="display: flex; gap: 5px;">
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a> <a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
@@ -64,7 +73,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android. Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
### Beta testing ### 🧪 Beta testing
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the ⁠🧪-public-beta channel on Discord and I'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that I can manually add you. To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the ⁠🧪-public-beta channel on Discord and I'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that I can manually add you.
@@ -81,7 +90,7 @@ To access the Streamyfin beta, you need to subscribe to the Member tier (or high
We welcome any help to make Streamyfin better. If you'd like to contribute, please fork the repository and submit a pull request. For major changes, it's best to open an issue first to discuss your ideas. We welcome any help to make Streamyfin better. If you'd like to contribute, please fork the repository and submit a pull request. For major changes, it's best to open an issue first to discuss your ideas.
### Development info ### 👨‍💻 Development info
1. Use node `>20` 1. Use node `>20`
2. Install dependencies `bun i && bun run submodule-reload` 2. Install dependencies `bun i && bun run submodule-reload`
@@ -107,18 +116,18 @@ Key points of the MPL-2.0:
- You must disclose your source code for any modifications to the covered files - You must disclose your source code for any modifications to the covered files
- Larger works may combine MPL code with code under other licenses - Larger works may combine MPL code with code under other licenses
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license - MPL-licensed components must remain under the MPL, but the larger work can be under a different license
- For the full text of the license, please see the LICENSE file in this repository. - For the full text of the license, please see the LICENSE file in this repository
## 🌐 Connect with Us ## 🌐 Connect with Us
Join our Discord: [https://discord.gg/aJvAYeycyY](https://discord.gg/aJvAYeycyY) Join our Discord: [![](https://dcbadge.limes.pink/api/server/https://discord.gg/BuGG9ZNhaE)](https://discord.gg/BuGG9ZNhaE)
If you have questions or need support, feel free to reach out: Need support or have questions:
- GitHub Issues: Report bugs or request features here. - GitHub Issues: Report bugs or request features here.
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com) - Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
## FAQ ## FAQ
1. Q: Why can't I see my libraries in Streamyfin? 1. Q: Why can't I see my libraries in Streamyfin?
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only. A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only.
@@ -135,81 +144,90 @@ We would like to thank the Jellyfin team for their great software and awesome su
Special shoutout to the JF official clients for being an inspiration to ours. Special shoutout to the JF official clients for being an inspiration to ours.
### Core Developers ### 🏆 Core Developers
Thanks to the following contributors for their significant contributions: Thanks to the following contributors for their significant contributions:
<div align="left">
<table> <table>
<tr <tr>
style="
display: flex;
justify-content: space-around;
align-items: center;
flex-wrap: wrap;
"
>
<td align="center"> <td align="center">
<a href="https://github.com/Alexk2309"> <a href="https://github.com/Alexk2309">
<img src="https://github.com/Alexk2309.png?size=80" width="80" style="border-radius: 50%;" /> <img src="https://github.com/Alexk2309.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@Alexk2309</b></sub> <br /><sub><b>@Alexk2309</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/herrrta"> <a href="https://github.com/herrrta">
<img src="https://github.com/herrrta.png?size=80" width="80" style="border-radius: 50%;" /> <img src="https://github.com/herrrta.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@herrrta</b></sub> <br /><sub><b>@herrrta</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/lostb1t"> <a href="https://github.com/lostb1t">
<img src="https://github.com/lostb1t.png?size=80" width="80" style="border-radius: 50%;" /> <img src="https://github.com/lostb1t.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@lostb1t</b></sub> <br /><sub><b>@lostb1t</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/Simon-Eklundh"> <a href="https://github.com/Simon-Eklundh">
<img src="https://github.com/Simon-Eklundh.png?size=80" width="80" style="border-radius: 50%;" /> <img src="https://github.com/Simon-Eklundh.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@Simon-Eklundh</b></sub> <br /><sub><b>@Simon-Eklundh</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/topiga"> <a href="https://github.com/topiga">
<img src="https://github.com/topiga.png?size=80" width="80" style="border-radius: 50%;" /> <img src="https://github.com/topiga.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@topiga</b></sub> <br /><sub><b>@topiga</b></sub>
</a> </a>
</td> </td>
<td align="center">
<a href="https://github.com/lancechant">
<img src="https://github.com/lancechant.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@lancechant</b></sub>
</a>
</td>
</tr>
<tr>
<td align="center"> <td align="center">
<a href="https://github.com/simoncaron"> <a href="https://github.com/simoncaron">
<img src="https://github.com/simoncaron.png?size=80" width="80" style="border-radius: 50%;" /> <img src="https://github.com/simoncaron.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@simoncaron</b></sub> <br /><sub><b>@simoncaron</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/jakequade"> <a href="https://github.com/jakequade">
<img src="https://github.com/jakequade.png?size=80" width="80" style="border-radius: 50%;" /> <img src="https://github.com/jakequade.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@jakequade</b></sub> <br /><sub><b>@jakequade</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/Ryan0204"> <a href="https://github.com/Ryan0204">
<img src="https://github.com/Ryan0204.png?size=80" width="80" style="border-radius: 50%;" /> <img src="https://github.com/Ryan0204.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@Ryan0204</b></sub> <br /><sub><b>@Ryan0204</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/retardgerman"> <a href="https://github.com/retardgerman">
<img src="https://github.com/retardgerman.png?size=80" width="80" style="border-radius: 50%;" /> <img src="https://github.com/retardgerman.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@retardgerman</b></sub> <br /><sub><b>@retardgerman</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/whoopsi-daisy"> <a href="https://github.com/whoopsi-daisy">
<img src="https://github.com/whoopsi-daisy.png?size=80" width="80" style="border-radius: 50%;" /> <img src="https://github.com/whoopsi-daisy.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@whoopsi-daisy</b></sub> <br /><sub><b>@whoopsi-daisy</b></sub>
</a> </a>
</td> </td>
<td align="center">
<a href="https://github.com/Gauvino">
<img src="https://github.com/Gauvino.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@Gauvino</b></sub>
</a>
</td>
</tr> </tr>
</table> </table>
</div>
And all other developers who have contributed to Streamyfin, thank you for your contributions. And all other developers who have contributed to Streamyfin, thank you for your contributions.
@@ -220,6 +238,12 @@ I'd also like to thank the following people and projects for their contributions
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project. - [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
- The Jellyfin devs for always being helpful in the Discord. - The Jellyfin devs for always being helpful in the Discord.
## Star History ## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=streamyfin/streamyfin&type=Date)](https://star-history.com/#streamyfin/streamyfin&Date) [![Star History Chart](https://api.star-history.com/svg?repos=streamyfin/streamyfin&type=Date)](https://star-history.com/#streamyfin/streamyfin&Date)
## ⚠️ Disclaimer
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions or support requests related to piracy are strictly prohibited across all our channels.
## 🤝 Sponsorship
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)

View File

@@ -4,6 +4,9 @@ module.exports = ({ config }) => {
"react-native-google-cast", "react-native-google-cast",
{ useDefaultExpandedMediaControls: true }, { useDefaultExpandedMediaControls: true },
]); ]);
// Add the background downloader plugin only for non-TV builds
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
} }
return { return {
android: { android: {

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.28.0", "version": "0.32.1",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
@@ -29,18 +29,19 @@
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "com.fredrikburmester.streamyfin", "bundleIdentifier": "com.fredrikburmester.streamyfin",
"icon": { "icon": {
"dark": "./assets/images/icon-plain.png", "dark": "./assets/images/icon-ios-plain.png",
"light": "./assets/images/icon-ios-light.png", "light": "./assets/images/icon-ios-light.png",
"tinted": "./assets/images/icon-ios-tinted.png" "tinted": "./assets/images/icon-ios-tinted.png"
} },
"appleTeamId": "MWD5K362T8"
}, },
"android": { "android": {
"jsEngine": "hermes", "jsEngine": "hermes",
"versionCode": 56, "versionCode": 62,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/icon-plain.png", "foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-mono.png", "monochromeImage": "./assets/images/icon-android-themed.png",
"backgroundColor": "#464646" "backgroundColor": "#2E2E2E"
}, },
"package": "com.fredrikburmester.streamyfin", "package": "com.fredrikburmester.streamyfin",
"permissions": [ "permissions": [
@@ -113,17 +114,15 @@
} }
} }
], ],
["react-native-bottom-tabs"],
["./plugins/withChangeNativeAndroidTextToWhite.js"], ["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidManifest.js"], ["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"], ["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"], ["./plugins/withGradleProperties.js"],
["./plugins/withRNBackgroundDownloader.js"],
[ [
"expo-splash-screen", "expo-splash-screen",
{ {
"backgroundColor": "#2e2e2e", "backgroundColor": "#2e2e2e",
"image": "./assets/images/StreamyFinFinal.png", "image": "./assets/images/icon-ios-plain.png",
"imageWidth": 100 "imageWidth": 100
} }
], ],
@@ -134,12 +133,8 @@
"color": "#9333EA" "color": "#9333EA"
} }
], ],
[ "./plugins/with-runtime-framework-headers.js",
"react-native-google-cast", "react-native-bottom-tabs"
{
"useDefaultExpandedMediaControls": true
}
]
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true

View File

@@ -1,13 +1,12 @@
import Ionicons from "@expo/vector-icons/Ionicons";
import { useAtom } from "jotai/index";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import Ionicons from "@expo/vector-icons/Ionicons";
import { useAtom } from "jotai/index";
import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { FlatList, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null; const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null;

View File

@@ -1,7 +1,7 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Stack } 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 { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
export default function SearchLayout() { export default function SearchLayout() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -10,7 +10,7 @@ export default function SearchLayout() {
<Stack.Screen <Stack.Screen
name='index' name='index'
options={{ options={{
headerShown: true, headerShown: !Platform.isTV,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: t("tabs.favorites"), headerTitle: t("tabs.favorites"),
headerLargeStyle: { headerLargeStyle: {

View File

@@ -1,8 +1,8 @@
import { Favorites } from "@/components/home/Favorites"; import { useCallback, useState } from "react";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import React, { useCallback, useState } from "react";
import { RefreshControl, ScrollView, View } from "react-native"; import { RefreshControl, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Favorites } from "@/components/home/Favorites";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
export default function favorites() { export default function favorites() {
const invalidateCache = useInvalidatePlaybackProgressCache(); const invalidateCache = useInvalidatePlaybackProgressCache();

View File

@@ -1,15 +1,17 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Feather, Ionicons } from "@expo/vector-icons"; import { Feather, Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router"; import { Stack, useRouter } from "expo-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast"); const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
import { useAtom } from "jotai";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions"; import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { userAtom } from "@/providers/JellyfinProvider"; import { userAtom } from "@/providers/JellyfinProvider";
import { useAtom } from "jotai";
export default function IndexLayout() { export default function IndexLayout() {
const router = useRouter(); const _router = useRouter();
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -18,7 +20,7 @@ export default function IndexLayout() {
<Stack.Screen <Stack.Screen
name='index' name='index'
options={{ options={{
headerShown: true, headerShown: !Platform.isTV,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: t("tabs.home"), headerTitle: t("tabs.home"),
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
@@ -64,12 +66,6 @@ export default function IndexLayout() {
title: t("home.settings.settings_title"), title: t("home.settings.settings_title"),
}} }}
/> />
<Stack.Screen
name='settings/optimized-server/page'
options={{
title: "",
}}
/>
<Stack.Screen <Stack.Screen
name='settings/marlin-search/page' name='settings/marlin-search/page'
options={{ options={{

View File

@@ -1,3 +1,8 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { EpisodeCard } from "@/components/downloads/EpisodeCard"; import { EpisodeCard } from "@/components/downloads/EpisodeCard";
import { import {
@@ -6,11 +11,6 @@ import {
} from "@/components/series/SeasonDropdown"; } from "@/components/series/SeasonDropdown";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
export default function page() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
@@ -23,12 +23,12 @@ export default function page() {
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>( const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
{}, {},
); );
const { downloadedFiles, deleteItems } = useDownload(); const { getDownloadedItems, deleteItems } = useDownload();
const series = useMemo(() => { const series = useMemo(() => {
try { try {
return ( return (
downloadedFiles getDownloadedItems()
?.filter((f) => f.item.SeriesId === seriesId) ?.filter((f) => f.item.SeriesId === seriesId)
?.sort( ?.sort(
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!, (a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
@@ -37,7 +37,37 @@ export default function page() {
} catch { } catch {
return []; return [];
} }
}, [downloadedFiles]); }, [getDownloadedItems]);
// 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 = const seasonIndex =
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] || seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
@@ -45,20 +75,8 @@ export default function page() {
""; "";
const groupBySeason = useMemo<BaseItemDto[]>(() => { const groupBySeason = useMemo<BaseItemDto[]>(() => {
const seasons: Record<string, BaseItemDto[]> = {}; return seasonGroups[Number(seasonIndex)] ?? [];
}, [seasonGroups, seasonIndex]);
series?.forEach((episode) => {
if (!seasons[episode.item.ParentIndexNumber!]) {
seasons[episode.item.ParentIndexNumber!] = [];
}
seasons[episode.item.ParentIndexNumber!].push(episode.item);
});
return (
seasons[seasonIndex]?.sort((a, b) => a.IndexNumber! - b.IndexNumber!) ??
[]
);
}, [series, seasonIndex]);
const initialSeasonIndex = useMemo( const initialSeasonIndex = useMemo(
() => () =>
@@ -102,7 +120,7 @@ export default function page() {
<View className='flex flex-row items-center justify-start my-2 px-4'> <View className='flex flex-row items-center justify-start my-2 px-4'>
<SeasonDropdown <SeasonDropdown
item={series[0].item} item={series[0].item}
seasons={series.map((s) => s.item)} seasons={uniqueSeasons}
state={seasonIndexState} state={seasonIndexState}
initialSeasonIndex={initialSeasonIndex!} initialSeasonIndex={initialSeasonIndex!}
onSelect={(season) => { onSelect={(season) => {

View File

@@ -1,13 +1,3 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { type DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { writeToLog } from "@/utils/log";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { import {
BottomSheetBackdrop, BottomSheetBackdrop,
@@ -16,28 +6,69 @@ import {
BottomSheetView, BottomSheetView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { useNavigation, useRouter } from "expo-router"; import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native"; import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { useDownload } from "@/providers/DownloadProvider";
import { type DownloadedItem } from "@/providers/Downloads/types";
import { queueAtom } from "@/utils/atoms/queue";
import { writeToLog } from "@/utils/log";
export default function page() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation(); const { t } = useTranslation();
const [queue, setQueue] = useAtom(queueAtom); const [queue, setQueue] = useAtom(queueAtom);
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload(); const {
removeProcess,
getDownloadedItems,
deleteFileByType,
deleteAllFiles,
} = useDownload();
const router = useRouter(); const router = useRouter();
const [settings] = useSettings();
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [showMigration, setShowMigration] = useState(false);
const migration_20241124 = () => {
Alert.alert(
t("home.downloads.new_app_version_requires_re_download"),
t("home.downloads.new_app_version_requires_re_download_description"),
[
{
text: t("home.downloads.back"),
onPress: () => {
setShowMigration(false);
router.back();
},
},
{
text: t("home.downloads.delete"),
style: "destructive",
onPress: async () => {
await deleteAllFiles();
setShowMigration(false);
},
},
],
);
};
const downloadedFiles = getDownloadedItems();
const movies = useMemo(() => { const movies = useMemo(() => {
try { try {
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || []; return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
} catch { } catch {
migration_20241124(); setShowMigration(true);
return []; return [];
} }
}, [downloadedFiles]); }, [downloadedFiles]);
@@ -54,13 +85,11 @@ export default function page() {
}); });
return Object.values(series); return Object.values(series);
} catch { } catch {
migration_20241124(); setShowMigration(true);
return []; return [];
} }
}, [downloadedFiles]); }, [downloadedFiles]);
const insets = useSafeAreaInsets();
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
@@ -71,6 +100,12 @@ export default function page() {
}); });
}, [downloadedFiles]); }, [downloadedFiles]);
useEffect(() => {
if (showMigration) {
migration_20241124();
}
}, [showMigration]);
const deleteMovies = () => const deleteMovies = () =>
deleteFileByType("Movie") deleteFileByType("Movie")
.then(() => .then(() =>
@@ -98,16 +133,10 @@ export default function page() {
return ( return (
<> <>
<ScrollView <View style={{ flex: 1 }}>
contentContainerStyle={{ <ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
paddingLeft: insets.left, <View className='py-4'>
paddingRight: insets.right, <View className='mb-4 flex flex-col space-y-4 px-4'>
paddingBottom: 100,
}}
>
<View className='py-4'>
<View className='mb-4 flex flex-col space-y-4 px-4'>
{settings?.downloadMethod === DownloadMethod.Remux && (
<View className='bg-neutral-900 p-4 rounded-2xl'> <View className='bg-neutral-900 p-4 rounded-2xl'>
<Text className='text-lg font-bold'> <Text className='text-lg font-bold'>
{t("home.downloads.queue")} {t("home.downloads.queue")}
@@ -151,70 +180,74 @@ export default function page() {
</Text> </Text>
)} )}
</View> </View>
)}
<ActiveDownloads /> <ActiveDownloads />
</View>
{movies.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.movies")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{movies?.map((item) => (
<View className='mb-2 last:mb-0' key={item.item.Id}>
<MovieCard item={item.item} />
</View>
))}
</View>
</ScrollView>
</View> </View>
)}
{groupedBySeries.length > 0 && ( {movies.length > 0 && (
<View className='mb-4'> <View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-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.tvseries")} {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'>
{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'>{movies?.length}</Text>
</View>
</View> </View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{movies?.map((item) => (
<TouchableItemRouter
item={item.item}
isOffline
key={item.item.Id}
>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
</View>
</ScrollView>
</View> </View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}> )}
<View className='px-4 flex flex-row'> {groupedBySeries.length > 0 && (
{groupedBySeries?.map((items) => ( <View className='mb-4'>
<View <View className='flex flex-row items-center justify-between mb-2 px-4'>
className='mb-2 last:mb-0' <Text className='text-lg font-bold'>
key={items[0].item.SeriesId} {t("home.downloads.tvseries")}
> </Text>
<SeriesCard <View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
items={items.map((i) => i.item)} <Text className='text-xs font-bold'>
key={items[0].item.SeriesId} {groupedBySeries?.length}
/> </Text>
</View> </View>
))}
</View> </View>
</ScrollView> <ScrollView horizontal showsHorizontalScrollIndicator={false}>
</View> <View className='px-4 flex flex-row'>
)} {groupedBySeries?.map((items) => (
{downloadedFiles?.length === 0 && ( <View
<View className='flex px-4'> className='mb-2 last:mb-0'
<Text className='opacity-50'> key={items[0].item.SeriesId}
{t("home.downloads.no_downloaded_items")} >
</Text> <SeriesCard
</View> items={items.map((i) => i.item)}
)} key={items[0].item.SeriesId}
</View> />
</ScrollView> </View>
))}
</View>
</ScrollView>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className='flex px-4'>
<Text className='opacity-50'>
{t("home.downloads.no_downloaded_items")}
</Text>
</View>
)}
</View>
</ScrollView>
</View>
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enableDynamicSizing enableDynamicSizing
@@ -249,23 +282,3 @@ export default function page() {
</> </>
); );
} }
function migration_20241124() {
const router = useRouter();
const { deleteAllFiles } = useDownload();
Alert.alert(
t("home.downloads.new_app_version_requires_re_download"),
t("home.downloads.new_app_version_requires_re_download_description"),
[
{
text: t("home.downloads.back"),
onPress: () => router.back(),
},
{
text: t("home.downloads.delete"),
style: "destructive",
onPress: async () => await deleteAllFiles(),
},
],
);
}

View File

@@ -1,12 +1,12 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { storage } from "@/utils/mmkv";
import { Feather, Ionicons } from "@expo/vector-icons"; import { Feather, Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useFocusEffect, useRouter } from "expo-router"; import { useFocusEffect, useRouter } from "expo-router";
import { useCallback } from "react"; import { useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Linking, TouchableOpacity, View } from "react-native"; import { Linking, Platform, TouchableOpacity, View } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { storage } from "@/utils/mmkv";
export default function page() { export default function page() {
const router = useRouter(); const router = useRouter();
@@ -19,7 +19,9 @@ export default function page() {
); );
return ( return (
<View className='bg-neutral-900 h-full py-16 px-4 space-y-8'> <View
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 space-y-4" : "py-16 space-y-8"} px-4`}
>
<View> <View>
<Text className='text-3xl font-bold text-center mb-2'> <Text className='text-3xl font-bold text-center mb-2'>
{t("home.intro.welcome_to_streamyfin")} {t("home.intro.welcome_to_streamyfin")}
@@ -49,42 +51,50 @@ export default function page() {
</Text> </Text>
</View> </View>
</View> </View>
<View className='flex flex-row items-center mt-4'> {!Platform.isTV && (
<View <>
style={{ <View className='flex flex-row items-center mt-4'>
width: 50, <View
height: 50, style={{
}} width: 50,
className='flex items-center justify-center' height: 50,
> }}
<Ionicons name='cloud-download-outline' size={32} color='white' /> className='flex items-center justify-center'
</View> >
<View className='shrink ml-2'> <Ionicons
<Text className='font-bold mb-1'> name='cloud-download-outline'
{t("home.intro.downloads_feature_title")} size={32}
</Text> color='white'
<Text className='shrink text-xs'> />
{t("home.intro.downloads_feature_description")} </View>
</Text> <View className='shrink ml-2'>
</View> <Text className='font-bold mb-1'>
</View> {t("home.intro.downloads_feature_title")}
<View className='flex flex-row items-center mt-4'> </Text>
<View <Text className='shrink text-xs'>
style={{ {t("home.intro.downloads_feature_description")}
width: 50, </Text>
height: 50, </View>
}} </View>
className='flex items-center justify-center' <View className='flex flex-row items-center mt-4'>
> <View
<Feather name='cast' size={28} color={"white"} /> style={{
</View> width: 50,
<View className='shrink ml-2'> height: 50,
<Text className='font-bold mb-1'>Chromecast</Text> }}
<Text className='shrink text-xs'> className='flex items-center justify-center'
{t("home.intro.chromecast_feature_description")} >
</Text> <Feather name='cast' size={28} color={"white"} />
</View> </View>
</View> <View className='shrink ml-2'>
<Text className='font-bold mb-1'>Chromecast</Text>
<Text className='shrink text-xs'>
{t("home.intro.chromecast_feature_description")}
</Text>
</View>
</View>
</>
)}
<View className='flex flex-row items-center mt-4'> <View className='flex flex-row items-center mt-4'>
<View <View
style={{ style={{
@@ -99,19 +109,22 @@ export default function page() {
<Text className='font-bold mb-1'> <Text className='font-bold mb-1'>
{t("home.intro.centralised_settings_plugin_title")} {t("home.intro.centralised_settings_plugin_title")}
</Text> </Text>
<Text className='shrink text-xs'> <View className='flex-row flex-wrap items-baseline'>
{t("home.intro.centralised_settings_plugin_description")}{" "} <Text className='shrink text-xs'>
<Text {t("home.intro.centralised_settings_plugin_description")}{" "}
className='text-purple-600' </Text>
<TouchableOpacity
onPress={() => { onPress={() => {
Linking.openURL( Linking.openURL(
"https://github.com/streamyfin/jellyfin-plugin-streamyfin", "https://github.com/streamyfin/jellyfin-plugin-streamyfin",
); );
}} }}
> >
{t("home.intro.read_more")} <Text className='text-xs text-purple-600 underline'>
</Text> {t("home.intro.read_more")}
</Text> </Text>
</TouchableOpacity>
</View>
</View> </View>
</View> </View>
</View> </View>

View File

@@ -1,19 +1,4 @@
import { Badge } from "@/components/Badge"; import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import Poster from "@/components/posters/Poster";
import { useInterval } from "@/hooks/useInterval";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { apiAtom } from "@/providers/JellyfinProvider";
import { formatBitrate } from "@/utils/bitrate";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { formatTimeString } from "@/utils/time";
import {
AntDesign,
Entypo,
Ionicons,
MaterialCommunityIcons,
} from "@expo/vector-icons";
import { import {
HardwareAccelerationType, HardwareAccelerationType,
type SessionInfoDto, type SessionInfoDto,
@@ -26,10 +11,19 @@ import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { get } from "lodash"; import { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { Badge } from "@/components/Badge";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import Poster from "@/components/posters/Poster";
import { useInterval } from "@/hooks/useInterval";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { apiAtom } from "@/providers/JellyfinProvider";
import { formatBitrate } from "@/utils/bitrate";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { formatTimeString } from "@/utils/time";
export default function page() { export default function page() {
const { sessions, isLoading } = useSessions({} as useSessionsProps); const { sessions, isLoading } = useSessions({} as useSessionsProps);
@@ -440,8 +434,6 @@ const TranscodingStreamView = ({
isTranscoding, isTranscoding,
properties, properties,
transcodeProperties, transcodeProperties,
value,
transcodeValue,
}: TranscodingStreamViewProps) => { }: TranscodingStreamViewProps) => {
return ( return (
<View className='flex flex-col pt-2 first:pt-0'> <View className='flex flex-col pt-2 first:pt-0'>
@@ -454,20 +446,18 @@ const TranscodingStreamView = ({
</Text> </Text>
</View> </View>
{isTranscoding && transcodeProperties ? ( {isTranscoding && transcodeProperties ? (
<> <View className='flex flex-row'>
<View className='flex flex-row'> <Text className='-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4'>
<Text className='-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4'> <MaterialCommunityIcons
<MaterialCommunityIcons name='arrow-right-bottom'
name='arrow-right-bottom' size={14}
size={14} color='white'
color='white' />
/> </Text>
</Text> <Text className='flex-1 text-sm mt-1'>
<Text className='flex-1 text-sm mt-1'> <TranscodingBadges properties={transcodeProperties} />
<TranscodingBadges properties={transcodeProperties} /> </Text>
</Text> </View>
</View>
</>
) : null} ) : null}
</View> </View>
); );

View File

@@ -1,3 +1,9 @@
import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import { useEffect } from "react";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
@@ -14,21 +20,14 @@ import { StorageSettings } from "@/components/settings/StorageSettings";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles"; import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { UserInfo } from "@/components/settings/UserInfo"; import { UserInfo } from "@/components/settings/UserInfo";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useJellyfin } from "@/providers/JellyfinProvider"; import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { userAtom } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log"; import { clearLogs } from "@/utils/log";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import React, { useEffect } from "react";
import { ScrollView, Switch, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function settings() { export default function settings() {
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [user] = useAtom(userAtom); const [_user] = useAtom(userAtom);
const { logout } = useJellyfin(); const { logout } = useJellyfin();
const successHapticFeedback = useHaptic("success"); const successHapticFeedback = useHaptic("success");
@@ -74,13 +73,13 @@ export default function settings() {
<OtherSettings /> <OtherSettings />
<DownloadSettings /> {!Platform.isTV && <DownloadSettings />}
<PluginSettings /> <PluginSettings />
<AppLanguageSelector /> <AppLanguageSelector />
<ChromecastSettings /> {!Platform.isTV && <ChromecastSettings />}
<ListGroup title={"Intro"}> <ListGroup title={"Intro"}>
<ListItem <ListItem
@@ -113,7 +112,7 @@ export default function settings() {
</ListGroup> </ListGroup>
</View> </View>
<StorageSettings /> {!Platform.isTV && <StorageSettings />}
</View> </View>
</ScrollView> </ScrollView>
); );

View File

@@ -1,15 +1,15 @@
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api"; import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Switch, View } from "react-native"; import { Switch, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
export default function page() { export default function page() {
const [settings, updateSettings, pluginSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();

View File

@@ -3,7 +3,7 @@ import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export default function page() { export default function page() {
const [settings, updateSettings, pluginSettings] = useSettings(); const [_settings, _updateSettings, pluginSettings] = useSettings();
return ( return (
<DisabledSetting <DisabledSetting

View File

@@ -1,20 +1,23 @@
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import { FilterButton } from "@/components/filters/FilterButton";
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import * as Sharing from "expo-sharing"; import * as Sharing from "expo-sharing";
import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollView, TouchableOpacity, View } from "react-native"; import { ScrollView, TouchableOpacity, View } from "react-native";
import Collapsible from "react-native-collapsible"; import Collapsible from "react-native-collapsible";
import { Text } from "@/components/common/Text";
import { FilterButton } from "@/components/filters/FilterButton";
import { Loader } from "@/components/Loader";
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
export default function page() { export default function Page() {
const navigation = useNavigation(); const navigation = useNavigation();
const { logs } = useLog(); const { logs } = useLog();
const { t } = useTranslation(); const { t } = useTranslation();
const orderFilterId = useId();
const levelsFilterId = useId();
const defaultLevels: LogLevel[] = ["INFO", "ERROR", "DEBUG", "WARN"]; const defaultLevels: LogLevel[] = ["INFO", "ERROR", "DEBUG", "WARN"];
const codeBlockStyle = { const codeBlockStyle = {
backgroundColor: "#000", backgroundColor: "#000",
@@ -25,10 +28,12 @@ export default function page() {
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [state, setState] = useState<Record<string, boolean>>({}); const [state, setState] = useState<Record<string, boolean>>({});
const [order, setOrder] = useState<"asc" | "desc">("desc"); const [order, setOrder] = useState<"asc" | "desc">("desc");
const [levels, setLevels] = useState<LogLevel[]>(defaultLevels); const [levels, setLevels] = useState<LogLevel[]>(defaultLevels);
const _orderId = useId();
const _levelsId = useId();
const filteredLogs = useMemo( const filteredLogs = useMemo(
() => () =>
logs logs
@@ -73,7 +78,7 @@ export default function page() {
<> <>
<View className='flex flex-row justify-end py-2 px-4 space-x-2'> <View className='flex flex-row justify-end py-2 px-4 space-x-2'>
<FilterButton <FilterButton
id='order' id={orderFilterId}
queryKey='log' queryKey='log'
queryFn={async () => ["asc", "desc"]} queryFn={async () => ["asc", "desc"]}
set={(values) => setOrder(values[0])} set={(values) => setOrder(values[0])}
@@ -83,7 +88,7 @@ export default function page() {
showSearch={false} showSearch={false}
/> />
<FilterButton <FilterButton
id='levels' id={levelsFilterId}
queryKey='log' queryKey='log'
queryFn={async () => defaultLevels} queryFn={async () => defaultLevels}
set={setLevels} set={setLevels}
@@ -122,7 +127,7 @@ export default function page() {
{new Date(log.timestamp).toLocaleString()} {new Date(log.timestamp).toLocaleString()}
</Text> </Text>
</View> </View>
<Text uiTextView selectable className='text-xs'> <Text selectable className='text-xs'>
{log.message} {log.message}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>

View File

@@ -1,13 +1,7 @@
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { useSettings } from "@/utils/atoms/settings";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
import React, { useEffect, useMemo, useState } from "react";
import { import {
Linking, Linking,
Switch, Switch,
@@ -16,6 +10,11 @@ import {
View, View,
} from "react-native"; } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { useSettings } from "@/utils/atoms/settings";
export default function page() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();

View File

@@ -1,93 +0,0 @@
import { Text } from "@/components/common/Text";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device";
import { getStatistics } from "@/utils/optimize-server";
import { useMutation } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native";
export default function page() {
const navigation = useNavigation();
const { t } = useTranslation();
const [api] = useAtom(apiAtom);
const [settings, updateSettings, pluginSettings] = useSettings();
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
useState<string>(settings?.optimizedVersionsServerUrl || "");
const saveMutation = useMutation({
mutationFn: async (newVal: string) => {
if (newVal.length === 0 || !newVal.startsWith("http")) {
toast.error(t("home.settings.toasts.invalid_url"));
return;
}
const updatedUrl = newVal.endsWith("/") ? newVal : `${newVal}/`;
updateSettings({
optimizedVersionsServerUrl: updatedUrl,
});
return await getStatistics({
url: updatedUrl,
authHeader: api?.accessToken,
deviceId: getOrSetDeviceId(),
});
},
onSuccess: (data) => {
if (data) {
toast.success(t("home.settings.toasts.connected"));
} else {
toast.error(t("home.settings.toasts.could_not_connect"));
}
},
onError: () => {
toast.error(t("home.settings.toasts.could_not_connect"));
},
});
const onSave = (newVal: string) => {
saveMutation.mutate(newVal);
};
useEffect(() => {
if (!pluginSettings?.optimizedVersionsServerUrl?.locked) {
navigation.setOptions({
title: t("home.settings.downloads.optimized_server"),
headerRight: () =>
saveMutation.isPending ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<TouchableOpacity
onPress={() => onSave(optimizedVersionsServerUrl)}
>
<Text className='text-blue-500'>
{t("home.settings.downloads.save_button")}
</Text>
</TouchableOpacity>
),
});
}
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
return (
<DisabledSetting
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
className='p-4'
>
<OptimizedServerForm
value={optimizedVersionsServerUrl}
onChangeValue={setOptimizedVersionsServerUrl}
/>
</DisabledSetting>
);
}

View File

@@ -1,15 +1,3 @@
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import type { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDtoQueryResult } 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";
@@ -19,6 +7,18 @@ import { useAtom } from "jotai";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
const page: React.FC = () => { const page: React.FC = () => {
const local = useLocalSearchParams(); const local = useLocalSearchParams();

View File

@@ -1,22 +1,3 @@
import { ItemCardText } from "@/components/ItemCardText";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemPoster } from "@/components/posters/ItemPoster";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
SortByOption,
SortOrderOption,
genreFilterAtom,
sortByAtom,
sortOptions,
sortOrderAtom,
sortOrderOptions,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import type { import type {
BaseItemDto, BaseItemDto,
BaseItemDtoQueryResult, BaseItemDtoQueryResult,
@@ -35,6 +16,25 @@ import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FlatList, View } from "react-native"; import { FlatList, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { ItemPoster } from "@/components/posters/ItemPoster";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
genreFilterAtom,
SortByOption,
SortOrderOption,
sortByAtom,
sortOptions,
sortOrderAtom,
sortOrderOptions,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
const page: React.FC = () => { const page: React.FC = () => {
const searchParams = useLocalSearchParams(); const searchParams = useLocalSearchParams();
@@ -43,7 +43,7 @@ const page: React.FC = () => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const navigation = useNavigation(); const navigation = useNavigation();
const [orientation, setOrientation] = useState( const [orientation, _setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP, ScreenOrientation.Orientation.PORTRAIT_UP,
); );
@@ -112,7 +112,7 @@ const page: React.FC = () => {
recursive: true, recursive: true,
genres: selectedGenres, genres: selectedGenres,
tags: selectedTags, tags: selectedTags,
years: selectedYears.map((year) => Number.parseInt(year)), years: selectedYears.map((year) => Number.parseInt(year, 10)),
includeItemTypes: ["Movie", "Series"], includeItemTypes: ["Movie", "Series"],
}); });

View File

@@ -1,10 +1,4 @@
import { ItemContent } from "@/components/ItemContent";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import type React from "react"; import type React from "react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -15,29 +9,18 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Text } from "@/components/common/Text";
import { ItemContent } from "@/components/ItemContent";
import { useItemQuery } from "@/hooks/useItemQuery";
const Page: React.FC = () => { const Page: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { id } = useLocalSearchParams() as { id: string }; const { id } = useLocalSearchParams() as { id: string };
const { t } = useTranslation(); const { t } = useTranslation();
const { data: item, isError } = useQuery({ const { offline } = useLocalSearchParams() as { offline?: string };
queryKey: ["item", id], const isOffline = offline === "true";
queryFn: async () => {
if (!api || !user || !id) return;
const res = await getUserLibraryApi(api).getItem({
itemId: id,
userId: user?.Id,
});
return res.data; const { data: item, isError } = useItemQuery(id, isOffline);
},
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
});
const opacity = useSharedValue(1); const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => { const animatedStyle = useAnimatedStyle(() => {
@@ -107,7 +90,7 @@ const Page: React.FC = () => {
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' /> <View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' /> <View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
</Animated.View> </Animated.View>
{item && <ItemContent item={item} />} {item && <ItemContent item={item} isOffline={isOffline} />}
</View> </View>
); );
}; };

View File

@@ -1,24 +1,23 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { uniqBy } from "lodash";
import { useMemo } from "react";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow"; import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster"; import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
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 { import {
type MovieResult, type MovieResult,
Results,
type TvResult, type TvResult,
} from "@/utils/jellyseerr/server/models/Search"; } from "@/utils/jellyseerr/server/models/Search";
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider"; import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { uniqBy } from "lodash";
import React, { useMemo } from "react";
export default function page() { export default function page() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const { companyId, name, image, type } = local as unknown as { const { companyId, image, type } = local as unknown as {
companyId: string; companyId: string;
name: string; name: string;
image: string; image: string;
@@ -99,7 +98,7 @@ export default function page() {
}} }}
/> />
} }
renderItem={(item, index) => ( renderItem={(item, _index) => (
<JellyseerrPoster item={item as MovieResult | TvResult} /> <JellyseerrPoster item={item as MovieResult | TvResult} />
)} )}
/> />

View File

@@ -1,21 +1,17 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { uniqBy } from "lodash";
import { useMemo } from "react";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard"; import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster"; import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import Poster from "@/components/posters/Poster";
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 { import {
type MovieResult, type MovieResult,
Results,
type TvResult, type TvResult,
} from "@/utils/jellyseerr/server/models/Search"; } from "@/utils/jellyseerr/server/models/Search";
import { useInfiniteQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams, useSegments } from "expo-router";
import { uniqBy } from "lodash";
import React, { useMemo } from "react";
import { TouchableOpacity } from "react-native";
export default function page() { export default function page() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
@@ -96,7 +92,7 @@ export default function page() {
{name} {name}
</Text> </Text>
} }
renderItem={(item, index) => ( renderItem={(item, _index) => (
<JellyseerrPoster item={item as MovieResult | TvResult} /> <JellyseerrPoster item={item as MovieResult | TvResult} />
)} )}
/> />

View File

@@ -1,25 +1,3 @@
import { Button } from "@/components/Button";
import { GenreTags } from "@/components/GenreTags";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { JellyserrRatings } from "@/components/Ratings";
import { Text } from "@/components/common/Text";
import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {
type IssueType,
IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { import {
BottomSheetBackdrop, BottomSheetBackdrop,
@@ -36,7 +14,31 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
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 { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { JellyserrRatings } from "@/components/Ratings";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {
type IssueType,
IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import RequestModal from "@/components/jellyseerr/RequestModal"; import RequestModal from "@/components/jellyseerr/RequestModal";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants"; import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
@@ -219,11 +221,7 @@ const Page: React.FC = () => {
| TvDetails | TvDetails
} }
/> />
<Text <Text selectable className='font-bold text-2xl mb-1'>
uiTextView
selectable
className='font-bold text-2xl mb-1'
>
{mediaTitle} {mediaTitle}
</Text> </Text>
<Text className='opacity-50'>{releaseYear}</Text> <Text className='opacity-50'>{releaseYear}</Text>
@@ -254,26 +252,28 @@ const Page: React.FC = () => {
) : ( ) : (
details?.mediaInfo?.jellyfinMediaId && ( details?.mediaInfo?.jellyfinMediaId && (
<View className='flex flex-row space-x-2 mt-4'> <View className='flex flex-row space-x-2 mt-4'>
<Button {!Platform.isTV && (
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100' <Button
color='transparent' className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
onPress={() => bottomSheetModalRef?.current?.present()} color='transparent'
iconLeft={ onPress={() => bottomSheetModalRef?.current?.present()}
<Ionicons iconLeft={
name='warning-outline' <Ionicons
size={20} name='warning-outline'
color='white' size={20}
/> color='white'
} />
style={{ }
borderWidth: 1, style={{
borderStyle: "solid", borderWidth: 1,
}} borderStyle: "solid",
> }}
<Text className='text-sm'> >
{t("jellyseerr.report_issue_button")} <Text className='text-sm'>
</Text> {t("jellyseerr.report_issue_button")}
</Button> </Text>
</Button>
)}
<Button <Button
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100' className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
onPress={() => { onPress={() => {
@@ -331,92 +331,95 @@ const Page: React.FC = () => {
}} }}
onDismiss={() => _setRequestBody(undefined)} onDismiss={() => _setRequestBody(undefined)}
/> />
<BottomSheetModal {!Platform.isTV && (
ref={bottomSheetModalRef} // This is till it's fixed because the menu isn't selectable on TV
enableDynamicSizing <BottomSheetModal
handleIndicatorStyle={{ ref={bottomSheetModalRef}
backgroundColor: "white", enableDynamicSizing
}} handleIndicatorStyle={{
backgroundStyle={{ backgroundColor: "white",
backgroundColor: "#171717", }}
}} backgroundStyle={{
backdropComponent={renderBackdrop} backgroundColor: "#171717",
> }}
<BottomSheetView> backdropComponent={renderBackdrop}
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'> >
<View> <BottomSheetView>
<Text className='font-bold text-2xl text-neutral-100'> <View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
{t("jellyseerr.whats_wrong")} <View>
</Text> <Text className='font-bold text-2xl text-neutral-100'>
</View> {t("jellyseerr.whats_wrong")}
<View className='flex flex-col space-y-2 items-start'> </Text>
<View className='flex flex-col'> </View>
<DropdownMenu.Root> <View className='flex flex-col space-y-2 items-start'>
<DropdownMenu.Trigger> <View className='flex flex-col'>
<View className='flex flex-col'> <DropdownMenu.Root>
<Text className='opacity-50 mb-1 text-xs'> <DropdownMenu.Trigger>
{t("jellyseerr.issue_type")} <View className='flex flex-col'>
</Text> <Text className='opacity-50 mb-1 text-xs'>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'> {t("jellyseerr.issue_type")}
<Text style={{}} className='' numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
</Text> </Text>
</TouchableOpacity> <TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
</View> <Text style={{}} className='' numberOfLines={1}>
</DropdownMenu.Trigger> {issueType
<DropdownMenu.Content ? IssueTypeName[issueType]
loop={false} : t("jellyseerr.select_an_issue")}
side='bottom' </Text>
align='center' </TouchableOpacity>
alignOffset={0} </View>
avoidCollisions={true} </DropdownMenu.Trigger>
collisionPadding={0} <DropdownMenu.Content
sideOffset={0} loop={false}
> side='bottom'
<DropdownMenu.Label> align='center'
{t("jellyseerr.types")} alignOffset={0}
</DropdownMenu.Label> avoidCollisions={true}
{Object.entries(IssueTypeName) collisionPadding={0}
.reverse() sideOffset={0}
.map(([key, value], idx) => ( >
<DropdownMenu.Item <DropdownMenu.Label>
key={value} {t("jellyseerr.types")}
onSelect={() => </DropdownMenu.Label>
setIssueType(key as unknown as IssueType) {Object.entries(IssueTypeName)
} .reverse()
> .map(([key, value], _idx) => (
<DropdownMenu.ItemTitle> <DropdownMenu.Item
{value} key={value}
</DropdownMenu.ItemTitle> onSelect={() =>
</DropdownMenu.Item> setIssueType(key as unknown as IssueType)
))} }
</DropdownMenu.Content> >
</DropdownMenu.Root> <DropdownMenu.ItemTitle>
</View> {value}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'> <View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
<BottomSheetTextInput <BottomSheetTextInput
multiline multiline
maxLength={254} maxLength={254}
style={{ color: "white" }} style={{ color: "white" }}
clearButtonMode='always' clearButtonMode='always'
placeholder={t("jellyseerr.describe_the_issue")} placeholder={t("jellyseerr.describe_the_issue")}
placeholderTextColor='#9CA3AF' placeholderTextColor='#9CA3AF'
// Issue with multiline + Textinput inside a portal // Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668 // https://github.com/callstack/react-native-paper/issues/1668
defaultValue={issueMessage} defaultValue={issueMessage}
onChangeText={setIssueMessage} onChangeText={setIssueMessage}
/> />
</View>
</View> </View>
<Button className='mt-auto' onPress={submitIssue} color='purple'>
{t("jellyseerr.submit_button")}
</Button>
</View> </View>
<Button className='mt-auto' onPress={submitIssue} color='purple'> </BottomSheetView>
{t("jellyseerr.submit_button")} </BottomSheetModal>
</Button> )}
</View>
</BottomSheetView>
</BottomSheetModal>
</View> </View>
); );
}; };

View File

@@ -1,6 +1,12 @@
import { OverviewText } from "@/components/OverviewText"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { orderBy, uniqBy } from "lodash";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow"; import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import { OverviewText } from "@/components/OverviewText";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster"; import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person"; import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
@@ -8,12 +14,6 @@ import type {
MovieResult, MovieResult,
TvResult, TvResult,
} from "@/utils/jellyseerr/server/models/Search"; } from "@/utils/jellyseerr/server/models/Search";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useSegments } from "expo-router";
import { orderBy, uniqBy } from "lodash";
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
export default function page() { export default function page() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
@@ -21,14 +21,13 @@ export default function page() {
const { const {
jellyseerrApi, jellyseerrApi,
jellyseerrUser,
jellyseerrRegion: region, jellyseerrRegion: region,
jellyseerrLocale: locale, jellyseerrLocale: locale,
} = useJellyseerr(); } = useJellyseerr();
const { personId } = local as { personId: string }; const { personId } = local as { personId: string };
const { data, isLoading, isFetching } = useQuery({ const { data } = useQuery({
queryKey: ["jellyseerr", "person", personId], queryKey: ["jellyseerr", "person", personId],
queryFn: async () => ({ queryFn: async () => ({
details: await jellyseerrApi?.personDetails(personId), details: await jellyseerrApi?.personDetails(personId),
@@ -107,7 +106,7 @@ export default function page() {
MainContent={() => ( MainContent={() => (
<OverviewText text={data?.details?.biography} className='mt-4' /> <OverviewText text={data?.details?.biography} className='mt-4' />
)} )}
renderItem={(item, index) => ( renderItem={(item, _index) => (
<JellyseerrPoster item={item as MovieResult | TvResult} /> <JellyseerrPoster item={item as MovieResult | TvResult} />
)} )}
/> />

View File

@@ -1,14 +1,13 @@
import type { import {
createMaterialTopTabNavigator,
MaterialTopTabNavigationEventMap, MaterialTopTabNavigationEventMap,
MaterialTopTabNavigationOptions, MaterialTopTabNavigationOptions,
} from "@react-navigation/material-top-tabs"; } from "@react-navigation/material-top-tabs";
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
import type { import type {
ParamListBase, ParamListBase,
TabNavigationState, TabNavigationState,
} from "@react-navigation/native"; } from "@react-navigation/native";
import { Stack, withLayoutContext } from "expo-router"; import { Stack, withLayoutContext } from "expo-router";
import React from "react";
const { Navigator } = createMaterialTopTabNavigator(); const { Navigator } = createMaterialTopTabNavigator();

View File

@@ -1,18 +1,17 @@
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React from "react";
import { View } from "react-native"; import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
export default function page() { export default function page() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets(); const _insets = useSafeAreaInsets();
const { data: channels } = useQuery({ const { data: channels } = useQuery({
queryKey: ["livetv", "channels"], queryKey: ["livetv", "channels"],

View File

@@ -1,23 +1,16 @@
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { HourHeader } from "@/components/livetv/HourHeader";
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
import { TAB_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useMemo, useState } from "react"; import React, { useCallback, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { Dimensions, ScrollView, TouchableOpacity, View } from "react-native";
Button,
Dimensions,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { HourHeader } from "@/components/livetv/HourHeader";
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
const HOUR_HEIGHT = 30; const HOUR_HEIGHT = 30;
const ITEMS_PER_PAGE = 20; const ITEMS_PER_PAGE = 20;
@@ -28,17 +21,9 @@ export default function page() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [date, setDate] = useState<Date>(new Date()); const [date, _setDate] = useState<Date>(new Date());
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const { data: guideInfo } = useQuery({
queryKey: ["livetv", "guideInfo"],
queryFn: async () => {
const res = await getLiveTvApi(api!).getGuideInfo();
return res.data;
},
});
const { data: channels } = useQuery({ const { data: channels } = useQuery({
queryKey: ["livetv", "channels", currentPage], queryKey: ["livetv", "channels", currentPage],
queryFn: async () => { queryFn: async () => {
@@ -150,7 +135,7 @@ export default function page() {
> >
<View className='flex flex-col'> <View className='flex flex-col'>
<HourHeader height={HOUR_HEIGHT} /> <HourHeader height={HOUR_HEIGHT} />
{channels?.Items?.map((c, i) => ( {channels?.Items?.map((c, _i) => (
<MemoizedLiveTVGuideRow <MemoizedLiveTVGuideRow
channel={c} channel={c}
programs={programs?.Items} programs={programs?.Items}

View File

@@ -1,13 +1,11 @@
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { TAB_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native"; import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
export default function page() { export default function page() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);

View File

@@ -1,7 +1,6 @@
import { Text } from "@/components/common/Text";
import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import { Text } from "@/components/common/Text";
export default function page() { export default function page() {
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -1,13 +1,3 @@
import { AddToFavorites } from "@/components/AddToFavorites";
import { DownloadItems } from "@/components/DownloadItem";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { NextUp } from "@/components/series/NextUp";
import { SeasonPicker } from "@/components/series/SeasonPicker";
import { SeriesHeader } from "@/components/series/SeriesHeader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
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";
@@ -18,6 +8,16 @@ import type React from "react";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { AddToFavorites } from "@/components/AddToFavorites";
import { DownloadItems } from "@/components/DownloadItem";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { NextUp } from "@/components/series/NextUp";
import { SeasonPicker } from "@/components/series/SeasonPicker";
import { SeriesHeader } from "@/components/series/SeriesHeader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
const page: React.FC = () => { const page: React.FC = () => {
const navigation = useNavigation(); const navigation = useNavigation();
@@ -69,10 +69,18 @@ const page: React.FC = () => {
seriesId: item?.Id!, seriesId: item?.Id!,
userId: user?.Id!, userId: user?.Id!,
enableUserData: true, enableUserData: true,
fields: ["MediaSources", "MediaStreams", "Overview"], // Note: Including trick play is necessary to enable trick play downloads
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
}); });
return res?.data.Items || []; return res?.data.Items || [];
}, },
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(
(a, b) =>
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
),
staleTime: 60, staleTime: 60,
enabled: !!api && !!user?.Id && !!item?.Id, enabled: !!api && !!user?.Id && !!item?.Id,
}); });
@@ -136,7 +144,7 @@ const page: React.FC = () => {
resizeMode: "contain", resizeMode: "contain",
}} }}
/> />
) : null ) : undefined
} }
> >
<View className='flex flex-col pt-4'> <View className='flex flex-col pt-4'>

View File

@@ -1,34 +1,3 @@
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react";
import { FlatList, View, useWindowDimensions } from "react-native";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemPoster } from "@/components/posters/ItemPoster";
import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
SortByOption,
SortOrderOption,
genreFilterAtom,
getSortByPreference,
getSortOrderPreference,
sortByAtom,
sortByPreferenceAtom,
sortOptions,
sortOrderAtom,
sortOrderOptions,
sortOrderPreferenceAtom,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import type { import type {
BaseItemDto, BaseItemDto,
BaseItemDtoQueryResult, BaseItemDtoQueryResult,
@@ -40,8 +9,38 @@ import {
getUserLibraryApi, getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FlatList, useWindowDimensions, 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 { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
genreFilterAtom,
getSortByPreference,
getSortOrderPreference,
SortByOption,
SortOrderOption,
sortByAtom,
sortByPreferenceAtom,
sortOptions,
sortOrderAtom,
sortOrderOptions,
sortOrderPreferenceAtom,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
const Page = () => { const Page = () => {
const searchParams = useLocalSearchParams(); const searchParams = useLocalSearchParams();
@@ -169,7 +168,7 @@ const Page = () => {
fields: ["PrimaryImageAspectRatio", "SortName"], fields: ["PrimaryImageAspectRatio", "SortName"],
genres: selectedGenres, genres: selectedGenres,
tags: selectedTags, tags: selectedTags,
years: selectedYears.map((year) => Number.parseInt(year)), years: selectedYears.map((year) => Number.parseInt(year, 10)),
includeItemTypes: itemType ? [itemType] : undefined, includeItemTypes: itemType ? [itemType] : undefined,
}); });
@@ -367,15 +366,7 @@ const Page = () => {
className='mr-1' className='mr-1'
id={libraryId} id={libraryId}
queryKey='sortBy' queryKey='sortBy'
queryFn={async () => queryFn={async () => sortOptions.map((s) => s.key)}
sortOptions
.filter(
(s) =>
library?.CollectionType !== "movies" ||
s.key !== SortByOption.DateLastContentAdded,
)
.map((s) => s.key)
}
set={setSortBy} set={setSortBy}
values={sortBy} values={sortBy}
title={t("library.filters.sort_by")} title={t("library.filters.sort_by")}

View File

@@ -1,9 +1,11 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useSettings } from "@/utils/atoms/settings";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export default function IndexLayout() { export default function IndexLayout() {
@@ -18,7 +20,7 @@ export default function IndexLayout() {
<Stack.Screen <Stack.Screen
name='index' name='index'
options={{ options={{
headerShown: true, headerShown: !Platform.isTV,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: t("tabs.library"), headerTitle: t("tabs.library"),
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
@@ -198,7 +200,7 @@ export default function IndexLayout() {
name='[libraryId]' name='[libraryId]'
options={{ options={{
title: "", title: "",
headerShown: true, headerShown: !Platform.isTV,
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -211,7 +213,7 @@ export default function IndexLayout() {
name='collections/[collectionId]' name='collections/[collectionId]'
options={{ options={{
title: "", title: "",
headerShown: true, headerShown: !Platform.isTV,
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,

View File

@@ -1,8 +1,3 @@
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { import {
getUserLibraryApi, getUserLibraryApi,
getUserViewsApi, getUserViewsApi,
@@ -14,6 +9,11 @@ import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { StyleSheet, View } from "react-native"; import { StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
export default function index() { export default function index() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);

View File

@@ -1,10 +1,10 @@
import { Stack } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { import {
commonScreenOptions, commonScreenOptions,
nestedTabPageScreenOptions, nestedTabPageScreenOptions,
} from "@/components/stacks/NestedTabPageStack"; } from "@/components/stacks/NestedTabPageStack";
import { Stack } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
export default function SearchLayout() { export default function SearchLayout() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -13,7 +13,7 @@ export default function SearchLayout() {
<Stack.Screen <Stack.Screen
name='index' name='index'
options={{ options={{
headerShown: true, headerShown: !Platform.isTV,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: t("tabs.search"), headerTitle: t("tabs.search"),
headerLargeStyle: { headerLargeStyle: {
@@ -31,7 +31,7 @@ export default function SearchLayout() {
name='collections/[collectionId]' name='collections/[collectionId]'
options={{ options={{
title: "", title: "",
headerShown: true, headerShown: !Platform.isTV,
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,

View File

@@ -1,9 +1,32 @@
import type {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Tag } from "@/components/GenreTags"; import { Input } from "@/components/common/Input";
import { ItemCardText } from "@/components/ItemCardText";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton"; import { FilterButton } from "@/components/filters/FilterButton";
import { Tag } from "@/components/GenreTags";
import { ItemCardText } from "@/components/ItemCardText";
import { import {
JellyseerrSearchSort, JellyseerrSearchSort,
JellyserrIndexPage, JellyserrIndexPage,
@@ -16,27 +39,6 @@ import { useJellyseerr } from "@/hooks/useJellyseerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
import type {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce";
type SearchType = "Library" | "Discover"; type SearchType = "Library" | "Discover";
@@ -57,6 +59,9 @@ export default function search() {
const { t } = useTranslation(); const { t } = useTranslation();
const searchFilterId = useId();
const orderFilterId = useId();
const { q } = params as { q: string }; const { q } = params as { q: string };
const [searchType, setSearchType] = useState<SearchType>("Library"); const [searchType, setSearchType] = useState<SearchType>("Library");
@@ -249,205 +254,223 @@ export default function search() {
}, [l1, l2, l3, l7, l8]); }, [l1, l2, l3, l7, l8]);
return ( return (
<> <ScrollView
<ScrollView keyboardDismissMode='on-drag'
keyboardDismissMode='on-drag' contentInsetAdjustmentBehavior='automatic'
contentInsetAdjustmentBehavior='automatic' contentContainerStyle={{
contentContainerStyle={{ paddingLeft: insets.left,
paddingLeft: insets.left, paddingRight: insets.right,
paddingRight: insets.right, }}
>
{/* <View
className='flex flex-col'
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}}
> */}
{Platform.isTV && (
<Input
placeholder={t("search.search")}
onChangeText={(text) => {
router.setParams({ q: "" });
setSearch(text);
}}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
clearButtonMode='while-editing'
maxLength={500}
/>
)}
<View
className='flex flex-col'
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}} }}
> >
<View {jellyseerrApi && (
className='flex flex-col' <ScrollView
style={{ horizontal
marginTop: Platform.OS === "android" ? 16 : 0, className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
}} >
> <TouchableOpacity onPress={() => setSearchType("Library")}>
{jellyseerrApi && ( <Tag
<ScrollView text={t("search.library")}
horizontal textClass='p-1'
className='flex flex-row flex-wrap space-x-2 px-4 mb-2' className={
> searchType === "Library" ? "bg-purple-600" : undefined
<TouchableOpacity onPress={() => setSearchType("Library")}> }
<Tag />
text={t("search.library")} </TouchableOpacity>
textClass='p-1' <TouchableOpacity onPress={() => setSearchType("Discover")}>
className={ <Tag
searchType === "Library" ? "bg-purple-600" : undefined text={t("search.discover")}
} textClass='p-1'
/> className={
</TouchableOpacity> searchType === "Discover" ? "bg-purple-600" : undefined
<TouchableOpacity onPress={() => setSearchType("Discover")}> }
<Tag />
text={t("search.discover")} </TouchableOpacity>
textClass='p-1' {searchType === "Discover" &&
className={ !loading &&
searchType === "Discover" ? "bg-purple-600" : undefined noResults &&
} debouncedSearch.length > 0 && (
/> <View className='flex flex-row justify-end items-center space-x-1'>
</TouchableOpacity> <FilterButton
{searchType === "Discover" && id={searchFilterId}
!loading && queryKey='jellyseerr_search'
noResults && queryFn={async () =>
debouncedSearch.length > 0 && ( Object.keys(JellyseerrSearchSort).filter((v) =>
<View className='flex flex-row justify-end items-center space-x-1'> Number.isNaN(Number(v)),
<FilterButton )
id='search' }
queryKey='jellyseerr_search' set={(value) => setJellyseerrOrderBy(value[0])}
queryFn={async () => values={[jellyseerrOrderBy]}
Object.keys(JellyseerrSearchSort).filter((v) => title={t("library.filters.sort_by")}
Number.isNaN(Number(v)), renderItemLabel={(item) =>
) t(`home.settings.plugins.jellyseerr.order_by.${item}`)
} }
set={(value) => setJellyseerrOrderBy(value[0])} showSearch={false}
values={[jellyseerrOrderBy]} />
title={t("library.filters.sort_by")} <FilterButton
renderItemLabel={(item) => id={orderFilterId}
t(`home.settings.plugins.jellyseerr.order_by.${item}`) queryKey='jellysearr_search'
} queryFn={async () => ["asc", "desc"]}
showSearch={false} set={(value) => setJellyseerrSortOrder(value[0])}
/> values={[jellyseerrSortOrder]}
<FilterButton title={t("library.filters.sort_order")}
id='order' renderItemLabel={(item) => t(`library.filters.${item}`)}
queryKey='jellysearr_search' showSearch={false}
queryFn={async () => ["asc", "desc"]} />
set={(value) => setJellyseerrSortOrder(value[0])} </View>
values={[jellyseerrSortOrder]} )}
title={t("library.filters.sort_order")} </ScrollView>
renderItemLabel={(item) => t(`library.filters.${item}`)} )}
showSearch={false}
/>
</View>
)}
</ScrollView>
)}
<View className='mt-2'> <View className='mt-2'>
<LoadingSkeleton isLoading={loading} /> <LoadingSkeleton isLoading={loading} />
</View>
{searchType === "Library" ? (
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
<SearchItemWrapper
header={t("search.movies")}
items={movies}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
className='flex flex-col w-28 mr-2'
item={item}
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className='opacity-50 text-xs'>
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={series}
header={t("search.series")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
item={item}
className='flex flex-col w-28 mr-2'
>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className='opacity-50 text-xs'>
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={episodes}
header={t("search.episodes")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-44 mr-2'
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={collections}
header={t("search.collections")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
item={item}
className='flex flex-col w-28 mr-2'
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={actors}
header={t("search.actors")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-28 mr-2'
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
</View>
) : (
<JellyserrIndexPage
searchQuery={debouncedSearch}
sortType={jellyseerrOrderBy}
order={jellyseerrSortOrder}
/>
)}
{searchType === "Library" &&
(!loading && noResults && debouncedSearch.length > 0 ? (
<View>
<Text className='text-center text-lg font-bold mt-4'>
{t("search.no_results_found_for")}
</Text>
<Text className='text-xs text-purple-600 text-center'>
"{debouncedSearch}"
</Text>
</View>
) : debouncedSearch.length === 0 ? (
<View className='mt-4 flex flex-col items-center space-y-2'>
{exampleSearches.map((e) => (
<TouchableOpacity
onPress={() => {
setSearch(e);
searchBarRef.current?.setText(e);
}}
key={e}
className='mb-2'
>
<Text className='text-purple-600'>{e}</Text>
</TouchableOpacity>
))}
</View>
) : null)}
</View> </View>
</ScrollView>
</> {searchType === "Library" ? (
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
<SearchItemWrapper
header={t("search.movies")}
items={movies}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
className='flex flex-col w-28 mr-2'
item={item}
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className='opacity-50 text-xs'>
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={series}
header={t("search.series")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
item={item}
className='flex flex-col w-28 mr-2'
>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className='opacity-50 text-xs'>
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={episodes}
header={t("search.episodes")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-44 mr-2'
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={collections}
header={t("search.collections")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
item={item}
className='flex flex-col w-28 mr-2'
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={actors}
header={t("search.actors")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-28 mr-2'
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
</View>
) : (
<JellyserrIndexPage
searchQuery={debouncedSearch}
sortType={jellyseerrOrderBy}
order={jellyseerrSortOrder}
/>
)}
{searchType === "Library" &&
(!loading && noResults && debouncedSearch.length > 0 ? (
<View>
<Text className='text-center text-lg font-bold mt-4'>
{t("search.no_results_found_for")}
</Text>
<Text className='text-xs text-purple-600 text-center'>
"{debouncedSearch}"
</Text>
</View>
) : debouncedSearch.length === 0 ? (
<View className='mt-4 flex flex-col items-center space-y-2'>
{exampleSearches.map((e) => (
<TouchableOpacity
onPress={() => {
setSearch(e);
searchBarRef.current?.setText(e);
}}
key={e}
className='mb-2'
>
<Text className='text-purple-600'>{e}</Text>
</TouchableOpacity>
))}
</View>
) : null)}
</View>
</ScrollView>
); );
} }

View File

@@ -1,29 +1,26 @@
import React, { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
import { import {
type NativeBottomTabNavigationEventMap,
createNativeBottomTabNavigator, createNativeBottomTabNavigator,
type NativeBottomTabNavigationEventMap,
type NativeBottomTabNavigationOptions,
} from "@bottom-tabs/react-navigation"; } from "@bottom-tabs/react-navigation";
const { Navigator } = createNativeBottomTabNavigator();
import type { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
import { Colors } from "@/constants/Colors";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
import { storage } from "@/utils/mmkv";
import type { import type {
ParamListBase, ParamListBase,
TabNavigationState, TabNavigationState,
} from "@react-navigation/native"; } from "@react-navigation/native";
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { Colors } from "@/constants/Colors";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
import { storage } from "@/utils/mmkv";
const { Navigator } = createNativeBottomTabNavigator();
export const NativeTabs = withLayoutContext< export const NativeTabs = withLayoutContext<
BottomTabNavigationOptions, NativeBottomTabNavigationOptions,
typeof Navigator, typeof Navigator,
TabNavigationState<ParamListBase>, TabNavigationState<ParamListBase>,
NativeBottomTabNavigationEventMap NativeBottomTabNavigationEventMap
@@ -54,7 +51,6 @@ export default function TabLayout() {
<SystemBars hidden={false} style='light' /> <SystemBars hidden={false} style='light' />
<NativeTabs <NativeTabs
sidebarAdaptable={false} sidebarAdaptable={false}
ignoresTopSafeArea
tabBarStyle={{ tabBarStyle={{
backgroundColor: "#121212", backgroundColor: "#121212",
}} }}
@@ -63,8 +59,8 @@ export default function TabLayout() {
> >
<NativeTabs.Screen redirect name='index' /> <NativeTabs.Screen redirect name='index' />
<NativeTabs.Screen <NativeTabs.Screen
listeners={({ navigation }) => ({ listeners={(_e) => ({
tabPress: (e) => { tabPress: (_e) => {
eventBus.emit("scrollToTop"); eventBus.emit("scrollToTop");
}, },
})} })}
@@ -73,8 +69,7 @@ export default function TabLayout() {
title: t("tabs.home"), title: t("tabs.home"),
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? ({ color, focused, size }) => ? (_e) => require("@/assets/icons/house.fill.png")
require("@/assets/icons/house.fill.png")
: ({ focused }) => : ({ focused }) =>
focused focused
? { sfSymbol: "house.fill" } ? { sfSymbol: "house.fill" }
@@ -82,8 +77,8 @@ export default function TabLayout() {
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
listeners={({ navigation }) => ({ listeners={(_e) => ({
tabPress: (e) => { tabPress: (_e) => {
eventBus.emit("searchTabPressed"); eventBus.emit("searchTabPressed");
}, },
})} })}
@@ -92,8 +87,7 @@ export default function TabLayout() {
title: t("tabs.search"), title: t("tabs.search"),
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? ({ color, focused, size }) => ? (_e) => require("@/assets/icons/magnifyingglass.png")
require("@/assets/icons/magnifyingglass.png")
: ({ focused }) => : ({ focused }) =>
focused focused
? { sfSymbol: "magnifyingglass" } ? { sfSymbol: "magnifyingglass" }
@@ -106,7 +100,7 @@ export default function TabLayout() {
title: t("tabs.favorites"), title: t("tabs.favorites"),
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? ({ color, focused, size }) => ? ({ focused }) =>
focused focused
? require("@/assets/icons/heart.fill.png") ? require("@/assets/icons/heart.fill.png")
: require("@/assets/icons/heart.png") : require("@/assets/icons/heart.png")
@@ -122,8 +116,7 @@ export default function TabLayout() {
title: t("tabs.library"), title: t("tabs.library"),
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? ({ color, focused, size }) => ? (_e) => require("@/assets/icons/server.rack.png")
require("@/assets/icons/server.rack.png")
: ({ focused }) => : ({ focused }) =>
focused focused
? { sfSymbol: "rectangle.stack.fill" } ? { sfSymbol: "rectangle.stack.fill" }
@@ -134,11 +127,10 @@ export default function TabLayout() {
name='(custom-links)' name='(custom-links)'
options={{ options={{
title: t("tabs.custom_links"), title: t("tabs.custom_links"),
// @ts-expect-error
tabBarItemHidden: !settings?.showCustomMenuLinks, tabBarItemHidden: !settings?.showCustomMenuLinks,
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? ({ focused }) => require("@/assets/icons/list.png") ? (_e) => require("@/assets/icons/list.png")
: ({ focused }) => : ({ focused }) =>
focused focused
? { sfSymbol: "list.dash.fill" } ? { sfSymbol: "list.dash.fill" }

View File

@@ -1,33 +1,7 @@
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useSettings } from "@/utils/atoms/settings";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import React, { useLayoutEffect } from "react";
import { Platform } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
export default function Layout() { export default function Layout() {
const [settings] = useSettings();
useLayoutEffect(() => {
if (Platform.isTV) return;
if (!settings.followDeviceOrientation && settings.defaultVideoOrientation) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
}
return () => {
if (Platform.isTV) return;
if (settings.followDeviceOrientation === true) {
ScreenOrientation.unlockAsync();
} else {
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
}
};
});
return ( return (
<> <>
<SystemBars hidden /> <SystemBars hidden />

View File

@@ -1,9 +1,28 @@
import {
type BaseItemDto,
type MediaSourceInfo,
PlaybackOrder,
PlaybackStartInfo,
RepeatMode,
} from "@jellyfin/sdk/lib/generated-client";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { router, useGlobalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform, View } from "react-native";
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
import { BITRATES } from "@/components/BitrateSelector"; import { BITRATES } from "@/components/BitrateSelector";
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls"; import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets"; import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules"; import { VlcPlayerView } from "@/modules";
@@ -13,41 +32,14 @@ import type {
ProgressUpdatePayload, ProgressUpdatePayload,
VlcPlayerViewRef, VlcPlayerViewRef,
} from "@/modules/VlcPlayer.types"; } from "@/modules/VlcPlayer.types";
import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import generateDeviceProfile from "@/utils/profiles/native"; import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time"; import { msToTicks, ticksToSeconds } from "@/utils/time";
import {
type BaseItemDto,
type MediaSourceInfo,
PlaybackOrder,
type PlaybackProgressInfo,
PlaybackStartInfo,
RepeatMode,
} from "@jellyfin/sdk/lib/generated-client";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { useGlobalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const downloadProvider = !Platform.isTV
? require("@/providers/DownloadProvider")
: null;
export default function page() { export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null); const videoRef = useRef<VlcPlayerViewRef>(null);
@@ -58,7 +50,12 @@ export default function page() {
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false); const [aspectRatio, setAspectRatio] = useState<
"default" | "16:9" | "4:3" | "1:1" | "21:9"
>("default");
const [scaleFactor, setScaleFactor] = useState<
1.0 | 1.1 | 1.2 | 1.3 | 1.4 | 1.5 | 1.6 | 1.7 | 1.8 | 1.9 | 2.0
>(1.0);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const [isBuffering, setIsBuffering] = useState(true); const [isBuffering, setIsBuffering] = useState(true);
@@ -72,10 +69,7 @@ export default function page() {
? null ? null
: require("react-native-volume-manager"); : require("react-native-volume-manager");
let getDownloadedItem = null; const downloadUtils = useDownload();
if (!Platform.isTV) {
getDownloadedItem = downloadProvider.useDownload();
}
const revalidateProgressCache = useInvalidatePlaybackProgressCache(); const revalidateProgressCache = useInvalidatePlaybackProgressCache();
@@ -93,6 +87,7 @@ export default function page() {
mediaSourceId, mediaSourceId,
bitrateValue: bitrateValueStr, bitrateValue: bitrateValueStr,
offline: offlineStr, offline: offlineStr,
playbackPosition: playbackPositionFromUrl,
} = useGlobalSearchParams<{ } = useGlobalSearchParams<{
itemId: string; itemId: string;
audioIndex: string; audioIndex: string;
@@ -100,10 +95,13 @@ export default function page() {
mediaSourceId: string; mediaSourceId: string;
bitrateValue: string; bitrateValue: string;
offline: string; offline: string;
/** Playback position in ticks. */
playbackPosition?: string;
}>(); }>();
const [settings] = useSettings(); const [_settings] = useSettings();
const insets = useSafeAreaInsets();
const offline = offlineStr === "true"; const offline = offlineStr === "true";
const playbackManager = usePlaybackManager();
const audioIndex = audioIndexStr const audioIndex = audioIndexStr
? Number.parseInt(audioIndexStr, 10) ? Number.parseInt(audioIndexStr, 10)
@@ -116,19 +114,33 @@ export default function page() {
: BITRATES[0].value; : BITRATES[0].value;
const [item, setItem] = useState<BaseItemDto | null>(null); const [item, setItem] = useState<BaseItemDto | null>(null);
const [downloadedItem, setDownloadedItem] = useState<DownloadedItem | null>(
null,
);
const [itemStatus, setItemStatus] = useState({ const [itemStatus, setItemStatus] = useState({
isLoading: true, isLoading: true,
isError: false, isError: false,
}); });
/** Gets the initial playback position from the URL. */
const getInitialPlaybackTicks = useCallback((): number => {
if (playbackPositionFromUrl) {
return Number.parseInt(playbackPositionFromUrl, 10);
}
return item?.UserData?.PlaybackPositionTicks ?? 0;
}, [playbackPositionFromUrl]);
useEffect(() => { useEffect(() => {
const fetchItemData = async () => { const fetchItemData = async () => {
setItemStatus({ isLoading: true, isError: false }); setItemStatus({ isLoading: true, isError: false });
try { try {
let fetchedItem: BaseItemDto | null = null; let fetchedItem: BaseItemDto | null = null;
if (offline && !Platform.isTV) { if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId); const data = downloadUtils.getDownloadedItemById(itemId);
if (data) fetchedItem = data.item as BaseItemDto; if (data) {
fetchedItem = data.item as BaseItemDto;
setDownloadedItem(data);
}
} else { } else {
const res = await getUserLibraryApi(api!).getItem({ const res = await getUserLibraryApi(api!).getItem({
itemId, itemId,
@@ -164,27 +176,30 @@ export default function page() {
useEffect(() => { useEffect(() => {
const fetchStreamData = async () => { const fetchStreamData = async () => {
setStreamStatus({ isLoading: true, isError: false }); setStreamStatus({ isLoading: true, isError: false });
const native = await generateDeviceProfile();
try { try {
let result: Stream | null = null; let result: Stream | null = null;
if (offline && !Platform.isTV) { if (offline && downloadedItem && downloadedItem.mediaSource) {
const data = await getDownloadedItem.getDownloadedItem(itemId); const url = downloadedItem.videoFilePath;
if (!data?.mediaSource) return;
const url = await getDownloadedFileUrl(data.item.Id!);
if (item) { if (item) {
result = { mediaSource: data.mediaSource, sessionId: "", url }; result = {
mediaSource: downloadedItem.mediaSource,
sessionId: "",
url: url,
};
} }
} else { } else {
const native = generateDeviceProfile();
const transcoding = generateDeviceProfile({ transcode: true });
const res = await getStreamUrl({ const res = await getStreamUrl({
api, api,
item, item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!, startTimeTicks: getInitialPlaybackTicks(),
userId: user?.Id, userId: user?.Id,
audioStreamIndex: audioIndex, audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue, maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex, subtitleStreamIndex: subtitleIndex,
deviceProfile: native, deviceProfile: bitrateValue ? transcoding : native,
}); });
if (!res) return; if (!res) return;
const { mediaSource, sessionId, url } = res; const { mediaSource, sessionId, url } = res;
@@ -205,26 +220,39 @@ export default function page() {
} }
}; };
fetchStreamData(); fetchStreamData();
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]); }, [
itemId,
mediaSourceId,
bitrateValue,
api,
item,
user?.Id,
downloadedItem,
]);
useEffect(() => { useEffect(() => {
if (!stream) return; if (!stream || !api) return;
const reportPlaybackStart = async () => { const reportPlaybackStart = async () => {
await getPlaystateApi(api!).reportPlaybackStart({ await getPlaystateApi(api).reportPlaybackStart({
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo, playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
}); });
}; };
reportPlaybackStart(); reportPlaybackStart();
}, [stream]); }, [stream, api]);
const togglePlay = async () => { const togglePlay = async () => {
lightHapticFeedback(); lightHapticFeedback();
setIsPlaying(!isPlaying); setIsPlaying(!isPlaying);
if (isPlaying) { if (isPlaying) {
await videoRef.current?.pause(); await videoRef.current?.pause();
reportPlaybackProgress(); playbackManager.reportPlaybackProgress(
item?.Id!,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
} else { } else {
videoRef.current?.play(); videoRef.current?.play();
await getPlaystateApi(api!).reportPlaybackStart({ await getPlaystateApi(api!).reportPlaybackStart({
@@ -234,7 +262,6 @@ export default function page() {
}; };
const reportPlaybackStopped = useCallback(async () => { const reportPlaybackStopped = useCallback(async () => {
if (offline) return;
const currentTimeInTicks = msToTicks(progress.get()); const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({ await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!, itemId: item?.Id!,
@@ -242,8 +269,6 @@ export default function page() {
positionTicks: currentTimeInTicks, positionTicks: currentTimeInTicks,
playSessionId: stream?.sessionId!, playSessionId: stream?.sessionId!,
}); });
revalidateProgressCache();
}, [ }, [
api, api,
item, item,
@@ -255,10 +280,15 @@ export default function page() {
]); ]);
const stop = useCallback(() => { const stop = useCallback(() => {
// Update URL with final playback position before stopping
router.setParams({
playbackPosition: msToTicks(progress.get()).toString(),
});
reportPlaybackStopped(); reportPlaybackStopped();
setIsPlaybackStopped(true); setIsPlaybackStopped(true);
videoRef.current?.stop(); videoRef.current?.stop();
}, [videoRef, reportPlaybackStopped]); revalidateProgressCache();
}, [videoRef, reportPlaybackStopped, progress]);
useEffect(() => { useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop); const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
@@ -267,7 +297,7 @@ export default function page() {
}; };
}, [navigation, stop]); }, [navigation, stop]);
const currentPlayStateInfo = () => { const currentPlayStateInfo = useCallback(() => {
if (!stream) return; if (!stream) return;
return { return {
itemId: item?.Id!, itemId: item?.Id!,
@@ -283,7 +313,32 @@ export default function page() {
repeatMode: RepeatMode.RepeatNone, repeatMode: RepeatMode.RepeatNone,
playbackOrder: PlaybackOrder.Default, playbackOrder: PlaybackOrder.Default,
}; };
}; }, [
stream,
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
progress,
isPlaying,
isMuted,
]);
const lastUrlUpdateTime = useSharedValue(0);
const wasJustSeeking = useSharedValue(false);
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second
// Track when seeking ends to update URL immediately
useAnimatedReaction(
() => isSeeking.get(),
(currentSeeking, previousSeeking) => {
if (previousSeeking && !currentSeeking) {
// Seeking just ended
wasJustSeeking.value = true;
}
},
[],
);
const onProgress = useCallback( const onProgress = useCallback(
async (data: ProgressUpdatePayload) => { async (data: ProgressUpdatePayload) => {
@@ -296,11 +351,31 @@ export default function page() {
progress.set(currentTime); progress.set(currentTime);
if (offline) return; // Update URL immediately after seeking, or every 30 seconds during normal playback
const now = Date.now();
const shouldUpdateUrl = wasJustSeeking.get();
wasJustSeeking.value = false;
if (!item?.Id || !stream) return; if (
shouldUpdateUrl ||
now - lastUrlUpdateTime.get() > URL_UPDATE_INTERVAL
) {
router.setParams({
playbackPosition: msToTicks(currentTime).toString(),
});
lastUrlUpdateTime.value = now;
}
reportPlaybackProgress(); if (!item?.Id) return;
playbackManager.reportPlaybackProgress(
item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
}, },
[ [
item?.Id, item?.Id,
@@ -320,29 +395,10 @@ export default function page() {
setIsPipStarted(pipStarted); setIsPipStarted(pipStarted);
}, []); }, []);
const reportPlaybackProgress = useCallback(async () => { /** Gets the initial playback position in seconds. */
if (!api || offline || !stream) return;
await getPlaystateApi(api).reportPlaybackProgress({
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
});
}, [
api,
isPlaying,
offline,
stream,
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
progress,
]);
const startPosition = useMemo(() => { const startPosition = useMemo(() => {
if (offline) return 0; return ticksToSeconds(getInitialPlaybackTicks());
return item?.UserData?.PlaybackPositionTicks }, [getInitialPlaybackTicks]);
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
: 0;
}, [item, offline]);
const volumeUpCb = useCallback(async () => { const volumeUpCb = useCallback(async () => {
if (Platform.isTV) return; if (Platform.isTV) return;
@@ -381,6 +437,7 @@ export default function page() {
console.error("Error toggling mute:", error); console.error("Error toggling mute:", error);
} }
}, [previousVolume]); }, [previousVolume]);
const volumeDownCb = useCallback(async () => { const volumeDownCb = useCallback(async () => {
if (Platform.isTV) return; if (Platform.isTV) return;
@@ -427,14 +484,32 @@ export default function page() {
const { state, isBuffering, isPlaying } = e.nativeEvent; const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") { if (state === "Playing") {
setIsPlaying(true); setIsPlaying(true);
reportPlaybackProgress(); if (item?.Id) {
playbackManager.reportPlaybackProgress(
item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
}
if (!Platform.isTV) await activateKeepAwakeAsync(); if (!Platform.isTV) await activateKeepAwakeAsync();
return; return;
} }
if (state === "Paused") { if (state === "Paused") {
setIsPlaying(false); setIsPlaying(false);
reportPlaybackProgress(); if (item?.Id) {
playbackManager.reportPlaybackProgress(
item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
}
if (!Platform.isTV) await deactivateKeepAwake(); if (!Platform.isTV) await deactivateKeepAwake();
return; return;
} }
@@ -446,7 +521,7 @@ export default function page() {
setIsBuffering(true); setIsBuffering(true);
} }
}, },
[reportPlaybackProgress], [playbackManager, item?.Id, progress],
); );
const allAudio = const allAudio =
@@ -464,25 +539,29 @@ export default function page() {
.filter((sub: any) => sub.DeliveryMethod === "External") .filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({ .map((sub: any) => ({
name: sub.DisplayTitle, name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl, DeliveryUrl: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl,
})); }));
/** The text based subtitle tracks */
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream); const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
/** The user chosen subtitle track from the server */
const chosenSubtitleTrack = allSubs.find( const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex, (sub) => sub.Index === subtitleIndex,
); );
/** The user chosen audio track from the server */
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex); const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
/** Whether the stream we're playing is not transcoding*/
const notTranscoding = !stream?.mediaSource.TranscodingUrl; const notTranscoding = !stream?.mediaSource.TranscodingUrl;
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`]; /** The initial options to pass to the VLC Player */
const initOptions = [``];
if ( if (
chosenSubtitleTrack && chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream) (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
) { ) {
// If not transcoding, we can the index as normal.
// If transcoding, we need to reverse the text based subtitles, because VLC reverses the HLS subtitles.
const finalIndex = notTranscoding const finalIndex = notTranscoding
? allSubs.indexOf(chosenSubtitleTrack) ? allSubs.indexOf(chosenSubtitleTrack)
: textSubs.indexOf(chosenSubtitleTrack); : [...textSubs].reverse().indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`); initOptions.push(`--sub-track=${finalIndex}`);
} }
@@ -498,7 +577,66 @@ export default function page() {
return () => setIsMounted(false); return () => setIsMounted(false);
}, []); }, []);
if (itemStatus.isLoading || streamStatus.isLoading) { // Memoize video ref functions to prevent unnecessary re-renders
const startPictureInPicture = useMemo(
() => videoRef.current?.startPictureInPicture,
[isVideoLoaded],
);
const play = useMemo(
() => videoRef.current?.play || (() => {}),
[isVideoLoaded],
);
const pause = useMemo(
() => videoRef.current?.pause || (() => {}),
[isVideoLoaded],
);
const seek = useMemo(
() => videoRef.current?.seekTo || (() => {}),
[isVideoLoaded],
);
const getAudioTracks = useMemo(
() => videoRef.current?.getAudioTracks,
[isVideoLoaded],
);
const getSubtitleTracks = useMemo(
() => videoRef.current?.getSubtitleTracks,
[isVideoLoaded],
);
const setSubtitleTrack = useMemo(
() => videoRef.current?.setSubtitleTrack,
[isVideoLoaded],
);
const setSubtitleURL = useMemo(
() => videoRef.current?.setSubtitleURL,
[isVideoLoaded],
);
const setAudioTrack = useMemo(
() => videoRef.current?.setAudioTrack,
[isVideoLoaded],
);
const setVideoAspectRatio = useMemo(
() => videoRef.current?.setVideoAspectRatio,
[isVideoLoaded],
);
const setVideoScaleFactor = useMemo(
() => videoRef.current?.setVideoScaleFactor,
[isVideoLoaded],
);
console.log("Debug: component render"); // Uncomment to debug re-renders
// Show error UI first, before checking loading/missingdata
if (itemStatus.isError || streamStatus.isError) {
return (
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
<Text className='text-white'>{t("player.error")}</Text>
</View>
);
}
// Then show loader while either side is still fetching or data isnt present
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
// …loader UI…
return ( return (
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'> <View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
<Loader /> <Loader />
@@ -514,7 +652,14 @@ export default function page() {
); );
return ( return (
<View style={{ flex: 1, backgroundColor: "black" }}> <View
style={{
flex: 1,
backgroundColor: "black",
height: "100%",
width: "100%",
}}
>
<View <View
style={{ style={{
display: "flex", display: "flex",
@@ -523,8 +668,6 @@ export default function page() {
position: "relative", position: "relative",
flexDirection: "column", flexDirection: "column",
justifyContent: "center", justifyContent: "center",
paddingLeft: ignoreSafeAreas ? 0 : insets.left,
paddingRight: ignoreSafeAreas ? 0 : insets.right,
}} }}
> >
<VlcPlayerView <VlcPlayerView
@@ -532,7 +675,7 @@ export default function page() {
source={{ source={{
uri: stream?.url || "", uri: stream?.url || "",
autoplay: true, autoplay: true,
isNetwork: true, isNetwork: !offline,
startPosition, startPosition,
externalSubtitles, externalSubtitles,
initOptions, initOptions,
@@ -555,7 +698,7 @@ export default function page() {
}} }}
/> />
</View> </View>
{videoRef.current && !isPipStarted && isMounted === true ? ( {!isPipStarted && isMounted === true && item && (
<Controls <Controls
mediaSource={stream?.mediaSource} mediaSource={stream?.mediaSource}
item={item} item={item}
@@ -568,23 +711,27 @@ export default function page() {
isBuffering={isBuffering} isBuffering={isBuffering}
showControls={showControls} showControls={showControls}
setShowControls={setShowControls} setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded} isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef?.current?.startPictureInPicture} startPictureInPicture={startPictureInPicture}
play={videoRef.current?.play} play={play}
pause={videoRef.current?.pause} pause={pause}
seek={videoRef.current?.seekTo} seek={seek}
enableTrickplay={true} enableTrickplay={true}
getAudioTracks={videoRef.current?.getAudioTracks} getAudioTracks={getAudioTracks}
getSubtitleTracks={videoRef.current?.getSubtitleTracks} getSubtitleTracks={getSubtitleTracks}
offline={offline} offline={offline}
setSubtitleTrack={videoRef.current.setSubtitleTrack} setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={videoRef.current.setSubtitleURL} setSubtitleURL={setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack} setAudioTrack={setAudioTrack}
setVideoAspectRatio={setVideoAspectRatio}
setVideoScaleFactor={setVideoScaleFactor}
aspectRatio={aspectRatio}
scaleFactor={scaleFactor}
setAspectRatio={setAspectRatio}
setScaleFactor={setScaleFactor}
isVlc isVlc
/> />
) : null} )}
</View> </View>
); );
} }

View File

@@ -1,5 +1,5 @@
import { ScrollViewStyleReset } from "expo-router/html"; import { ScrollViewStyleReset } from "expo-router/html";
import type { PropsWithChildren } from "react"; import { type PropsWithChildren } from "react";
/** /**
* This file is web-only and used to configure the root HTML for every web page during static rendering. * This file is web-only and used to configure the root HTML for every web page during static rendering.

View File

@@ -1,13 +1,15 @@
import "@/augmentations"; import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { Platform } from "react-native";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider"; import { DownloadProvider } from "@/providers/DownloadProvider";
import { import {
JellyfinProvider,
apiAtom, apiAtom,
getOrSetDeviceId, getOrSetDeviceId,
getTokenFromStorage, getTokenFromStorage,
JellyfinProvider,
} from "@/providers/JellyfinProvider"; } from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { type Settings, useSettings } from "@/utils/atoms/settings"; import { type Settings, useSettings } from "@/utils/atoms/settings";
@@ -23,36 +25,37 @@ import {
writeToLog, writeToLog,
} from "@/utils/log"; } from "@/utils/log";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Platform } from "react-native";
const BackGroundDownloader = !Platform.isTV const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader") ? require("@kesha-antonov/react-native-background-downloader")
: null; : null;
import { DarkTheme, ThemeProvider } from "@react-navigation/native"; import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const BackgroundFetch = !Platform.isTV const BackgroundFetch = !Platform.isTV
? require("expo-background-fetch") ? require("expo-background-fetch")
: null; : null;
import * as Device from "expo-device"; import * as Device from "expo-device";
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
const Notifications = !Platform.isTV ? require("expo-notifications") : null; const Notifications = !Platform.isTV ? require("expo-notifications") : null;
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { Stack, router, useSegments } from "expo-router"; import { router, Stack, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen"; import * as SplashScreen from "expo-splash-screen";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null; const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
import { getLocales } from "expo-localization"; import { getLocales } from "expo-localization";
import { Provider as JotaiProvider } from "jotai"; import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider } from "react-i18next";
import { AppState, Appearance } from "react-native"; import { Appearance, AppState } 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 "react-native-reanimated"; import "react-native-reanimated";
import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import type { EventSubscription } from "expo-modules-core"; import type { EventSubscription } from "expo-modules-core";
import type { import type {
@@ -62,6 +65,8 @@ import type {
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types"; import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { Toaster } from "sonner-native"; import { Toaster } from "sonner-native";
import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store";
if (!Platform.isTV) { if (!Platform.isTV) {
Notifications.setNotificationHandler({ Notifications.setNotificationHandler({
@@ -83,9 +88,9 @@ SplashScreen.setOptions({
}); });
function useNotificationObserver() { function useNotificationObserver() {
if (Platform.isTV) return;
useEffect(() => { useEffect(() => {
if (Platform.isTV) return;
let isMounted = true; let isMounted = true;
function redirect(notification: typeof Notifications.Notification) { function redirect(notification: typeof Notifications.Notification) {
@@ -137,16 +142,13 @@ if (!Platform.isTV) {
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
console.log("TaskManager ~ trigger"); console.log("TaskManager ~ trigger");
const now = Date.now();
const settingsData = storage.getString("settings"); const settingsData = storage.getString("settings");
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData; if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
const settings: Partial<Settings> = JSON.parse(settingsData); const settings: Partial<Settings> = JSON.parse(settingsData);
const url = settings?.optimizedVersionsServerUrl;
if (!settings?.autoDownload || !url) if (!settings?.autoDownload)
return BackgroundFetch.BackgroundFetchResult.NoData; return BackgroundFetch.BackgroundFetchResult.NoData;
const token = getTokenFromStorage(); const token = getTokenFromStorage();
@@ -156,74 +158,6 @@ if (!Platform.isTV) {
if (!token || !deviceId || !baseDirectory) if (!token || !deviceId || !baseDirectory)
return BackgroundFetch.BackgroundFetchResult.NoData; return BackgroundFetch.BackgroundFetchResult.NoData;
const jobs = await getAllJobsByDeviceId({
deviceId,
authHeader: token,
url,
});
console.log("TaskManager ~ Active jobs: ", jobs.length);
for (const job of jobs) {
if (job.status === "completed") {
const downloadUrl = `${url}download/${job.id}`;
const tasks = await BackGroundDownloader.checkForExistingDownloads();
if (tasks.find((task: { id: string }) => task.id === job.id)) {
console.log("TaskManager ~ Download already in progress: ", job.id);
continue;
}
BackGroundDownloader.download({
id: job.id,
url: downloadUrl,
destination: `${baseDirectory}${job.item.Id}.mp4`,
headers: {
Authorization: token,
},
})
.begin(() => {
console.log("TaskManager ~ Download started: ", job.id);
})
.done(() => {
console.log("TaskManager ~ Download completed: ", job.id);
saveDownloadedItemInfo(job.item);
BackGroundDownloader.completeHandler(job.id);
cancelJobById({
authHeader: token,
id: job.id,
url: url,
});
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: "Download completed",
data: {
url: "/downloads",
},
},
trigger: null,
});
})
.error((error: any) => {
console.log("TaskManager ~ Download error: ", job.id, error);
BackGroundDownloader.completeHandler(job.id);
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: "Download failed",
data: {
url: "/downloads",
},
},
trigger: null,
});
});
}
}
console.log(`Auto download started: ${new Date(now).toISOString()}`);
// Be sure to return the successful result type! // Be sure to return the successful result type!
return BackgroundFetch.BackgroundFetchResult.NewData; return BackgroundFetch.BackgroundFetchResult.NewData;
}); });
@@ -301,51 +235,51 @@ function Layout() {
); );
}, [settings?.preferedLanguage, i18n]); }, [settings?.preferedLanguage, i18n]);
if (!Platform.isTV) { useNotificationObserver();
useNotificationObserver();
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>(); const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>();
const notificationListener = useRef<EventSubscription>(); const notificationListener = useRef<EventSubscription>();
const responseListener = useRef<EventSubscription>(); const responseListener = useRef<EventSubscription>();
useEffect(() => { useEffect(() => {
if (expoPushToken && api && user) { if (!Platform.isTV && expoPushToken && api && user) {
api api
?.post("/Streamyfin/device", { ?.post("/Streamyfin/device", {
token: expoPushToken.data, token: expoPushToken.data,
deviceId: getOrSetDeviceId(), deviceId: getOrSetDeviceId(),
userId: user.Id, userId: user.Id,
}) })
.then((_) => console.log("Posted expo push token")) .then((_) => console.log("Posted expo push token"))
.catch((_) => .catch((_) =>
writeErrorLog("Failed to push expo push token to plugin"), writeErrorLog("Failed to push expo push token to plugin"),
); );
} else console.log("No token available"); } else console.log("No token available");
}, [api, expoPushToken, user]); }, [api, expoPushToken, user]);
async function registerNotifications() { async function registerNotifications() {
if (Platform.OS === "android") { if (Platform.OS === "android") {
console.log("Setting android notification channel 'default'"); console.log("Setting android notification channel 'default'");
await Notifications?.setNotificationChannelAsync("default", { await Notifications?.setNotificationChannelAsync("default", {
name: "default", name: "default",
}); });
}
await checkAndRequestPermissions();
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
await registerBackgroundFetchAsyncSessions();
}
// only create push token for real devices (pointless for emulators)
if (Device.isDevice) {
Notifications?.getExpoPushTokenAsync()
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
.catch((reason: any) => console.log("Failed to get token", reason));
}
} }
useEffect(() => { await checkAndRequestPermissions();
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
await registerBackgroundFetchAsyncSessions();
}
// only create push token for real devices (pointless for emulators)
if (Device.isDevice) {
Notifications?.getExpoPushTokenAsync()
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
.catch((reason: any) => console.log("Failed to get token", reason));
}
}
useEffect(() => {
if (!Platform.isTV) {
registerNotifications(); registerNotifications();
notificationListener.current = notificationListener.current =
@@ -363,12 +297,10 @@ function Layout() {
(response: NotificationResponse) => { (response: NotificationResponse) => {
// 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;
writeDebugLog( writeDebugLog(
`Notification ${title} opened`, `Notification ${title} opened`,
response.notification.request.content, response.notification.request.content,
); );
if (data && Object.keys(data).length > 0) { if (data && Object.keys(data).length > 0) {
const type = data?.type?.toLower?.(); const type = data?.type?.toLower?.();
const itemId = data?.id; const itemId = data?.id;
@@ -381,12 +313,10 @@ function Layout() {
// We just clicked a notification for an individual episode. // We just clicked a notification for an individual episode.
if (itemId) { if (itemId) {
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`); router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
} // summarized season notification for multiple episodes. Bring them to series season
// summarized season notification for multiple episodes. Bring them to series season } else {
else {
const seriesId = data.seriesId; const seriesId = data.seriesId;
const seasonIndex = data.seasonIndex; const seasonIndex = data.seasonIndex;
if (seasonIndex) { if (seasonIndex) {
router.push( router.push(
`/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`, `/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`,
@@ -411,127 +341,116 @@ function Layout() {
responseListener.current, responseListener.current,
); );
}; };
}, []); }
}, [user, api]);
useEffect(() => { useEffect(() => {
if (Platform.isTV) return; if (Platform.isTV) {
if (segments.includes("direct-player" as never)) { return;
return; }
if (segments.includes("direct-player" as never)) {
if (
!settings.followDeviceOrientation &&
settings.defaultVideoOrientation
) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
} }
return;
}
// If the user has auto rotate enabled, unlock the orientation if (settings.followDeviceOrientation === true) {
if (settings.followDeviceOrientation === true) { ScreenOrientation.unlockAsync();
ScreenOrientation.unlockAsync(); } else {
} else { ScreenOrientation.lockAsync(
// If the user has auto rotate disabled, lock the orientation to portrait ScreenOrientation.OrientationLock.PORTRAIT_UP,
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
}
}, [settings.followDeviceOrientation, segments]);
useEffect(() => {
const subscription = AppState.addEventListener(
"change",
(nextAppState) => {
if (
appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
BackGroundDownloader.checkForExistingDownloads();
}
},
); );
}
}, [
settings.followDeviceOrientation,
settings.defaultVideoOrientation,
segments,
]);
BackGroundDownloader.checkForExistingDownloads(); useEffect(() => {
if (Platform.isTV) {
return;
}
return () => { const subscription = AppState.addEventListener("change", (nextAppState) => {
subscription.remove(); if (
}; appState.current.match(/inactive|background/) &&
}, []); nextAppState === "active"
} ) {
BackGroundDownloader.checkForExistingDownloads();
}
});
BackGroundDownloader.checkForExistingDownloads();
return () => {
subscription.remove();
};
}, []);
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<JobQueueProvider> <JellyfinProvider>
<JellyfinProvider> <PlaySettingsProvider>
<PlaySettingsProvider> <LogProvider>
<LogProvider> <WebSocketProvider>
<WebSocketProvider> <DownloadProvider>
<DownloadProvider> <BottomSheetModalProvider>
<BottomSheetModalProvider> <SystemBars style='light' hidden={false} />
<SystemBars style='light' hidden={false} /> <ThemeProvider value={DarkTheme}>
<ThemeProvider value={DarkTheme}> <Stack initialRouteName='(auth)/(tabs)'>
<Stack initialRouteName='(auth)/(tabs)'> <Stack.Screen
<Stack.Screen name='(auth)/(tabs)'
name='(auth)/(tabs)' options={{
options={{ headerShown: false,
headerShown: false, title: "",
title: "", header: () => null,
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}} }}
closeButton
/> />
</ThemeProvider> <Stack.Screen
</BottomSheetModalProvider> name='(auth)/player'
</DownloadProvider> options={{
</WebSocketProvider> headerShown: false,
</LogProvider> title: "",
</PlaySettingsProvider> header: () => null,
</JellyfinProvider> }}
</JobQueueProvider> />
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</JellyfinProvider>
</QueryClientProvider> </QueryClientProvider>
); );
} }
function saveDownloadedItemInfo(item: BaseItemDto) {
try {
const downloadedItems = storage.getString("downloadedItems");
const items: BaseItemDto[] = downloadedItems
? JSON.parse(downloadedItems)
: [];
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
if (existingItemIndex !== -1) {
items[existingItemIndex] = item;
} else {
items.push(item);
}
storage.set("downloadedItems", JSON.stringify(items));
} catch (error) {
writeToLog("ERROR", "Failed to save downloaded item information:", error);
console.error("Failed to save downloaded item information:", error);
}
}

View File

@@ -1,29 +1,29 @@
import { Button } from "@/components/Button";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
Alert, Alert,
Keyboard,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
SafeAreaView, SafeAreaView,
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { Keyboard } from "react-native";
import { t } from "i18next";
import { z } from "zod"; import { z } from "zod";
import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
const CredentialsSchema = z.object({ const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")), username: z.string().min(1, t("login.username_required")),
}); });
@@ -199,7 +199,7 @@ const Login: React.FC = () => {
], ],
); );
} }
} catch (error) { } catch (_error) {
Alert.alert( Alert.alert(
t("login.error_title"), t("login.error_title"),
t("login.failed_to_initiate_quick_connect"), t("login.failed_to_initiate_quick_connect"),
@@ -213,133 +213,127 @@ const Login: React.FC = () => {
behavior={Platform.OS === "ios" ? "padding" : "height"} behavior={Platform.OS === "ios" ? "padding" : "height"}
> >
{api?.basePath ? ( {api?.basePath ? (
<> <View className='flex flex-col h-full relative items-center justify-center'>
<View className='flex flex-col h-full relative items-center justify-center'> <View className='px-4 -mt-20 w-full'>
<View className='px-4 -mt-20 w-full'> <View className='flex flex-col space-y-2'>
<View className='flex flex-col space-y-2'> <Text className='text-2xl font-bold -mb-2'>
<Text className='text-2xl font-bold -mb-2'> {serverName ? (
{serverName ? ( <>
<> {`${t("login.login_to_title")} `}
{`${t("login.login_to_title")} `} <Text className='text-purple-600'>{serverName}</Text>
<Text className='text-purple-600'>{serverName}</Text> </>
</> ) : (
) : ( t("login.login_title")
t("login.login_title") )}
)}
</Text>
<Text className='text-xs text-neutral-400'>
{api.basePath}
</Text>
<Input
placeholder={t("login.username_placeholder")}
onChangeText={(text) =>
setCredentials({ ...credentials, username: text })
}
value={credentials.username}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
// Changed from username to oneTimeCode because it is a known issue in RN
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
textContentType='oneTimeCode'
clearButtonMode='while-editing'
maxLength={500}
/>
<Input
placeholder={t("login.password_placeholder")}
onChangeText={(text) =>
setCredentials({ ...credentials, password: text })
}
value={credentials.password}
secureTextEntry
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
clearButtonMode='while-editing'
maxLength={500}
/>
<View className='flex flex-row items-center justify-between'>
<Button
onPress={handleLogin}
loading={loading}
className='flex-1 mr-2'
>
{t("login.login_button")}
</Button>
<TouchableOpacity
onPress={handleQuickConnect}
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
>
<MaterialCommunityIcons
name='cellphone-lock'
size={24}
color='white'
/>
</TouchableOpacity>
</View>
</View>
</View>
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
</View>
</>
) : (
<>
<View className='flex flex-col h-full items-center justify-center w-full'>
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
<Image
style={{
width: 100,
height: 100,
marginLeft: -23,
marginBottom: -20,
}}
source={require("@/assets/images/StreamyFinFinal.png")}
/>
<Text className='text-3xl font-bold'>Streamyfin</Text>
<Text className='text-neutral-500'>
{t("server.enter_url_to_jellyfin_server")}
</Text> </Text>
<Text className='text-xs text-neutral-400'>{api.basePath}</Text>
<Input <Input
aria-label='Server URL' placeholder={t("login.username_placeholder")}
placeholder={t("server.server_url_placeholder")} onChangeText={(text) =>
onChangeText={setServerURL} setCredentials({ ...credentials, username: text })
value={serverURL} }
keyboardType='url' value={credentials.username}
keyboardType='default'
returnKeyType='done' returnKeyType='done'
autoCapitalize='none' autoCapitalize='none'
textContentType='URL' // Changed from username to oneTimeCode because it is a known issue in RN
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
textContentType='oneTimeCode'
clearButtonMode='while-editing'
maxLength={500} maxLength={500}
/> />
<Button
loading={loadingServerCheck} <Input
disabled={loadingServerCheck} placeholder={t("login.password_placeholder")}
onPress={async () => { onChangeText={(text) =>
await handleConnect(serverURL); setCredentials({ ...credentials, password: text })
}} }
className='w-full grow' value={credentials.password}
> secureTextEntry
{t("server.connect_button")} keyboardType='default'
</Button> returnKeyType='done'
<JellyfinServerDiscovery autoCapitalize='none'
onServerSelect={async (server) => { textContentType='password'
setServerURL(server.address); clearButtonMode='while-editing'
if (server.serverName) { maxLength={500}
setServerName(server.serverName);
}
await handleConnect(server.address);
}}
/>
<PreviousServersList
onServerSelect={async (s) => {
await handleConnect(s.address);
}}
/> />
<View className='flex flex-row items-center justify-between'>
<Button
onPress={handleLogin}
loading={loading}
className='flex-1 mr-2'
>
{t("login.login_button")}
</Button>
<TouchableOpacity
onPress={handleQuickConnect}
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
>
<MaterialCommunityIcons
name='cellphone-lock'
size={24}
color='white'
/>
</TouchableOpacity>
</View>
</View> </View>
</View> </View>
</>
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
</View>
) : (
<View className='flex flex-col h-full items-center justify-center w-full'>
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
<Image
style={{
width: 100,
height: 100,
marginLeft: -23,
marginBottom: -20,
}}
source={require("@/assets/images/icon-ios-plain.png")}
/>
<Text className='text-3xl font-bold'>Streamyfin</Text>
<Text className='text-neutral-500'>
{t("server.enter_url_to_jellyfin_server")}
</Text>
<Input
aria-label='Server URL'
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
maxLength={500}
/>
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => {
await handleConnect(serverURL);
}}
className='w-full grow'
>
{t("server.connect_button")}
</Button>
<JellyfinServerDiscovery
onServerSelect={async (server) => {
setServerURL(server.address);
if (server.serverName) {
setServerName(server.serverName);
}
await handleConnect(server.address);
}}
/>
<PreviousServersList
onServerSelect={async (s) => {
await handleConnect(s.address);
}}
/>
</View>
</View>
)} )}
</KeyboardAvoidingView> </KeyboardAvoidingView>
</SafeAreaView> </SafeAreaView>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -1,6 +1,6 @@
import type { StreamyfinPluginConfig } from "@/utils/atoms/settings"; import { Api, AUTHORIZATION_HEADER } from "@jellyfin/sdk";
import { AUTHORIZATION_HEADER, Api } from "@jellyfin/sdk";
import type { AxiosRequestConfig, AxiosResponse } from "axios"; import type { AxiosRequestConfig, AxiosResponse } from "axios";
import type { StreamyfinPluginConfig } from "@/utils/atoms/settings";
declare module "@jellyfin/sdk" { declare module "@jellyfin/sdk" {
interface Api { interface Api {

View File

@@ -7,15 +7,27 @@ declare module "react-native-mmkv" {
} }
} }
// Add the augmentation methods directly to the MMKV prototype
// This follows the recommended pattern while adding the helper methods your app uses
MMKV.prototype.get = function <T>(key: string): T | undefined { MMKV.prototype.get = function <T>(key: string): T | undefined {
const serializedItem = this.getString(key); try {
return serializedItem ? JSON.parse(serializedItem) : undefined; const serializedItem = this.getString(key);
if (!serializedItem) return undefined;
return JSON.parse(serializedItem);
} catch (error) {
console.warn(`Failed to parse MMKV value for key "${key}":`, error);
return undefined;
}
}; };
MMKV.prototype.setAny = function (key: string, value: any | undefined): void { MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
if (value === undefined) { try {
this.delete(key); if (value === undefined) {
} else { this.delete(key);
this.set(key, JSON.stringify(value)); } else {
this.set(key, JSON.stringify(value));
}
} catch (error) {
console.warn(`Failed to set MMKV value for key "${key}":`, error);
} }
}; };

View File

@@ -1,16 +1,14 @@
{ {
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
"organizeImports": {
"enabled": true
},
"files": { "files": {
"ignore": [ "includes": [
"node_modules", "**/*",
"ios", "!node_modules",
"android", "!ios",
"Streamyfin.app", "!android",
"utils/jellyseerr", "!Streamyfin.app",
".expo" "!utils/jellyseerr",
"!.expo"
] ]
}, },
"linter": { "linter": {
@@ -26,7 +24,9 @@
"noForEach": "off" "noForEach": "off"
}, },
"recommended": true, "recommended": true,
"correctness": { "useExhaustiveDependencies": "off" }, "correctness": {
"useExhaustiveDependencies": "off"
},
"suspicious": { "suspicious": {
"noExplicitAny": "off", "noExplicitAny": "off",
"noArrayIndexKey": "off" "noArrayIndexKey": "off"

1593
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
import { RoundButton } from "@/components/RoundButton";
import { useFavorite } from "@/hooks/useFavorite";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { FC } from "react"; import type { FC } from "react";
import { View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { RoundButton } from "@/components/RoundButton";
import { useFavorite } from "@/hooks/useFavorite";
interface Props extends ViewProps { interface Props extends ViewProps {
item: BaseItemDto; item: BaseItemDto;

View File

@@ -1,7 +1,9 @@
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react"; import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
@@ -17,7 +19,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
selected, selected,
...props ...props
}) => { }) => {
if (Platform.isTV) return null; const isTv = Platform.isTV;
const audioStreams = useMemo( const audioStreams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"), () => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
[source], [source],
@@ -30,6 +33,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
const { t } = useTranslation(); const { t } = useTranslation();
if (isTv) return null;
return ( return (
<View <View
className='flex shrink' className='flex shrink'

View File

@@ -1,5 +1,7 @@
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
@@ -58,23 +60,26 @@ export const BitrateSelector: React.FC<Props> = ({
inverted, inverted,
...props ...props
}) => { }) => {
if (Platform.isTV) return null; const isTv = Platform.isTV;
const sorted = useMemo(() => { const sorted = useMemo(() => {
if (inverted) if (inverted)
return BITRATES.sort( return BITRATES.slice().sort(
(a, b) => (a, b) =>
(a.value || Number.POSITIVE_INFINITY) - (a.value || Number.POSITIVE_INFINITY) -
(b.value || Number.POSITIVE_INFINITY), (b.value || Number.POSITIVE_INFINITY),
); );
return BITRATES.sort( return BITRATES.slice().sort(
(a, b) => (a, b) =>
(b.value || Number.POSITIVE_INFINITY) - (b.value || Number.POSITIVE_INFINITY) -
(a.value || Number.POSITIVE_INFINITY), (a.value || Number.POSITIVE_INFINITY),
); );
}, []); }, [inverted]);
const { t } = useTranslation(); const { t } = useTranslation();
if (isTv) return null;
return ( return (
<View <View
className='flex shrink' className='flex shrink'

View File

@@ -1,7 +1,7 @@
import { useHaptic } from "@/hooks/useHaptic";
import type React from "react"; import type React from "react";
import { type PropsWithChildren, type ReactNode, useMemo } from "react"; import { type PropsWithChildren, type ReactNode, useMemo } from "react";
import { Platform, Text, TouchableOpacity, View } from "react-native"; import { Text, TouchableOpacity, View } from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
export interface ButtonProps export interface ButtonProps

View File

@@ -1,6 +1,6 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import React, { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { Platform, TouchableOpacity, type ViewProps } from "react-native"; import { Platform } from "react-native";
import GoogleCast, { import GoogleCast, {
CastButton, CastButton,
CastContext, CastContext,
@@ -11,12 +11,6 @@ import GoogleCast, {
} from "react-native-google-cast"; } from "react-native-google-cast";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
interface Props extends ViewProps {
width?: number;
height?: number;
background?: "blur" | "transparent";
}
export function Chromecast({ export function Chromecast({
width = 48, width = 48,
height = 48, height = 48,
@@ -44,11 +38,7 @@ export function Chromecast({
// Android requires the cast button to be present for startDiscovery to work // Android requires the cast button to be present for startDiscovery to work
const AndroidCastButton = useCallback( const AndroidCastButton = useCallback(
() => () =>
Platform.OS === "android" ? ( Platform.OS === "android" ? <CastButton tintColor='transparent' /> : null,
<CastButton tintColor='transparent' />
) : (
<></>
),
[Platform.OS], [Platform.OS],
); );

View File

@@ -1 +0,0 @@
export * from "zeego/context-menu";

View File

@@ -1,11 +1,12 @@
import { apiAtom } from "@/providers/JellyfinProvider";
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 { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useMemo } from "react";
import type React from "react"; import type React from "react";
import { useMemo } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider";
import { ProgressBar } from "./common/ProgressBar";
import { WatchedIndicator } from "./WatchedIndicator"; import { WatchedIndicator } from "./WatchedIndicator";
type ContinueWatchingPosterProps = { type ContinueWatchingPosterProps = {
@@ -62,18 +63,6 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`; return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}, [item]); }, [item]);
const progress = useMemo(() => {
if (item.Type === "Program") {
const startDate = new Date(item.StartDate || "");
const endDate = new Date(item.EndDate || "");
const now = new Date();
const total = endDate.getTime() - startDate.getTime();
const elapsed = now.getTime() - startDate.getTime();
return (elapsed / total) * 100;
}
return item.UserData?.PlayedPercentage || 0;
}, [item]);
if (!url) if (!url)
return <View className='aspect-video border border-neutral-800 w-44' />; return <View className='aspect-video border border-neutral-800 w-44' />;
@@ -101,22 +90,8 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
</View> </View>
)} )}
</View> </View>
{!progress && <WatchedIndicator item={item} />} {!item.UserData?.Played && <WatchedIndicator item={item} />}
{progress > 0 && ( <ProgressBar item={item} />
<>
<View
className={
"absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full"
}
/>
<View
style={{
width: `${progress}%`,
}}
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"}
/>
</>
)}
</View> </View>
); );
}; };

View File

@@ -1,11 +1,3 @@
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
import download from "@/utils/profiles/download";
import Ionicons from "@expo/vector-icons/Ionicons"; import Ionicons from "@expo/vector-icons/Ionicons";
import { import {
BottomSheetBackdrop, BottomSheetBackdrop,
@@ -17,22 +9,36 @@ import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { type Href, router, useFocusEffect } from "expo-router"; import { type Href, router } 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, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, Platform, 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 useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getDownloadUrl } from "@/utils/jellyfin/media/getDownloadUrl";
import { AudioTrackSelector } from "./AudioTrackSelector"; import { AudioTrackSelector } from "./AudioTrackSelector";
import { type Bitrate, BitrateSelector } from "./BitrateSelector"; import { type Bitrate, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button"; import { Button } from "./Button";
import { Text } from "./common/Text";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector"; import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle"; import ProgressCircle from "./ProgressCircle";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
import { Text } from "./common/Text";
export type SelectedOptions = {
bitrate: Bitrate;
mediaSource: MediaSourceInfo | undefined;
audioIndex: number | undefined;
subtitleIndex: number;
};
interface DownloadProps extends ViewProps { interface DownloadProps extends ViewProps {
items: BaseItemDto[]; items: BaseItemDto[];
@@ -54,33 +60,29 @@ export const DownloadItems: React.FC<DownloadProps> = ({
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
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 [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
const { processes, startBackgroundDownload, downloadedFiles } = useDownload(); const { processes, startBackgroundDownload, getDownloadedItems } =
//const { startRemuxing } = useRemuxHlsToMp4(); useDownload();
const downloadedFiles = getDownloadedItems();
const [selectedMediaSource, setSelectedMediaSource] = useState< const [selectedOptions, setSelectedOptions] = useState<
MediaSourceInfo | undefined | null SelectedOptions | undefined
>(undefined); >(undefined);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] = const {
useState<number>(0); defaultAudioIndex,
const [maxBitrate, setMaxBitrate] = useState<Bitrate>( defaultBitrate,
settings?.defaultBitrate ?? { defaultMediaSource,
key: "Max", defaultSubtitleIndex,
value: undefined, } = useDefaultPlaySettings(items[0], settings);
},
);
const userCanDownload = useMemo( const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading, () => user?.Policy?.EnableContentDownloading,
[user], [user],
); );
const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === DownloadMethod.Optimized,
[settings],
);
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -88,7 +90,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
bottomSheetModalRef.current?.present(); bottomSheetModalRef.current?.present();
}, []); }, []);
const handleSheetChanges = useCallback((index: number) => {}, []); const handleSheetChanges = useCallback((_index: number) => {}, []);
const closeModal = useCallback(() => { const closeModal = useCallback(() => {
bottomSheetModalRef.current?.dismiss(); bottomSheetModalRef.current?.dismiss();
@@ -102,6 +104,28 @@ export const DownloadItems: React.FC<DownloadProps> = ({
[items, downloadedFiles], [items, downloadedFiles],
); );
// Initialize selectedOptions with default values
useEffect(() => {
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
}, [
defaultAudioIndex,
defaultBitrate,
defaultSubtitleIndex,
defaultMediaSource,
]);
const itemsToDownload = useMemo(() => {
if (downloadUnwatchedOnly) {
return itemsNotDownloaded.filter((item) => !item.UserData?.Played);
}
return itemsNotDownloaded;
}, [itemsNotDownloaded, downloadUnwatchedOnly]);
const allItemsDownloaded = useMemo(() => { const allItemsDownloaded = useMemo(() => {
if (items.length === 0) return false; if (items.length === 0) return false;
return itemsNotDownloaded.length === 0; return itemsNotDownloaded.length === 0;
@@ -144,99 +168,98 @@ export const DownloadItems: React.FC<DownloadProps> = ({
); );
}; };
const acceptDownloadOptions = useCallback(() => {
if (userCanDownload === true) {
if (itemsNotDownloaded.some((i) => !i.Id)) {
throw new Error("No item id");
}
closeModal();
initiateDownload(...itemsNotDownloaded);
} else {
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
);
}
}, [
queue,
setQueue,
itemsNotDownloaded,
usingOptimizedServer,
userCanDownload,
maxBitrate,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
]);
const initiateDownload = useCallback( const initiateDownload = useCallback(
async (...items: BaseItemDto[]) => { async (...items: BaseItemDto[]) => {
if ( if (
!api || !api ||
!user?.Id || !user?.Id ||
items.some((p) => !p.Id) || items.some((p) => !p.Id) ||
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id) (itemsNotDownloaded.length === 1 && !selectedOptions?.mediaSource?.Id)
) { ) {
throw new Error( throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item", "DownloadItem ~ initiateDownload: No api or user or item",
); );
} }
let mediaSource = selectedMediaSource; const downloadDetailsPromises = items.map(async (item) => {
let audioIndex: number | undefined = selectedAudioStream; const { mediaSource, audioIndex, subtitleIndex } =
let subtitleIndex: number | undefined = selectedSubtitleStream; itemsNotDownloaded.length > 1
? getDefaultPlaySettings(item, settings!)
: {
mediaSource: selectedOptions?.mediaSource,
audioIndex: selectedOptions?.audioIndex,
subtitleIndex: selectedOptions?.subtitleIndex,
};
for (const item of items) { const downloadDetails = await getDownloadUrl({
if (itemsNotDownloaded.length > 1) {
const defaults = getDefaultPlaySettings(item, settings!);
mediaSource = defaults.mediaSource;
audioIndex = defaults.audioIndex;
subtitleIndex = defaults.subtitleIndex;
}
const res = await getStreamUrl({
api, api,
item, item,
startTimeTicks: 0, userId: user.Id!,
userId: user?.Id, mediaSource: mediaSource!,
audioStreamIndex: audioIndex, audioStreamIndex: audioIndex ?? -1,
maxStreamingBitrate: maxBitrate.value, subtitleStreamIndex: subtitleIndex ?? -1,
mediaSourceId: mediaSource?.Id, maxBitrate: selectedOptions?.bitrate || defaultBitrate,
subtitleStreamIndex: subtitleIndex, deviceId: api.deviceInfo.id,
deviceProfile: download,
download: true,
// deviceId: mediaSource?.Id,
}); });
if (!res) { return {
url: downloadDetails?.url,
item,
mediaSource: downloadDetails?.mediaSource,
};
});
const downloadDetails = await Promise.all(downloadDetailsPromises);
for (const { url, item, mediaSource } of downloadDetails) {
if (!url) {
Alert.alert( Alert.alert(
t("home.downloads.something_went_wrong"), t("home.downloads.something_went_wrong"),
t("home.downloads.could_not_get_stream_url_from_jellyfin"), t("home.downloads.could_not_get_stream_url_from_jellyfin"),
); );
continue; continue;
} }
if (!mediaSource) {
const { mediaSource: source, url } = res; console.error(`Could not get download URL for ${item.Name}`);
toast.error(
if (!url || !source) throw new Error("No url"); t("Could not get download URL for {{itemName}}", {
itemName: item.Name,
saveDownloadItemInfoToDiskTmp(item, source, url); }),
await startBackgroundDownload(url, item, source, maxBitrate); );
continue;
}
await startBackgroundDownload(
url,
item,
mediaSource,
selectedOptions?.bitrate || defaultBitrate,
);
} }
}, },
[ [
api, api,
user?.Id, user?.Id,
itemsNotDownloaded, itemsNotDownloaded,
selectedMediaSource, selectedOptions,
selectedAudioStream,
selectedSubtitleStream,
settings, settings,
maxBitrate, defaultBitrate,
usingOptimizedServer,
startBackgroundDownload, startBackgroundDownload,
], ],
); );
const acceptDownloadOptions = useCallback(() => {
if (userCanDownload === true) {
if (itemsToDownload.some((i) => !i.Id)) {
throw new Error("No item id");
}
closeModal();
initiateDownload(...itemsToDownload);
} else {
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
);
}
}, [closeModal, initiateDownload, itemsToDownload, userCanDownload]);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => ( (props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop <BottomSheetBackdrop
@@ -247,19 +270,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
), ),
[], [],
); );
useFocusEffect(
useCallback(() => {
if (!settings) return;
if (itemsNotDownloaded.length !== 1) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(items[0], settings);
setSelectedMediaSource(mediaSource ?? undefined);
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [items, itemsNotDownloaded, settings]),
);
const renderButtonContent = () => { const renderButtonContent = () => {
if (processes.length > 0 && itemsProcesses.length > 0) { if (processes.length > 0 && itemsProcesses.length > 0) {
@@ -327,40 +337,78 @@ export const DownloadItems: React.FC<DownloadProps> = ({
<Text className='text-neutral-300'> <Text className='text-neutral-300'>
{subtitle || {subtitle ||
t("item_card.download.download_x_item", { t("item_card.download.download_x_item", {
item_count: itemsNotDownloaded.length, item_count: itemsToDownload.length,
})} })}
</Text> </Text>
</View> </View>
<View className='flex flex-col space-y-2 w-full items-start'> <View className='flex flex-col space-y-2 w-full items-start'>
<BitrateSelector <BitrateSelector
inverted inverted
onChange={setMaxBitrate} onChange={(val) =>
selected={maxBitrate} setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val },
)
}
selected={selectedOptions?.bitrate}
/> />
{itemsNotDownloaded.length > 1 && (
<View className='flex flex-row items-center justify-between w-full py-2'>
<Text>{t("item_card.download.download_unwatched_only")}</Text>
<Switch
onValueChange={setDownloadUnwatchedOnly}
value={downloadUnwatchedOnly}
/>
</View>
)}
{itemsNotDownloaded.length === 1 && ( {itemsNotDownloaded.length === 1 && (
<> <View>
<MediaSourceSelector <MediaSourceSelector
item={items[0]} item={items[0]}
onChange={setSelectedMediaSource} onChange={(val) =>
selected={selectedMediaSource} setSelectedOptions(
(prev) =>
prev && {
...prev,
mediaSource: val,
},
)
}
selected={selectedOptions?.mediaSource}
/> />
{selectedMediaSource && ( {selectedOptions?.mediaSource && (
<View className='flex flex-col space-y-2'> <View className='flex flex-col space-y-2'>
<AudioTrackSelector <AudioTrackSelector
source={selectedMediaSource} source={selectedOptions.mediaSource}
onChange={setSelectedAudioStream} onChange={(val) => {
selected={selectedAudioStream} setSelectedOptions(
(prev) =>
prev && {
...prev,
audioIndex: val,
},
);
}}
selected={selectedOptions.audioIndex}
/> />
<SubtitleTrackSelector <SubtitleTrackSelector
source={selectedMediaSource} source={selectedOptions.mediaSource}
onChange={setSelectedSubtitleStream} onChange={(val) => {
selected={selectedSubtitleStream} setSelectedOptions(
(prev) =>
prev && {
...prev,
subtitleIndex: val,
},
);
}}
selected={selectedOptions.subtitleIndex}
/> />
</View> </View>
)} )}
</> </View>
)} )}
</View> </View>
<Button <Button
className='mt-auto' className='mt-auto'
onPress={acceptDownloadOptions} onPress={acceptDownloadOptions}
@@ -368,13 +416,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
> >
{t("item_card.download.download_button")} {t("item_card.download.download_button")}
</Button> </Button>
<View className='opacity-70 text-center w-full flex items-center'>
<Text className='text-xs'>
{usingOptimizedServer
? t("item_card.download.using_optimized_server")
: t("item_card.download.using_default_method")}
</Text>
</View>
</View> </View>
</BottomSheetView> </BottomSheetView>
</BottomSheetModal> </BottomSheetModal>

View File

@@ -1,4 +1,3 @@
import { tc } from "@/utils/textTools";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react"; import type React from "react";
import { View } from "react-native"; import { View } from "react-native";

View File

@@ -1,25 +1,3 @@
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { DownloadSingleItem } from "@/components/DownloadItem";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
import { PlayButton } from "@/components/PlayButton";
import { PlayedStatus } from "@/components/PlayedStatus";
import { SimilarItems } from "@/components/SimilarItems";
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
import { ItemImage } from "@/components/common/ItemImage";
import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors";
import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider";
import { userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import type { import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
@@ -30,12 +8,34 @@ import { useAtom } from "jotai";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { ItemImage } from "@/components/common/ItemImage";
import { DownloadSingleItem } from "@/components/DownloadItem";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
import { PlayButton } from "@/components/PlayButton";
import { PlayedStatus } from "@/components/PlayedStatus";
import { SimilarItems } from "@/components/SimilarItems";
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors";
import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { AddToFavorites } from "./AddToFavorites"; import { AddToFavorites } from "./AddToFavorites";
import { ItemHeader } from "./ItemHeader"; import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails"; import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { MediaSourceSelector } from "./MediaSourceSelector"; import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession"; import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null; const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
export type SelectedOptions = { export type SelectedOptions = {
@@ -45,8 +45,13 @@ export type SelectedOptions = {
subtitleIndex: number; subtitleIndex: number;
}; };
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( interface ItemContentProps {
({ item }) => { item: BaseItemDto;
isOffline: boolean;
}
export const ItemContent: React.FC<ItemContentProps> = React.memo(
({ item, isOffline }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [settings] = useSettings(); const [settings] = useSettings();
const { orientation } = useOrientation(); const { orientation } = useOrientation();
@@ -68,7 +73,16 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
defaultBitrate, defaultBitrate,
defaultMediaSource, defaultMediaSource,
defaultSubtitleIndex, defaultSubtitleIndex,
} = useDefaultPlaySettings(item, settings); } = useDefaultPlaySettings(item!, settings);
const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null),
[api, item],
);
const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
// Needs to automatically change the selected to the default values for default indexes. // Needs to automatically change the selected to the default values for default indexes.
useEffect(() => { useEffect(() => {
@@ -85,8 +99,8 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
defaultMediaSource, defaultMediaSource,
]); ]);
if (!Platform.isTV) { useEffect(() => {
useEffect(() => { if (!Platform.isTV) {
navigation.setOptions({ navigation.setOptions({
headerRight: () => headerRight: () =>
item && ( item && (
@@ -112,22 +126,19 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</View> </View>
), ),
}); });
}, [item]); }
} }, [item, navigation, user]);
useEffect(() => { useEffect(() => {
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP) if (item) {
setHeaderHeight(230); if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
else if (item.Type === "Movie") setHeaderHeight(500); setHeaderHeight(230);
else setHeaderHeight(350); else if (item.Type === "Movie") setHeaderHeight(500);
}, [item.Type, orientation]); else setHeaderHeight(350);
}
}, [item, orientation]);
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]); if (!item || !selectedOptions) return null;
const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
if (!selectedOptions) return null;
return ( return (
<View <View
@@ -168,13 +179,15 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
onLoad={() => setLoadingLogo(false)} onLoad={() => setLoadingLogo(false)}
onError={() => setLoadingLogo(false)} onError={() => setLoadingLogo(false)}
/> />
) : null ) : (
<View />
)
} }
> >
<View className='flex flex-col bg-transparent shrink'> <View className='flex flex-col bg-transparent shrink'>
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'> <View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
<ItemHeader item={item} className='mb-4' /> <ItemHeader item={item} className='mb-2' />
{item.Type !== "Program" && !Platform.isTV && ( {item.Type !== "Program" && !Platform.isTV && !isOffline && (
<View className='flex flex-row items-center justify-start w-full h-16'> <View className='flex flex-row items-center justify-start w-full h-16'>
<BitrateSelector <BitrateSelector
className='mr-1' className='mr-1'
@@ -233,25 +246,34 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
className='grow' className='grow'
selectedOptions={selectedOptions} selectedOptions={selectedOptions}
item={item} item={item}
isOffline={isOffline}
/> />
</View> </View>
{item.Type === "Episode" && ( {item.Type === "Episode" && (
<SeasonEpisodesCarousel item={item} loading={loading} /> <SeasonEpisodesCarousel
item={item}
loading={loading}
isOffline={isOffline}
/>
)} )}
<ItemTechnicalDetails source={selectedOptions.mediaSource} /> {!isOffline && (
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
)}
<OverviewText text={item.Overview} className='px-4 mb-4' /> <OverviewText text={item.Overview} className='px-4 mb-4' />
{item.Type !== "Program" && ( {item.Type !== "Program" && (
<> <>
{item.Type === "Episode" && ( {item.Type === "Episode" && !isOffline && (
<CurrentSeries item={item} className='mb-4' /> <CurrentSeries item={item} className='mb-4' />
)} )}
<CastAndCrew item={item} className='mb-4' loading={loading} /> {!isOffline && (
<CastAndCrew item={item} className='mb-4' loading={loading} />
)}
{item.People && item.People.length > 0 && ( {item.People && item.People.length > 0 && !isOffline && (
<View className='mb-4'> <View className='mb-4'>
{item.People.slice(0, 3).map((person, idx) => ( {item.People.slice(0, 3).map((person, idx) => (
<MoreMoviesWithActor <MoreMoviesWithActor
@@ -264,7 +286,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</View> </View>
)} )}
<SimilarItems itemId={item.Id} /> {!isOffline && <SimilarItems itemId={item.Id} />}
</> </>
)} )}
</View> </View>

View File

@@ -2,8 +2,8 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react"; import type React from "react";
import { View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { GenreTags } from "./GenreTags"; import { GenreTags } from "./GenreTags";
import { Ratings } from "./Ratings";
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader"; import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
import { Ratings } from "./Ratings";
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader"; import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
import { ItemActions } from "./series/SeriesActions"; import { ItemActions } from "./series/SeriesActions";
@@ -33,16 +33,16 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
<ItemActions item={item} /> <ItemActions item={item} />
</View> </View>
{item.Type === "Episode" && ( {item.Type === "Episode" && (
<> <View>
<EpisodeTitleHeader item={item} /> <EpisodeTitleHeader item={item} />
<GenreTags genres={item.Genres!} /> <GenreTags genres={item.Genres!} />
</> </View>
)} )}
{item.Type === "Movie" && ( {item.Type === "Movie" && (
<> <View>
<MoviesTitleHeader item={item} /> <MoviesTitleHeader item={item} />
<GenreTags genres={item.Genres!} /> <GenreTags genres={item.Genres!} />
</> </View>
)} )}
</View> </View>
</View> </View>

View File

@@ -1,11 +1,9 @@
import { formatBitrate } from "@/utils/bitrate";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { import {
BottomSheetBackdrop, BottomSheetBackdrop,
type BottomSheetBackdropProps, type BottomSheetBackdropProps,
BottomSheetModal, BottomSheetModal,
BottomSheetScrollView, BottomSheetScrollView,
BottomSheetView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import type { import type {
MediaSourceInfo, MediaSourceInfo,
@@ -15,15 +13,15 @@ import type React from "react";
import { useMemo, useRef } from "react"; import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { formatBitrate } from "@/utils/bitrate";
import { Badge } from "./Badge"; import { Badge } from "./Badge";
import { Button } from "./Button";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
interface Props { interface Props {
source?: MediaSourceInfo; source?: MediaSourceInfo;
} }
export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => { export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -55,7 +53,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
> >
<BottomSheetScrollView> <BottomSheetScrollView>
<View className='flex flex-col space-y-2 p-4 mb-4'> <View className='flex flex-col space-y-2 p-4 mb-4'>
<View className=''> <View>
<Text className='text-lg font-bold mb-4'> <Text className='text-lg font-bold mb-4'>
{t("item_card.video")} {t("item_card.video")}
</Text> </Text>
@@ -64,7 +62,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
</View> </View>
</View> </View>
<View className=''> <View>
<Text className='text-lg font-bold mb-2'> <Text className='text-lg font-bold mb-2'>
{t("item_card.audio")} {t("item_card.audio")}
</Text> </Text>
@@ -77,7 +75,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
/> />
</View> </View>
<View className=''> <View>
<Text className='text-lg font-bold mb-2'> <Text className='text-lg font-bold mb-2'>
{t("item_card.subtitles")} {t("item_card.subtitles")}
</Text> </Text>
@@ -103,7 +101,7 @@ const SubtitleStreamInfo = ({
}) => { }) => {
return ( return (
<View className='flex flex-col'> <View className='flex flex-col'>
{subtitleStreams.map((stream, index) => ( {subtitleStreams.map((stream, _index) => (
<View key={stream.Index} className='flex flex-col'> <View key={stream.Index} className='flex flex-col'>
<Text className='text-xs mb-3 text-neutral-400'> <Text className='text-xs mb-3 text-neutral-400'>
{stream.DisplayTitle} {stream.DisplayTitle}
@@ -177,15 +175,13 @@ const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
}; };
const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => { const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
if (!source) return null;
const videoStream = useMemo(() => { const videoStream = useMemo(() => {
return source.MediaStreams?.find( return source?.MediaStreams?.find((stream) => stream.Type === "Video") as
(stream) => stream.Type === "Video", | MediaStream
) as MediaStream; | undefined;
}, [source.MediaStreams]); }, [source?.MediaStreams]);
if (!videoStream) return null; if (!source || !videoStream) return null;
return ( return (
<View className='flex-row flex-wrap gap-2'> <View className='flex-row flex-wrap gap-2'>
@@ -223,7 +219,11 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
<Badge <Badge
variant='gray' variant='gray'
iconLeft={<Ionicons name='play-outline' size={16} color='white' />} iconLeft={<Ionicons name='play-outline' size={16} color='white' />}
text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`} text={
videoStream.AverageFrameRate != null
? `${videoStream.AverageFrameRate.toFixed(0)} fps`
: ""
}
/> />
</View> </View>
); );
@@ -236,6 +236,7 @@ const formatFileSize = (bytes?: number | null) => {
if (bytes === 0) return "0 Byte"; if (bytes === 0) return "0 Byte";
const i = Number.parseInt( const i = Number.parseInt(
Math.floor(Math.log(bytes) / Math.log(1024)).toString(), Math.floor(Math.log(bytes) / Math.log(1024)).toString(),
10,
); );
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`; return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
}; };

View File

@@ -1,7 +1,7 @@
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
import type React from "react"; import type React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Text, TouchableOpacity, View } from "react-native"; import { Text, View } from "react-native";
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
import { Button } from "./Button"; import { Button } from "./Button";
import { ListGroup } from "./list/ListGroup"; import { ListGroup } from "./list/ListGroup";
import { ListItem } from "./list/ListItem"; import { ListItem } from "./list/ListItem";

View File

@@ -2,7 +2,6 @@ import {
ActivityIndicator, ActivityIndicator,
type ActivityIndicatorProps, type ActivityIndicatorProps,
Platform, Platform,
View,
} from "react-native"; } from "react-native";
interface Props extends ActivityIndicatorProps {} interface Props extends ActivityIndicatorProps {}

View File

@@ -2,9 +2,11 @@ import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react"; import { useCallback, useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
@@ -20,37 +22,31 @@ export const MediaSourceSelector: React.FC<Props> = ({
selected, selected,
...props ...props
}) => { }) => {
if (Platform.isTV) return null; const isTv = Platform.isTV;
const selectedName = useMemo(
() =>
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
(x) => x.Type === "Video",
)?.DisplayTitle || "",
[item, selected],
);
const { t } = useTranslation(); const { t } = useTranslation();
const commonPrefix = useMemo(() => { const getDisplayName = useCallback((source: MediaSourceInfo) => {
const mediaSources = item.MediaSources || []; const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
if (!mediaSources.length) return ""; if (videoStream?.DisplayTitle) {
return videoStream.DisplayTitle;
let commonPrefix = "";
for (let i = 0; i < mediaSources[0].Name!.length; i++) {
const char = mediaSources[0].Name![i];
if (mediaSources.every((source) => source.Name![i] === char)) {
commonPrefix += char;
} else {
commonPrefix = commonPrefix.slice(0, -1);
break;
}
} }
return commonPrefix;
}, [item.MediaSources]);
const name = (name?: string | null) => { // Fallback to source name
return name?.replace(commonPrefix, "").toLowerCase(); if (source.Name) {
}; return source.Name;
}
// Last resort fallback
return `Source ${source.Id}`;
}, []);
const selectedName = useMemo(() => {
if (!selected) return "";
return getDisplayName(selected);
}, [selected, getDisplayName]);
if (isTv) return null;
return ( return (
<View <View
@@ -88,7 +84,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
}} }}
> >
<DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>
{`${name(source.Name)}`} {getDisplayName(source)}
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.Item> </DropdownMenu.Item>
))} ))}

View File

@@ -1,10 +1,3 @@
import { ItemCardText } from "@/components/ItemCardText";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
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";
@@ -12,6 +5,13 @@ 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 { View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
interface Props extends ViewProps { interface Props extends ViewProps {
actorId: string; actorId: string;

View File

@@ -1,8 +1,8 @@
import { Text } from "@/components/common/Text";
import { tc } from "@/utils/textTools";
import { useState } from "react"; import { useState } 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 { Text } from "@/components/common/Text";
import { tc } from "@/utils/textTools";
interface Props extends ViewProps { interface Props extends ViewProps {
text?: string | null; text?: string | null;

View File

@@ -1,11 +1,6 @@
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import type { PropsWithChildren, ReactElement } from "react"; import type { PropsWithChildren, ReactElement } from "react";
import { import { type NativeScrollEvent, View, type ViewProps } from "react-native";
type NativeScrollEvent,
NativeSyntheticEvent,
View,
type ViewProps,
} from "react-native";
import Animated, { import Animated, {
interpolate, interpolate,
useAnimatedRef, useAnimatedRef,

View File

@@ -1,6 +1,7 @@
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import type React from "react"; import type React from "react";
import { Platform, View, type ViewProps } from "react-native"; import { Platform, View, type ViewProps } from "react-native";
interface Props extends ViewProps { interface Props extends ViewProps {
blurAmount?: number; blurAmount?: number;
blurType?: "light" | "dark" | "xlight"; blurType?: "light" | "dark" | "xlight";

View File

@@ -1,13 +1,3 @@
import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet"; import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
@@ -15,7 +5,6 @@ import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, Pressable } from "react-native";
import { Alert, TouchableOpacity, View } from "react-native"; import { Alert, TouchableOpacity, View } from "react-native";
import CastContext, { import CastContext, {
CastButton, CastButton,
@@ -33,12 +22,23 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import { runtimeTicksToMinutes } from "@/utils/time";
import type { Button } from "./Button"; import type { Button } from "./Button";
import type { SelectedOptions } from "./ItemContent"; import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof Button> { interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto; item: BaseItemDto;
selectedOptions: SelectedOptions; selectedOptions: SelectedOptions;
isOffline?: boolean;
} }
const ANIMATION_DURATION = 500; const ANIMATION_DURATION = 500;
@@ -47,6 +47,7 @@ const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({ export const PlayButton: React.FC<Props> = ({
item, item,
selectedOptions, selectedOptions,
isOffline,
...props ...props
}: Props) => { }: Props) => {
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
@@ -76,7 +77,7 @@ export const PlayButton: React.FC<Props> = ({
} }
router.push(`/player/direct-player?${q}`); router.push(`/player/direct-player?${q}`);
}, },
[router], [router, isOffline],
); );
const onPress = useCallback(async () => { const onPress = useCallback(async () => {
@@ -91,6 +92,8 @@ export const PlayButton: React.FC<Props> = ({
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "", subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "", mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "", bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
offline: isOffline ? "true" : "false",
}); });
const queryString = queryParams.toString(); const queryString = queryParams.toString();

View File

@@ -1,17 +1,9 @@
import { useHaptic } from "@/hooks/useHaptic"; import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } 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 { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { TouchableOpacity, View } from "react-native";
import { Platform } from "react-native";
import { Alert, TouchableOpacity, View } from "react-native";
import Animated, { import Animated, {
Easing, Easing,
interpolate, interpolate,
@@ -22,6 +14,10 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useHaptic } from "@/hooks/useHaptic";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { runtimeTicksToMinutes } from "@/utils/time";
import type { Button } from "./Button"; import type { Button } from "./Button";
import type { SelectedOptions } from "./ItemContent"; import type { SelectedOptions } from "./ItemContent";
@@ -38,12 +34,7 @@ export const PlayButton: React.FC<Props> = ({
selectedOptions, selectedOptions,
...props ...props
}: Props) => { }: Props) => {
const { showActionSheetWithOptions } = useActionSheet();
const { t } = useTranslation();
const [colorAtom] = useAtom(itemThemeColorAtom); const [colorAtom] = useAtom(itemThemeColorAtom);
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const router = useRouter(); const router = useRouter();

View File

@@ -1,5 +1,3 @@
import { useAllSessions, type useSessionsProps } from "@/hooks/useSessions";
import { apiAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { import {
type BaseItemDto, type BaseItemDto,
@@ -15,9 +13,11 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { useAllSessions, type useSessionsProps } from "@/hooks/useSessions";
import { apiAtom } from "@/providers/JellyfinProvider";
import { Text } from "./common/Text";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
import { Text } from "./common/Text";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto; item: BaseItemDto;

View File

@@ -1,50 +1,18 @@
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query";
import type React from "react"; import type React from "react";
import { View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
interface Props extends ViewProps { interface Props extends ViewProps {
items: BaseItemDto[]; items: BaseItemDto[];
isOffline?: boolean;
size?: "default" | "large"; size?: "default" | "large";
} }
export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => { export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
const queryClient = useQueryClient();
const invalidateQueries = () => {
items.forEach((item) => {
queryClient.invalidateQueries({
queryKey: ["item", item.Id],
});
});
queryClient.invalidateQueries({
queryKey: ["resumeItems"],
});
queryClient.invalidateQueries({
queryKey: ["continueWatching"],
});
queryClient.invalidateQueries({
queryKey: ["nextUp-all"],
});
queryClient.invalidateQueries({
queryKey: ["nextUp"],
});
queryClient.invalidateQueries({
queryKey: ["episodes"],
});
queryClient.invalidateQueries({
queryKey: ["seasons"],
});
queryClient.invalidateQueries({
queryKey: ["home"],
});
};
const allPlayed = items.every((item) => item.UserData?.Played); const allPlayed = items.every((item) => item.UserData?.Played);
const toggle = useMarkAsPlayed(items);
const markAsPlayedStatus = useMarkAsPlayed(items);
return ( return (
<View {...props}> <View {...props}>
@@ -52,8 +20,7 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
fillColor={allPlayed ? "primary" : undefined} fillColor={allPlayed ? "primary" : undefined}
icon={allPlayed ? "checkmark" : "checkmark"} icon={allPlayed ? "checkmark" : "checkmark"}
onPress={async () => { onPress={async () => {
console.log(allPlayed); await toggle(!allPlayed);
await markAsPlayedStatus(!allPlayed);
}} }}
size={props.size} size={props.size}
/> />

View File

@@ -1,5 +1,4 @@
import type React from "react"; import type React from "react";
import { StyleSheet, View } from "react-native";
import { AnimatedCircularProgress } from "react-native-circular-progress"; import { AnimatedCircularProgress } from "react-native-circular-progress";
type ProgressCircleProps = { type ProgressCircleProps = {

View File

@@ -1,3 +1,9 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useMemo } from "react";
import { View, type ViewProps } from "react-native";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
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";
@@ -6,12 +12,6 @@ import type {
TvResult, TvResult,
} from "@/utils/jellyseerr/server/models/Search"; } from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useMemo } from "react";
import { View, type ViewProps } from "react-native";
import { Badge } from "./Badge"; import { Badge } from "./Badge";
interface Props extends ViewProps { interface Props extends ViewProps {

View File

@@ -1,4 +1,3 @@
import { useHaptic } from "@/hooks/useHaptic";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
@@ -7,6 +6,7 @@ import {
TouchableOpacity, TouchableOpacity,
type TouchableOpacityProps, type TouchableOpacityProps,
} from "react-native"; } from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
onPress?: () => void; onPress?: () => void;

View File

@@ -1,23 +1,16 @@
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { getLibraryApi } 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 { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { View, type ViewProps } from "react-native";
ScrollView, import MoviePoster from "@/components/posters/MoviePoster";
TouchableOpacity, import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
View,
type ViewProps,
} from "react-native";
import { ItemCardText } from "./ItemCardText";
import { Loader } from "./Loader";
import { HorizontalScroll } from "./common/HorrizontalScroll"; import { HorizontalScroll } from "./common/HorrizontalScroll";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { TouchableItemRouter } from "./common/TouchableItemRouter"; import { TouchableItemRouter } from "./common/TouchableItemRouter";
import { ItemCardText } from "./ItemCardText";
interface SimilarItemsProps extends ViewProps { interface SimilarItemsProps extends ViewProps {
itemId?: string | null; itemId?: string | null;

View File

@@ -1,8 +1,10 @@
import { tc } from "@/utils/textTools";
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react"; import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { tc } from "@/utils/textTools";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
@@ -18,7 +20,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
selected, selected,
...props ...props
}) => { }) => {
if (Platform.isTV) return null; const { t } = useTranslation();
const subtitleStreams = useMemo(() => { const subtitleStreams = useMemo(() => {
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle"); return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
}, [source]); }, [source]);
@@ -28,9 +30,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
[subtitleStreams, selected], [subtitleStreams, selected],
); );
if (subtitleStreams?.length === 0) return null; if (Platform.isTV || subtitleStreams?.length === 0) return null;
const { t } = useTranslation();
return ( return (
<View <View

View File

@@ -1,4 +1,3 @@
import * as React from "react";
import renderer from "react-test-renderer"; import renderer from "react-test-renderer";
import { ThemedText } from "../ThemedText"; import { ThemedText } from "../ThemedText";

View File

@@ -1,5 +1,5 @@
import { Text } from "@/components/common/Text";
import { View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
interface Props extends ViewProps {} interface Props extends ViewProps {}

View File

@@ -1,7 +1,6 @@
import { useMemo } from "react"; import { View, type ViewProps } from "react-native";
import { StyleSheet, View, type ViewProps } from "react-native";
const getItemStyle = (index: number, numColumns: number) => { const _getItemStyle = (index: number, numColumns: number) => {
const alignItems = (() => { const alignItems = (() => {
if (numColumns < 2 || index % numColumns === 0) return "flex-start"; if (numColumns < 2 || index % numColumns === 0) return "flex-start";
if ((index + 1) % numColumns === 0) return "flex-end"; if ((index + 1) % numColumns === 0) return "flex-end";

View File

@@ -1,13 +1,14 @@
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "@/components/common/Text";
import DisabledSetting from "@/components/settings/DisabledSetting"; import {
import React, {
type PropsWithChildren, type PropsWithChildren,
type ReactNode, type ReactNode,
useEffect, useEffect,
useState, useState,
} from "react"; } from "react";
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native"; import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import DisabledSetting from "@/components/settings/DisabledSetting";
interface Props<T> { interface Props<T> {
data: T[]; data: T[];
@@ -33,14 +34,17 @@ const Dropdown = <T,>({
multiple = false, multiple = false,
...props ...props
}: PropsWithChildren<Props<T> & ViewProps>) => { }: PropsWithChildren<Props<T> & ViewProps>) => {
if (Platform.isTV) return null; const isTv = Platform.isTV;
const [selected, setSelected] = useState<T[]>(); const [selected, setSelected] = useState<T[]>();
useEffect(() => { useEffect(() => {
if (selected !== undefined) { if (selected !== undefined) {
onSelected(...selected); onSelected(...selected);
} }
}, [selected]); }, [selected, onSelected]);
if (isTv) return null;
return ( return (
<DisabledSetting disabled={disabled === true} showText={false} {...props}> <DisabledSetting disabled={disabled === true} showText={false} {...props}>
@@ -58,7 +62,7 @@ const Dropdown = <T,>({
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : ( ) : (
<>{title}</> title
)} )}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
@@ -71,7 +75,7 @@ const Dropdown = <T,>({
sideOffset={0} sideOffset={0}
> >
<DropdownMenu.Label>{label}</DropdownMenu.Label> <DropdownMenu.Label>{label}</DropdownMenu.Label>
{data.map((item, idx) => {data.map((item, _idx) =>
multiple ? ( multiple ? (
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
value={ value={
@@ -80,7 +84,10 @@ const Dropdown = <T,>({
: "off" : "off"
} }
key={keyExtractor(item)} key={keyExtractor(item)}
onValueChange={(next: "on" | "off", previous: "on" | "off") => { onValueChange={(
next: "on" | "off",
_previous: "on" | "off",
) => {
setSelected((p) => { setSelected((p) => {
const prev = p || []; const prev = p || [];
if (next === "on") { if (next === "on") {

View File

@@ -1,4 +1,3 @@
import { Text } from "@/components/common/Text";
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 { useRouter } from "expo-router";
@@ -6,8 +5,6 @@ import {
Platform, Platform,
TouchableOpacity, TouchableOpacity,
type TouchableOpacityProps, type TouchableOpacityProps,
View,
ViewProps,
} from "react-native"; } from "react-native";
interface Props extends BlurViewProps { interface Props extends BlurViewProps {

View File

@@ -1,5 +1,5 @@
import { FlashList, type FlashListProps } from "@shopify/flash-list"; import { FlashList, type FlashListProps } from "@shopify/flash-list";
import React, { forwardRef, useImperativeHandle, useRef } from "react"; import React, { useImperativeHandle, useRef } from "react";
import { View, type ViewStyle } from "react-native"; import { View, type ViewStyle } from "react-native";
import { Text } from "./Text"; import { Text } from "./Text";
@@ -19,64 +19,59 @@ interface HorizontalScrollProps<T>
keyExtractor?: (item: T, index: number) => string; keyExtractor?: (item: T, index: number) => string;
containerStyle?: ViewStyle; containerStyle?: ViewStyle;
contentContainerStyle?: ViewStyle; contentContainerStyle?: ViewStyle;
loadingContainerStyle?: ViewStyle;
height?: number; height?: number;
loading?: boolean; loading?: boolean;
extraData?: any; extraData?: any;
noItemsText?: string; noItemsText?: string;
} }
export const HorizontalScroll = forwardRef< export const HorizontalScroll = <T,>(
HorizontalScrollRef, props: HorizontalScrollProps<T> & {
HorizontalScrollProps<any> ref?: React.ForwardedRef<HorizontalScrollRef>;
>( },
<T,>( ) => {
{ const {
data = [], data = [],
keyExtractor, keyExtractor,
renderItem, renderItem,
containerStyle, containerStyle,
contentContainerStyle, contentContainerStyle,
loadingContainerStyle, loading = false,
loading = false, height = 164,
height = 164, extraData,
extraData, noItemsText,
noItemsText, ref,
...props ...restProps
}: HorizontalScrollProps<T>, } = props;
ref: React.ForwardedRef<HorizontalScrollRef>,
) => {
const flashListRef = useRef<FlashList<T>>(null);
useImperativeHandle(ref!, () => ({ const flashListRef = useRef<FlashList<T>>(null);
scrollToIndex: (index: number, viewOffset: number) => {
flashListRef.current?.scrollToIndex({
index,
animated: true,
viewPosition: 0,
viewOffset,
});
},
}));
const renderFlashListItem = ({ useImperativeHandle(ref!, () => ({
item, scrollToIndex: (index: number, viewOffset: number) => {
index, flashListRef.current?.scrollToIndex({
}: { index,
item: T; animated: true,
index: number; viewPosition: 0,
}) => <View className='mr-2'>{renderItem(item, index)}</View>; viewOffset,
});
},
}));
if (!data || loading) { const renderFlashListItem = ({ item, index }: { item: T; index: number }) => (
return ( <View className='mr-2'>{renderItem(item, index)}</View>
<View className='px-4 mb-2'> );
<View className='bg-neutral-950 h-24 w-full rounded-md mb-2' />
<View className='bg-neutral-950 h-10 w-full rounded-md mb-1' />
</View>
);
}
if (!data || loading) {
return ( return (
<View className='px-4 mb-2'>
<View className='bg-neutral-950 h-24 w-full rounded-md mb-2' />
<View className='bg-neutral-950 h-10 w-full rounded-md mb-1' />
</View>
);
}
return (
<View style={[{ height }, containerStyle]}>
<FlashList<T> <FlashList<T>
ref={flashListRef} ref={flashListRef}
data={data} data={data}
@@ -97,8 +92,8 @@ export const HorizontalScroll = forwardRef<
</Text> </Text>
</View> </View>
)} )}
{...props} {...restProps}
/> />
); </View>
}, );
); };

View File

@@ -1,4 +1,3 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import type { import type {
BaseItemDto, BaseItemDto,
BaseItemDtoQueryResult, BaseItemDtoQueryResult,
@@ -14,6 +13,7 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Loader } from "../Loader"; import { Loader } from "../Loader";
import { Text } from "./Text"; import { Text } from "./Text";
@@ -56,7 +56,7 @@ export function InfiniteHorizontalScroll({
}; };
}); });
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({ const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey, queryKey,
queryFn, queryFn,
getNextPageParam: (lastPage, pages) => { getNextPageParam: (lastPage, pages) => {

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