Compare commits

...

176 Commits

Author SHA1 Message Date
Uruk
9a17e36882 fix: normalize base URL in SeerrApi constructor to ensure protocol prefix 2026-01-14 21:50:18 +01:00
Uruk
391ca897a5 docs: update internationalization guidelines for translation management 2026-01-14 20:31:21 +01:00
Gauvain
c8fefdf4a1 Merge branch 'develop' into remove-optimized-server 2026-01-14 20:08:05 +01:00
Gauvain
ae658aa5b0 fix: remove Android emulator detection from MPV player (#1369) 2026-01-14 16:46:17 +01:00
Uruk
956eea8848 docs: enhance TypeScript standards and remove type suppressions
Strengthens code quality guidelines by establishing strict TypeScript practices that prohibit `any` types and minimize type suppressions.

Updates Copilot instructions to emphasize production-ready code with comprehensive type safety rules, error handling requirements, and reliability standards. Explicitly discourages type escape hatches in favor of proper type definitions.

Refactors navigation implementation to use URLSearchParams instead of object-based params, eliminating the need for type suppression while maintaining functionality.

Removes unnecessary type error suppressions and unused properties throughout codebase, aligning with new standards.
2026-01-14 16:27:37 +01:00
Uruk
895c245254 chore: add translation cleanup tooling and remove unused keys
- Add check-unused-translations.js script for detecting unused i18n keys
- Remove unused player keys: refresh_tracks, audio_tracks, playback_state, index
- Remove unused aspect_ratio section (7 keys)
- Update copilot-instructions.md with i18n guidelines (only edit en.json, Crowdin handles other languages)
2026-01-14 14:51:42 +01:00
Uruk
376d2e84da docs: add internationalization guidelines to Copilot instructions 2026-01-14 14:16:53 +01:00
Alex
81f79a54af fix(mpv): Add progress throttling for mpv (#1366)
Some checks failed
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-14 13:14:52 +01:00
renovate[bot]
ca1b640a61 chore(deps): Update dependency @babel/core to v7.28.6 (#1363)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 11:41:57 +01:00
renovate[bot]
e771949c95 chore(deps): Update dependency @tanstack/react-query to v5.90.17 (#1365)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 10:57:40 +01:00
Uruk
12047cbe12 fix: correct dependency arrays and add null checks
Fixes missing dependencies in useMemo and useCallback hooks to prevent stale closures and potential bugs.

Adds null/undefined guards before navigation in music components to prevent crashes when attempting to navigate with missing IDs.

Corrects query key from "company" to "genre" in genre page to ensure proper cache invalidation.

Updates Jellyseerr references to Seerr throughout documentation and error messages for consistency.

Improves type safety by adding error rejection handling in SeerrApi and memoizing components to optimize re-renders.
2026-01-14 10:24:57 +01:00
Gauvain
32f4bbcc7d Merge branch 'develop' into remove-optimized-server 2026-01-14 10:13:15 +01:00
renovate[bot]
78bfa68a17 chore(deps): Update dependency react-i18next to v16.5.3 (#1364)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 10:06:03 +01:00
renovate[bot]
ac59615d79 chore(deps): Update dependency jotai to v2.16.2 (#1329)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 09:17:32 +01:00
Fredrik Burmester
4dd80cd8f5 Merge branch 'develop' into fix/external-sub-selection
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-13 22:43:05 +01:00
renovate[bot]
db9f02b225 chore(deps): Update BRAINSia/free-disk-space action to v2.1.3 (#1358)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:42:18 +01:00
renovate[bot]
7a0bbb1084 chore(deps): Update actions/upload-artifact action to v6 (#1362)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:20:42 +01:00
renovate[bot]
05925530c0 chore(deps): Update actions/checkout action to v6 (#1361)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:20:28 +01:00
renovate[bot]
625a292e26 chore(deps): Update actions/cache action to v5 (#1360)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:20:15 +01:00
renovate[bot]
1acd3102ea chore(deps): Update oven-sh/setup-bun action to v2.1.0 (#1359)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:19:58 +01:00
renovate[bot]
543881dc41 chore(deps): Update BRAINSia/free-disk-space digest to 7ef2f7e (#1357)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:19:27 +01:00
Cristea Florian Victor
5d93483dc2 feat: xcode build script (#1296)
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2026-01-13 19:32:58 +01:00
Gauvain
d54a29020a fix(ci): code scanning alert no. 219 (#1353)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-01-13 18:41:56 +01:00
renovate[bot]
1d04e39b85 chore(deps): Update github/codeql-action action to v4.31.10 (#1352)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 15:05:27 +01:00
Alex Kim
ecc62259fc Re-add config version 2026-01-13 22:21:19 +11:00
Alex Kim
ffd96e05fe Merge branch 'fix/external-sub-selection' of github.com:streamyfin/streamyfin into fix/external-sub-selection 2026-01-13 22:19:58 +11:00
Alex Kim
8541ba02d4 Add android version for stopping auto selection of subtitles 2026-01-13 22:19:47 +11:00
Chris
6c955d8a2a Enhance README with Discord link and badge
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Added Discord link and badge to the README.
2026-01-12 22:06:18 +01:00
Fredrik Burmester
b0bb6c6c9a feat: add technical stream info overlay for MPV player 2026-01-12 21:55:32 +01:00
Fredrik Burmester
82abc291d4 Merge branch 'develop' into fix/external-sub-selection 2026-01-12 21:02:16 +01:00
renovate[bot]
3da4b42ca3 chore(deps): Update dependency @tanstack/react-query to v5.90.16 (#1328)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 15:16:38 +01:00
Alex Kim
16940075b2 Stop external subs from being selected when added 2026-01-12 22:50:36 +11:00
renovate[bot]
a3bbb1bc3a chore(deps): Update dependency @types/lodash to v4.17.23 (#1344)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 12:11:05 +01:00
Uruk
138a86f473 chore: remove unused region variable in person page 2026-01-12 11:43:12 +01:00
Uruk
6dd111defe fix: resolve merge conflict in useSeerr.ts - keep improved BCP 47 locale logic 2026-01-12 11:40:13 +01:00
Uruk
4ba03b669e docs: restructure README to improve clarity and organization
Reorganizes the README documentation to better communicate features and setup instructions.

Renames "Experimental Features" section to "How It Works" to better reflect the maturity and purpose of the features. Removes emphasis on experimental status of downloads and Chromecast, consolidating information under clearer headings.

Improves formatting and consistency throughout:
- Standardizes heading levels and removes emoji prefixes from subsections
- Enhances development setup instructions with better formatting and clearer steps
- Improves license section readability with proper paragraph spacing
- Adds consistent punctuation to section endings

Removes detailed feature descriptions for music library and consolidates search providers information under a dedicated subsection, making the document more scannable and focused on how the app works rather than listing every feature.
2026-01-12 11:35:05 +01:00
Gauvain
aacf5327d1 Merge branch 'develop' into remove-optimized-server 2026-01-12 11:06:24 +01:00
renovate[bot]
1874c116a6 chore(deps): Update dependency react-i18next to v16.5.2 (#1345)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 11:05:17 +01:00
Uruk
f543fa9e3e refactor: remove unused code and simplify implementations
Removes extensive dead code including unused components, utilities, and augmentations that were no longer referenced in the codebase.

Simplifies play settings logic by removing complex stream ranking algorithm in favor of direct previous index matching for audio and subtitle selections.

Removes aspectRatio prop from video player as it was set to a constant "default" value and never changed.

Inlines POSTER_CAROUSEL_HEIGHT constant directly where used instead of importing from centralized constants file.

Eliminates unused features including image color extraction for TV platforms, M3U8 subtitle parsing, and various Jellyfin API helpers that were no longer needed.

Cleans up credential management by making internal helper functions private that should not be exposed to external consumers.
2026-01-12 11:04:23 +01:00
Uruk
4385fe5502 fix: correct capitalization in UI messages
Standardizes capitalization in user-facing messages to follow proper sentence casing conventions.

Changes "Downloaded" to "downloaded" in the no internet message and "No Trailer Available" to "No trailer available" for consistency with standard UI text formatting guidelines.
2026-01-12 10:09:01 +01:00
Uruk
3d72c9c783 Merge branch 'remove-optimized-server' of https://github.com/streamyfin/streamyfin into remove-optimized-server 2026-01-12 09:44:11 +01:00
Uruk
e41d1b4818 fix: hide tags dropdown when no tags are available
Prevents the tags selection UI from rendering when the service has no tags configured.

Wraps the tags dropdown component in a conditional check to only display when tags exist in the default service details, avoiding empty or broken UI states.
2026-01-12 09:41:24 +01:00
Uruk
fd3766fc23 fix: add missing React keys to streamystats sections
Resolves React warning about missing keys in list rendering by converting fragment to array with unique key props for each streamystats component.

Also wraps promoted watchlists in a View container to properly handle props spreading, preventing props from being passed through to individual watchlist sections.
2026-01-12 09:41:24 +01:00
Uruk
f211a9ce7a refactor: rename Jellyseerr to Seerr throughout codebase
Updates branding and naming conventions to use "Seerr" instead of "Jellyseerr" across all files, components, hooks, and translations.

Renames files, functions, classes, variables, and UI text to reflect the new naming convention while maintaining identical functionality. Updates asset references including logo and screenshot images.

Changes API class name, storage keys, atom names, and all related utilities to use "Seerr" prefix. Modifies translation keys and user-facing text to match the rebrand.
2026-01-12 09:41:24 +01:00
Uruk
b7db06f53d docs: update README and fix typos across codebase
Enhances README with comprehensive feature categorization including Media Playback, Media Management, and Advanced Features sections

Expands documentation for music library support, search providers (Marlin, Streamystats, Jellysearch), and plugin functionality

Updates FAQ section with more detailed answers about library visibility, downloads, subtitles, TV platform support, and Seerr integration

Corrects typos throughout the application:
- Fixes "liraries" to "libraries" in hide libraries settings
- Changes "centralised" to "centralized" for consistency
- Updates "Jellyseerr" references to "Seerr" throughout codebase

Adds missing translations for player settings including aspect ratio options, alignment controls, and MPV subtitle customization

Improves consistency in capitalization and punctuation across translation strings
2026-01-12 09:41:24 +01:00
Uruk
ceac74dbfa refactor: remove unused download settings components
Removes empty download settings component files and simplifies download feature description in translations.

The download settings components contained only placeholder implementations that returned null, indicating they were not in use. Updates the feature description to remove references to optimized server and background downloads, providing a clearer and more straightforward explanation of the download functionality.
2026-01-12 09:41:24 +01:00
Uruk
b6bd427e19 style: standardizes capitalization and punctuation in English translations
Improves consistency across user-facing text by:

- Normalizing sentence case in error messages and labels (e.g., "Username is required" instead of "Username Is Required")
- Fixing inconsistent capitalization in feature descriptions and UI text
- Standardizing punctuation, particularly replacing semicolons where appropriate and ensuring consistent usage of "log in" vs "login"
- Correcting article usage ("the left side" instead of "left side")
- Updating terminology consistency (e.g., "tv shows" instead of "tv-shows", "optimized" instead of "optimize")

These changes enhance readability and professionalism throughout the application interface.
2026-01-12 09:41:24 +01:00
Uruk
47bf1c9201 fix: hide tags dropdown when no tags are available
Prevents the tags selection UI from rendering when the service has no tags configured.

Wraps the tags dropdown component in a conditional check to only display when tags exist in the default service details, avoiding empty or broken UI states.
2026-01-12 09:40:50 +01:00
Uruk
4aaddd2104 fix: add missing React keys to streamystats sections
Resolves React warning about missing keys in list rendering by converting fragment to array with unique key props for each streamystats component.

Also wraps promoted watchlists in a View container to properly handle props spreading, preventing props from being passed through to individual watchlist sections.
2026-01-12 09:37:49 +01:00
Uruk
4a75e8f551 refactor: rename Jellyseerr to Seerr throughout codebase
Updates branding and naming conventions to use "Seerr" instead of "Jellyseerr" across all files, components, hooks, and translations.

Renames files, functions, classes, variables, and UI text to reflect the new naming convention while maintaining identical functionality. Updates asset references including logo and screenshot images.

Changes API class name, storage keys, atom names, and all related utilities to use "Seerr" prefix. Modifies translation keys and user-facing text to match the rebrand.
2026-01-12 09:26:19 +01:00
Simon Eklundh
7a0f70778d fix: fix music videos and home videos on library (#1326)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2026-01-11 22:31:25 +01:00
Simon Eklundh
6957c4fd64 feat: add autorotate for landscape (#1265)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2026-01-11 22:27:19 +01:00
Uruk
0c8c27bfc0 docs: update README and fix typos across codebase
Enhances README with comprehensive feature categorization including Media Playback, Media Management, and Advanced Features sections

Expands documentation for music library support, search providers (Marlin, Streamystats, Jellysearch), and plugin functionality

Updates FAQ section with more detailed answers about library visibility, downloads, subtitles, TV platform support, and Seerr integration

Corrects typos throughout the application:
- Fixes "liraries" to "libraries" in hide libraries settings
- Changes "centralised" to "centralized" for consistency
- Updates "Jellyseerr" references to "Seerr" throughout codebase

Adds missing translations for player settings including aspect ratio options, alignment controls, and MPV subtitle customization

Improves consistency in capitalization and punctuation across translation strings
2026-01-11 22:15:43 +01:00
Uruk
67e61f3ab8 refactor: remove unused download settings components
Removes empty download settings component files and simplifies download feature description in translations.

The download settings components contained only placeholder implementations that returned null, indicating they were not in use. Updates the feature description to remove references to optimized server and background downloads, providing a clearer and more straightforward explanation of the download functionality.
2026-01-11 20:09:48 +01:00
Uruk
c66541ce4d style: standardizes capitalization and punctuation in English translations
Improves consistency across user-facing text by:

- Normalizing sentence case in error messages and labels (e.g., "Username is required" instead of "Username Is Required")
- Fixing inconsistent capitalization in feature descriptions and UI text
- Standardizing punctuation, particularly replacing semicolons where appropriate and ensuring consistent usage of "log in" vs "login"
- Correcting article usage ("the left side" instead of "left side")
- Updating terminology consistency (e.g., "tv shows" instead of "tv-shows", "optimized" instead of "optimize")

These changes enhance readability and professionalism throughout the application interface.
2026-01-11 20:04:07 +01:00
github-actions[bot]
1c0ed82deb feat: New Crowdin Translations (#1343)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-01-11 17:39:23 +01:00
Alex
ad54823f96 refactor: downloads to minimize prop drilling and improve layout and design (#1337)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: Simon-Eklundh <simon.eklundh@proton.me>
2026-01-11 17:38:41 +01:00
Fredrik Burmester
cfa638afc6 chore: deps
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-11 13:12:03 +01:00
Fredrik Burmester
467bea7192 feat(network): add local network auto-switch feature (#1334) 2026-01-11 13:08:14 +01:00
github-actions[bot]
ac9ac5d423 feat: New Crowdin Translations (#1341)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-01-11 13:07:53 +01:00
Fredrik Burmester
62b45121e5 feat(settings): add toggle to disable auto-play next episode (#1342) 2026-01-11 13:07:38 +01:00
Fredrik Burmester
0e238ad10e feat(ios): glassview container for badges 2026-01-11 11:48:30 +01:00
Fredrik Burmester
ce793e3469 fix: ensure continue watching overlay appears above controls 2026-01-11 11:30:28 +01:00
Fredrik Burmester
beba4853b9 Revert "feat(settings): add toggle to disable auto-play next episode"
This reverts commit d1b15a9dde.
2026-01-11 10:19:35 +01:00
Fredrik Burmester
d1b15a9dde feat(settings): add toggle to disable auto-play next episode
Add new setting to completely disable the auto-play next episode feature.
When disabled, the countdown button is hidden and the max episode count
setting appears greyed out.
2026-01-11 10:16:10 +01:00
Fredrik Burmester
12847894df feat(watchlists): add see all navigation for promoted watchlists
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-11 00:19:31 +01:00
Fredrik Burmester
773eae8cb9 feat(home): add see all navigation from recently added sections 2026-01-11 00:16:18 +01:00
Fredrik Burmester
781c309332 feat(search): lower debounce to 200ms and add AbortController using TanStack Pacer 2026-01-10 23:51:16 +01:00
Fredrik Burmester
8a7f5fd4c5 chore: ai docs 2026-01-10 22:54:08 +01:00
Fredrik Burmester
838c8f48a3 fix(mpv-player): resolve swift compiler warnings 2026-01-10 21:57:53 +01:00
Fredrik Burmester
399eb0a351 fix(chromecast): add missing MediaInfo fields
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-10 21:32:09 +01:00
Fredrik Burmester
f1575ca48b feat: MPV player for both Android and iOS with added HW decoding PiP (with subtitles) (#1332)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
Co-authored-by: Alex <111128610+Alexk2309@users.noreply.github.com>
Co-authored-by: Simon-Eklundh <simon.eklundh@proton.me>
2026-01-10 19:35:27 +01:00
github-actions[bot]
df2f44e086 feat: New Crowdin Translations (#1333)
Some checks failed
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-01-10 12:13:30 +01:00
Fredrik Burmester
9d566d697d fix: crowdin setting 2026-01-10 11:35:10 +01:00
Fredrik Burmester
35f7257a6d chore(claude): add /reflect skill for session learnings 2026-01-10 11:33:07 +01:00
Fredrik Burmester
d4ab62bdd6 chore: update ai doc 2026-01-10 11:33:07 +01:00
Fredrik Burmester
ca7591ad21 fix: add back redirect for startup tab 2026-01-10 11:33:07 +01:00
Fredrik Burmester
7514bc2c2c fix: redirect when intro modal first time 2026-01-10 11:33:07 +01:00
Fredrik Burmester
241f8c949a fix: cache issues with songs, partial downloads, component rerenders, etc 2026-01-10 11:33:07 +01:00
Fredrik Burmester
f369738f7b fix: remove console errors when offline - since offline is supported 2026-01-10 11:33:07 +01:00
Fredrik Burmester
ac6288d9a4 Update Crowdin configuration file 2026-01-10 11:18:56 +01:00
lance chant
91128944c6 fix: fixing an issue where offline playback wasn't working/tracking when back online (#1239)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2026-01-09 09:17:35 +01:00
Fredrik Burmester
81449963fa feat(auth): add multi-account support with PIN/password protection 2026-01-09 08:37:30 +01:00
Fredrik Burmester
8569cd390b fix(android): quick connect not showing bc keyboard 2026-01-09 07:56:17 +01:00
Fredrik Burmester
3959aa2f72 fix(android): convert intro modal to bottom sheet bc broken on smaller screens 2026-01-09 07:15:53 +01:00
Fredrik Burmester
097001b092 fix(player): load external subtitles for offline playback
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-08 21:51:27 +01:00
Fredrik Burmester
d1387ec725 fix(downloads): preserve audio track selection for offline playback 2026-01-08 21:51:27 +01:00
Fredrik Burmester
51ecde1565 fix(vlc): add audio transcoding mode to fix 7.1 TrueHD playback 2026-01-08 21:51:27 +01:00
Gauvain
0a0da687d5 ci(crowdin): include untranslated files in exports (#1323)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-08 13:32:18 +01:00
Gauvain
0a3cc87b2a chore(i18n): include untranslated strings in Crowdin sync (#1321)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-07 22:55:13 +01:00
Gauvain
77596bd788 fix(ci): use default GITHUB_TOKEN for Crowdin workflow (#1320) 2026-01-07 22:48:41 +01:00
Fredrik Burmester
630e7a1a36 chore: fix deps 2026-01-07 22:38:28 +01:00
renovate[bot]
04bc412bce chore(deps): Update dependency expo-constants to v18.0.13 (#1319)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:36:43 +01:00
Fredrik Burmester
99aa6bd342 fix(player): hide sub size when vlc 2026-01-07 22:20:11 +01:00
renovate[bot]
a3751c4801 chore(deps): Update dependency @biomejs/biome to v2.3.11 (#1228)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:19:03 +01:00
renovate[bot]
e8ddd1cad7 chore(deps): Pin dependencies (#1235)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:18:40 +01:00
Fredrik Burmester
be8651357b feat(player): vlc subtitle options 2026-01-07 22:18:11 +01:00
Fredrik Burmester
588c8ffeb5 fix(player): audio and subtitle dropdown not visible in player 2026-01-07 22:18:11 +01:00
renovate[bot]
3e01310466 chore(deps): Update github/codeql-action action to v4.31.9 (#1230)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:11:51 +01:00
renovate[bot]
f3417b5a75 chore(deps): Update dependency lint-staged to v16.2.7 (#1236)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:11:25 +01:00
renovate[bot]
f97d7d78c1 chore(deps): Update dependency react-native-mmkv to v4.0.1 (#1238)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:10:49 +01:00
renovate[bot]
eaa519fdda chore(deps): Update dependency @types/lodash to v4.17.21 (#1248)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:10:39 +01:00
renovate[bot]
d84ff82590 chore(deps): Update dependency react-i18next to v16.5.1 (#1249)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:10:30 +01:00
renovate[bot]
ea271b8cdd chore(deps): Update dependency expo-doctor to v1.17.14 (#1260)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:10:22 +01:00
renovate[bot]
b6270f1157 chore(deps): Update crowdin/github-action action to v2.13.0 (#1263)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:09:53 +01:00
renovate[bot]
fe6299c22b chore(deps): Update actions/checkout action to v6 (#1264)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:09:43 +01:00
renovate[bot]
88722b3e17 chore(deps): Update actions/cache action to v5 (#1280)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:09:32 +01:00
renovate[bot]
1a4159ffee chore(deps): Update actions/upload-artifact action to v6 (#1281)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:09:13 +01:00
renovate[bot]
bd13791a83 chore(deps): Update peter-evans/create-pull-request action to v8 (#1282)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:09:00 +01:00
renovate[bot]
968c3e35d5 chore(deps): Update dependency @react-native-community/cli to v20.1.0 (#1317)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:08:42 +01:00
renovate[bot]
b822e315a8 chore(deps): Update oven-sh/setup-bun action to v2.1.0 (#1318)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:08:26 +01:00
Fredrik Burmester
f862a2424e fix(player): restore missing subtitle and audio dropdown for vlc
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-07 20:25:32 +01:00
Simon Eklundh
77ee4aca27 chore: Delete .github/workflows/stale.yml (#1254)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-07 07:32:43 +01:00
Fredrik Burmester
d4ea8debfa feat: marquee for music title in modal
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-06 19:15:17 +01:00
Fredrik Burmester
e1dd410f73 feat: airplay and chromecast for music 2026-01-06 19:10:19 +01:00
Fredrik Burmester
896c7460df refactor(music): move storage clear options 2026-01-06 18:15:53 +01:00
Fredrik Burmester
3453fd22b8 fix: better music modal design and favorite song 2026-01-06 17:55:37 +01:00
Fredrik Burmester
1c3369c61f feat: search for artists, albums and songs
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2026-01-06 16:40:47 +01:00
Fredrik Burmester
99c9caf03c feat: 3-dot menu open song options 2026-01-06 16:03:25 +01:00
Fredrik Burmester
055357de60 feat: swipe to remove individual server logins 2026-01-06 15:46:12 +01:00
Fredrik Burmester
a24e254a9e fix: better animation 2026-01-06 15:37:13 +01:00
Fredrik Burmester
4bdb8003bb chore: lint 2026-01-06 15:28:58 +01:00
Fredrik Burmester
9825ccce6e fix: width to be same as bottom tab bar ios specifically 2026-01-06 15:27:08 +01:00
Fredrik Burmester
9bfac2a4d9 fix: improve downloaded music queue
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-05 21:32:41 +01:00
Fredrik Burmester
478052599f chore: version 2026-01-05 21:32:41 +01:00
Fredrik Burmester
37b0b10098 feat: save login credentials when switching servers 2026-01-05 21:32:41 +01:00
Fredrik Burmester
9ca852bb7e fix: intro can't be closed on small phones / large zoom 2026-01-05 21:32:41 +01:00
Fredrik Burmester
a37a8753c2 revert: remove accidentally merged ios widget commits
Reverts commits d58c1a77 (feat: ios widget) and 08fe871d (wip: widget)
that were accidentally merged from feat/ios-widget-build-target branch.
2026-01-05 21:32:41 +01:00
Fredrik Burmester
5eae6e6cd0 fix(websocket): persist reconnect counter across retry attempts
Move reconnectAttempts from local variable to useRef so it persists
across recursive connectWebSocket calls. Previously, each call reset
the counter to 0, causing infinite reconnection attempts instead of
stopping at the max of 5.

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

Uses regex to properly match key=value pairs where values can be either
quoted (containing any characters except quotes) or unquoted (no commas).
2026-01-05 21:32:41 +01:00
Fredrik Burmester
964d53cc79 feat(music): add haptic feedback to track options menu 2026-01-05 21:32:41 +01:00
Fredrik Burmester
a26980ddab refactor: improve music design 2026-01-05 21:32:41 +01:00
Fredrik Burmester
090ed98233 fix: refresh data on open page in background replace cached data 2026-01-05 21:32:41 +01:00
Fredrik Burmester
24d04c1003 fix: BOOT_COMPLETED runtime protection 2026-01-05 21:32:41 +01:00
Fredrik Burmester
7da52441ab chore: version 2026-01-05 21:32:41 +01:00
Fredrik Burmester
70268e6120 fix: use vlc build with 16kb page size 2026-01-05 21:32:41 +01:00
Fredrik Burmester
96fbb9fe1f fix: target version 35 2026-01-05 21:32:40 +01:00
Fredrik Burmester
3b104b91fc chore: version 2026-01-05 21:32:40 +01:00
Fredrik Burmester
e4134d6f9a fix: item content header button colors 2026-01-05 21:32:40 +01:00
Fredrik Burmester
5b2e7b3883 fix: lower build taget to solve 16kb page requirement
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2026-01-05 08:48:57 +01:00
Elias
1fde3c82a3 feat: add Dolby Vision badge (#1177) 2026-01-05 08:48:40 +01:00
Fredrik Burmester
054fb05651 feat: delete playlist
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-01-04 23:01:03 +01:00
Fredrik Burmester
a2058a8009 fix: music downloading not playing + queue drag and drop
Some checks failed
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2026-01-04 16:28:35 +01:00
Fredrik Burmester
d22827bc9b feat: add toggle to hide streamystats watchlist tab/functions 2026-01-04 14:31:55 +01:00
Fredrik Burmester
4121502bfe fix: remove song from playlist 2026-01-04 13:44:53 +01:00
Fredrik Burmester
b6e59aab01 chore: version 2026-01-04 12:54:28 +01:00
Fredrik Burmester
ab3465aec5 feat: cache and download music 2026-01-04 12:50:41 +01:00
Fredrik Burmester
b1da9f8777 feat: add actions to song rows 2026-01-04 00:09:21 +01:00
Fredrik Burmester
36d24176ae fix: music rows full width 2026-01-03 23:55:52 +01:00
Fredrik Burmester
bfdc2c053b fix: improve gestures 2026-01-03 23:51:27 +01:00
Fredrik Burmester
245c9597c4 feat: music bar geastures 2026-01-03 23:44:15 +01:00
Fredrik Burmester
966a8e8f24 feat: music info badges in music modal 2026-01-03 23:23:40 +01:00
Fredrik Burmester
f941c88457 fix: device profile for music for rntp ios/android 2026-01-03 23:08:46 +01:00
lance chant
bd4e5bb70a fix: jellyseer categories (#1233)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2026-01-03 19:58:17 +01:00
Fredrik Burmester
9334263414 chore: update submodule 2026-01-03 19:49:44 +01:00
Fredrik Burmester
4ae3c44d02 fix: bug in playback speed 2026-01-03 19:49:39 +01:00
Fredrik Burmester
4fb3fb195c fix: ignore android build files 2026-01-03 19:49:18 +01:00
Fredrik Burmester
e8089cfd20 fix: key id fail 2026-01-03 19:39:04 +01:00
Fredrik Burmester
039bf9729a feat: hide volume or/and brightness in controls setting 2026-01-03 19:34:45 +01:00
Fredrik Burmester
3ff7c47b7f feat: playback speed options 2026-01-03 19:14:20 +01:00
Fredrik Burmester
1d8d92175a fix: correct control buttions top right for each player
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2026-01-03 18:22:38 +01:00
Fredrik Burmester
60b0040681 docs: add claude code guidance file 2026-01-03 17:58:33 +01:00
Fredrik Burmester
9cd55cf544 feat: setting to hide the item page header session button 2026-01-03 17:56:31 +01:00
Emre Sanden
090e0cb170 feat: add button to toggle video orientation in player (#743)
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2026-01-03 17:52:45 +01:00
Fredrik Burmester
85d707ef45 fix: improve music page design, mostly headers 2026-01-03 17:45:28 +01:00
Fredrik Burmester
792eef20a9 fix(player): hide pip button when vlc player selected 2026-01-03 17:40:34 +01:00
Fredrik Burmester
6487c8b5a1 fix: show loading indicator when pressing song in music 2026-01-03 16:24:23 +01:00
Leo THIVILLON
baa96d222f fix(readme): Add Obtainium button (#1293)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2026-01-03 16:15:18 +01:00
Fredrik Burmester
74d86b5d12 feat: KSPlayer as an option for iOS + other improvements (#1266) 2026-01-03 13:05:50 +01:00
Cristea Florian Victor
d1795c9df8 fix(player): Fix skip credits seeking past video end causing pause (#1277)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2025-12-21 10:09:57 +01:00
renovate[bot]
149609f46e chore(deps): Update actions/setup-node action to v6.1.0 (#1262)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 22:54:57 +01:00
zhangchuang
07a0a48613 refactor: improve locale handling with regex-based region detection
Address reviewer feedback by implementing regex check to detect if locale already contains region code. This prevents invalid BCP 47 tags like "zh-CN-CN" while maintaining backward compatibility.

- Use /^[a-z]{2,3}-/i regex to detect existing region in locale
- Only append region if locale doesn't already contain it
- Centralize logic in useJellyseerr hook for DRY principle
- All 7 usage points automatically benefit from this fix

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 13:00:51 +08:00
bot2
b630e0784b fix: correct invalid BCP 47 language tag in Jellyseerr components
Fix RangeError caused by invalid locale tags (e.g., zh-CN-US) in
multiple Jellyseerr components. The locale variable already contains
a complete BCP 47 language tag (e.g., zh-CN), so concatenating it
with region (e.g., US) creates an invalid tag.

Changes:
- DetailFacts.tsx: Fix 5 occurrences in date and currency formatting
- JellyseerrSeasons.tsx: Fix 1 occurrence in upcoming air date
- person/[personId].tsx: Fix 1 occurrence in birthday formatting

Total: 3 files, 7 fixes

Closes #1130

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 13:00:51 +08:00
388 changed files with 117825 additions and 9542 deletions

View File

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

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

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

View File

@@ -3,7 +3,7 @@
## Project Overview ## Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Jellyseerr APIs, It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Seerr APIs,
and provides seamless media streaming with offline capabilities and Chromecast support. and provides seamless media streaming with offline capabilities and Chromecast support.
## Main Technologies ## Main Technologies
@@ -40,9 +40,30 @@ and provides seamless media streaming with offline capabilities and Chromecast s
- `scripts/` Automation scripts (Node.js, Bash) - `scripts/` Automation scripts (Node.js, Bash)
- `plugins/` Expo/Metro plugins - `plugins/` Expo/Metro plugins
## Coding Standards ## Code Quality Standards
**CRITICAL: Code must be production-ready, reliable, and maintainable**
### Type Safety
- Use TypeScript for ALL files (no .js files) - Use TypeScript for ALL files (no .js files)
- **NEVER use `any` type** - use proper types, generics, or `unknown` with type guards
- Use `@ts-expect-error` with detailed comments only when necessary (e.g., library limitations)
- When facing type issues, create proper type definitions and helper functions instead of using `any`
- Use type assertions (`as`) only as a last resort with clear documentation explaining why
- For Expo Router navigation: prefer string URLs with `URLSearchParams` over object syntax to avoid type conflicts
- Enable and respect strict TypeScript compiler options
- Define explicit return types for functions
- Use discriminated unions for complex state
### Code Reliability
- Implement comprehensive error handling with try-catch blocks
- Validate all external inputs (API responses, user input, query params)
- Handle edge cases explicitly (empty arrays, null, undefined)
- Use optional chaining (`?.`) and nullish coalescing (`??`) appropriately
- Add runtime checks for critical operations
- Implement proper loading and error states in components
### Best Practices
- Use descriptive English names for variables, functions, and components - Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks - Prefer functional React components with hooks
- Use Jotai atoms for global state management - Use Jotai atoms for global state management
@@ -50,8 +71,10 @@ and provides seamless media streaming with offline capabilities and Chromecast s
- Follow BiomeJS formatting and linting rules - Follow BiomeJS formatting and linting rules
- Use `const` over `let`, avoid `var` entirely - Use `const` over `let`, avoid `var` entirely
- Implement proper error boundaries - Implement proper error boundaries
- Use React.memo() for performance optimization - Use React.memo() for performance optimization when needed
- Handle both mobile and TV navigation patterns - Handle both mobile and TV navigation patterns
- Write self-documenting code with clear intent
- Add comments only when code complexity requires explanation
## API Integration ## API Integration
@@ -85,6 +108,18 @@ Exemples:
- `fix(auth): handle expired JWT tokens` - `fix(auth): handle expired JWT tokens`
- `chore(deps): update Jellyfin SDK` - `chore(deps): update Jellyfin SDK`
## Internationalization (i18n)
- **Primary workflow**: Always edit `translations/en.json` for new translation keys or updates
- **Translation files** (ar.json, ca.json, cs.json, de.json, etc.):
- **NEVER add or remove keys** - Crowdin manages the key structure
- **Editing translation values is safe** - Bidirectional sync handles merges
- Prefer letting Crowdin translators update values, but direct edits work if needed
- **Crowdin workflow**:
- New keys added to `en.json` sync to Crowdin automatically
- Approved translations sync back to language files via GitHub integration
- The source of truth is `en.json` for structure, Crowdin for translations
## Special Instructions ## Special Instructions
- Prioritize cross-platform compatibility (mobile + TV) - Prioritize cross-platform compatibility (mobile + TV)

12
.github/crowdin.yml vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

9
.gitignore vendored
View File

@@ -19,7 +19,7 @@ web-build/
/androidtv /androidtv
# Module-specific Builds # Module-specific Builds
modules/vlc-player/android/build modules/mpv-player/android/build
modules/player/android modules/player/android
modules/hls-downloader/android/build modules/hls-downloader/android/build
@@ -51,6 +51,7 @@ npm-debug.*
.ruby-lsp .ruby-lsp
.cursor/ .cursor/
.claude/ .claude/
CLAUDE.md
# Environment and Configuration # Environment and Configuration
expo-env.d.ts expo-env.d.ts
@@ -65,4 +66,10 @@ streamyfin-4fec1-firebase-adminsdk.json
# Version and Backup Files # Version and Backup Files
/version-backup-* /version-backup-*
/modules/sf-player/android/build
/modules/music-controls/android/build
modules/background-downloader/android/build/* modules/background-downloader/android/build/*
/modules/mpv-player/android/build
# ios:unsigned-build Artifacts
build/

136
CLAUDE.md Normal file
View File

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

110
README.md
View File

@@ -5,6 +5,12 @@
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%"> <img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
</p> </p>
<p align="center">
<a href="https://discord.gg/aJvAYeycyY">
<img alt="Streamyfin Discord" src="https://img.shields.io/badge/Discord-Streamyfin-blue?style=flat-square&logo=discord">
</a>
</p>
**Streamyfin is a user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.** **Streamyfin is a user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
--- ---
@@ -16,53 +22,75 @@
&nbsp; &nbsp;
<img src="./assets/images/screenshots/screenshot2.png" width="20%"> <img src="./assets/images/screenshots/screenshot2.png" width="20%">
&nbsp; &nbsp;
<img src="./assets/images/jellyseerr.PNG" width="21%"> <img src="./assets/images/seerr.PNG" width="21%">
</p> </p>
## 🌟 Features ## 🌟 Features
- 🚀 **Skip Intro / Credits Support**: Lets you quickly skip intros and credits during playback ### 🎬 Media Playback
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking - 🚀 **Skip Intro / Credits**: Automatically skip intros and credits during playback
- 📥 **Download media**: Save your media locally and watch it offline - 🖼️ **Trickplay Images**: Chapter previews with thumbnails when seeking
- ⚙️ **Settings management**: Manage app configurations for all users through our plugin - 🎵 **Music Library**: Full support for music playback with playlists and queue management
- 🤖 **Seerr (formerly Jellyseerr) integration**: Request media directly in the app - 📺 **Live TV**: Watch and record live television streams
- 👁️ **Sessions view:** View all active sessions currently streaming on your server
- 📡 **Chromecast**: Cast your media to any Chromecast-enabled device - 📡 **Chromecast**: Cast your media to any Chromecast-enabled device
- 🎥 **MPV Player**: Powerful open-source player with wide format support
## 🧪 Experimental Features ### 📱 Media Management
- 📥 **Download Media**: Save movies, shows, and music locally for offline viewing
-**Favorites**: Quick access to your favorite content
- 📋 **Watchlists**: Create and manage custom watchlists with Streamystats integration
- 🔖 **Continue Watching**: Pick up right where you left off
- 🎯 **Next Up**: Smart suggestions for your next episode
Streamyfin offers exciting experimental features such as media downloading and Chromecast support. These features are under active development, and your feedback and patience help us make them even better. ### ⚙️ Advanced Features
- 🤖 **Seerr Integration**: Request new media directly in the app
- 🔍 **Smart Search**: Powerful search with Marlin Search and Streamystats support
- 👁️ **Active Sessions**: View all active streams on your server
- 🌐 **Multi-Language**: Available in 20+ languages with Crowdin integration
- 🎨 **Customizable**: Personalize your home screen and settings
- 🔌 **Plugin System**: Centralized settings sync across all devices via Jellyfin plugin
### 📥 Downloading ## 🧩 How It Works
### 📥 Downloads
Downloading works by using FFmpeg to convert an HLS stream into a video file on your device. This lets you download and watch any content that you can stream. The conversion is handled in real time by Jellyfin on the server during the download. While this may take a bit longer, it ensures compatibility with any file your server can transcode. Downloading works by using FFmpeg to convert an HLS stream into a video file on your device. This lets you download and watch any content that you can stream. The conversion is handled in real time by Jellyfin on the server during the download. While this may take a bit longer, it ensures compatibility with any file your server can transcode.
### 🧩 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 synchronizes settings across all your devices and users. Install it on your Jellyfin server to enable:
- Automatic Seerr login with no user input required - Automatic Seerr login with no user input required
- Set your preferred default languages - Default language preferences for audio and subtitles
- Configure download method and search provider - Configure download settings and search providers (Marlin, Streamystats)
- Personalize your home screen - Customize your home screen layout and sections
- Centralized configuration management
- And much more - And much more
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin) [Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
### 📡 Chromecast ### 🎬 MPV Player
Chromecast support is currently under development. Video casting is already available, and we're actively working on adding subtitle support and additional features. Streamyfin uses [MPV](https://mpv.io/) as its primary video player on all platforms, powered by [MPVKit](https://github.com/mpvkit/MPVKit). MPV is a powerful, open-source media player known for its wide format support and high-quality playback.
### 🔍 Jellysearch Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building the native MPV module in Streamyfin.
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin ### 🎵 Music Library
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients. Full music library support with playlists, queue management, background playback, and offline downloads.
### 🔍 Search Providers
Streamyfin supports multiple search providers:
- **Marlin Search**: Fast semantic search for your Jellyfin library
- **Streamystats**: Advanced statistics and personalized recommendations
- **Jellysearch**: Fast full-text search proxy ([Jellysearch](https://gitlab.com/DomiStyle/jellysearch))
## 🛣️ Roadmap ## 🛣️ Roadmap
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests. Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
## 📥 Download Streamyfin ## 📥 Download Streamyfin
@@ -70,6 +98,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a> <a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get Streamyfin on Google Play Store" src="./assets/Google_Play_Store_badge_EN.svg"/></a> <a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get Streamyfin on Google Play Store" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
<a href="https://github.com/streamyfin/streamyfin/releases/latest"><img height=50 alt="Get Streamyfin on Github" src="./assets/Download_on_Github_.png"/></a> <a href="https://github.com/streamyfin/streamyfin/releases/latest"><img height=50 alt="Get Streamyfin on Github" src="./assets/Download_on_Github_.png"/></a>
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/streamyfin/streamyfin"><img height=50 alt="Add Streamyfin to Obtainium" src="./assets/Download_with_Obtainium.png"/></a>
</div> </div>
### 🧪 Beta Testing ### 🧪 Beta Testing
@@ -101,12 +130,13 @@ You can contribute translations directly on our [Crowdin project page](https://c
### 👨‍💻 Development Info ### 👨‍💻 Development Info
1. Use node `>20` 1. Use Node.js `>20`
2. Install dependencies `bun i && bun run submodule-reload` 2. Install dependencies: `bun i && bun run submodule-reload`
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/) 3. Make sure you have Xcode and/or Android Studio installed ([Expo setup guide](https://docs.expo.dev/workflow/android-studio-emulator/))
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/) - If iOS builds fail with `missing Metal Toolchain` (KSPlayer shaders), run `npm run ios:install-metal-toolchain` once
4. run `npm run prebuild` 4. Install the [BiomeJS extension](https://biomejs.dev/) in your IDE
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app 5. Run `npm run prebuild`
6. Create an Expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
For the TV version suffix the npm commands with `:tv`. For the TV version suffix the npm commands with `:tv`.
@@ -124,10 +154,20 @@ Need assistance or have any questions?
## ❓ 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: Ensure your Jellyfin server is running a recent version (10.10.0+) and that you have proper permissions to access the libraries.
2. Q: Why can't I see my music library?
A: We don't currently support music and are unlikely to support music in the near future 2. **Q: How do I enable downloads?**
A: Downloads use FFmpeg to convert HLS streams. Ensure your server has transcoding enabled and sufficient resources.
3. **Q: Does Streamyfin support subtitles?**
A: Yes, with full customization including size, color, position, and automatic language selection.
4. **Q: Can I use Streamyfin on Apple TV or Android TV?**
A: Yes, Streamyfin has dedicated TV builds optimized for remote control navigation. Please note that TV platforms are currently in early development and not very stable. Android TV is currently the most reliable platform for testing.
5. **Q: How do I set up Seerr integration?**
A: Go to Settings → Plugins → Seerr, enter your server URL and Jellyfin credentials.
## 📝 Credits ## 📝 Credits
@@ -228,6 +268,7 @@ We also thank all other developers who have contributed to Streamyfin, your effo
A special mention to the following people and projects for their contributions: A special mention to the following people and projects for their contributions:
- [@Alexk2309](https://github.com/Alexk2309) for building the native MPV module that integrates [MPVKit](https://github.com/mpvkit/MPVKit) with React Native
- [Reiverr](https://github.com/aleksilassila/reiverr) for invaluable help with understanding the Jellyfin API - [Reiverr](https://github.com/aleksilassila/reiverr) for invaluable help with understanding the Jellyfin API
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for providing the TypeScript SDK - [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for providing the TypeScript SDK
- [Seerr](https://github.com/seerr-team/seerr) for enabling API integration with their project - [Seerr](https://github.com/seerr-team/seerr) for enabling API integration with their project
@@ -240,7 +281,9 @@ A special mention to the following people and projects for their contributions:
## 📄 License ## 📄 License
Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0). Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0).
This means you are free to use, modify, and distribute this software. The MPL-2.0 is a copyleft license that allows for more flexibility in combining the software with proprietary code. This means you are free to use, modify, and distribute this software. The MPL-2.0 is a copyleft license that allows for more flexibility in combining the software with proprietary code.
Key points of the MPL-2.0: Key points of the MPL-2.0:
- You can use the software for any purpose - You can use the software for any purpose
@@ -249,10 +292,13 @@ 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.
## ⚠️ Disclaimer ## ⚠️ Disclaimer
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions, support requests, or references to piracy, as well as any tools, software, or websites related to piracy, are strictly prohibited across all our channels. Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions, support requests, or references to piracy, as well as any tools, software, or websites related to piracy, are strictly prohibited across all our channels.
## 🤝 Sponsorship ## 🤝 Sponsorship
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster).

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,181 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { FlashList } from "@shopify/flash-list";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
import {
SeasonDropdown,
type SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
export default function page() {
const navigation = useNavigation();
const local = useLocalSearchParams();
const { seriesId, episodeSeasonIndex } = local as {
seriesId: string;
episodeSeasonIndex: number | string | undefined;
};
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
{},
);
const { downloadedItems, deleteItems } = useDownload();
const insets = useSafeAreaInsets();
const series = useMemo(() => {
try {
return (
downloadedItems
?.filter((f) => f.item.SeriesId === seriesId)
?.sort(
(a, b) =>
(a.item.ParentIndexNumber ?? 0) - (b.item.ParentIndexNumber ?? 0),
) || []
);
} catch {
return [];
}
}, [downloadedItems, seriesId]);
// Group episodes by season in a single pass
const seasonGroups = useMemo(() => {
const groups: Record<number, BaseItemDto[]> = {};
series.forEach((episode) => {
const seasonNumber = episode.item.ParentIndexNumber;
if (seasonNumber !== undefined && seasonNumber !== null) {
if (!groups[seasonNumber]) {
groups[seasonNumber] = [];
}
groups[seasonNumber].push(episode.item);
}
});
// Sort episodes within each season
Object.values(groups).forEach((episodes) => {
episodes.sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0));
});
return groups;
}, [series]);
// Get unique seasons (just the season numbers, sorted)
const uniqueSeasons = useMemo(() => {
const seasonNumbers = Object.keys(seasonGroups)
.map(Number)
.sort((a, b) => a - b);
return seasonNumbers.map((seasonNum) => seasonGroups[seasonNum][0]); // First episode of each season
}, [seasonGroups]);
const seasonIndex =
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ??
episodeSeasonIndex ??
series?.[0]?.item?.ParentIndexNumber ??
"";
const groupBySeason = useMemo<BaseItemDto[]>(() => {
return seasonGroups[Number(seasonIndex)] ?? [];
}, [seasonGroups, seasonIndex]);
const initialSeasonIndex = useMemo(
() =>
groupBySeason?.[0]?.ParentIndexNumber ??
series?.[0]?.item?.ParentIndexNumber,
[groupBySeason, series],
);
useEffect(() => {
if (series.length > 0) {
navigation.setOptions({
title: series[0].item.SeriesName,
});
} else {
storage.remove(seriesId);
router.back();
}
}, [series]);
const deleteSeries = useCallback(() => {
Alert.alert(
"Delete season",
"Are you sure you want to delete the entire season?",
[
{
text: "Cancel",
style: "cancel",
},
{
text: "Delete",
onPress: () =>
deleteItems(
groupBySeason
.map((item) => item.Id)
.filter((id) => id !== undefined),
),
style: "destructive",
},
],
);
}, [groupBySeason, deleteItems]);
const ListHeaderComponent = useCallback(() => {
if (series.length === 0) return null;
return (
<View className='flex flex-row items-center justify-start pb-2'>
<SeasonDropdown
item={series[0].item}
seasons={uniqueSeasons}
state={seasonIndexState}
initialSeasonIndex={initialSeasonIndex!}
onSelect={(season) => {
setSeasonIndexState((prev) => ({
...prev,
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
}));
}}
/>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'>
<Text className='text-xs font-bold'>{groupBySeason.length}</Text>
</View>
<View className='bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto'>
<TouchableOpacity onPress={deleteSeries}>
<Ionicons name='trash' size={20} color='white' />
</TouchableOpacity>
</View>
</View>
);
}, [
series,
uniqueSeasons,
seasonIndexState,
initialSeasonIndex,
groupBySeason,
deleteSeries,
]);
return (
<View className='flex-1'>
<FlashList
key={seasonIndex}
data={groupBySeason}
renderItem={({ item }) => <EpisodeCard item={item} />}
keyExtractor={(item, index) => item.Id ?? `episode-${index}`}
ListHeaderComponent={ListHeaderComponent}
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingHorizontal: 16,
paddingLeft: insets.left + 16,
paddingRight: insets.right + 16,
paddingTop: Platform.OS === "android" ? 10 : 8,
}}
/>
</View>
);
}

View File

@@ -1,15 +1,10 @@
import { BottomSheetModal } from "@gorhom/bottom-sheet"; import { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useNavigation, useRouter } from "expo-router"; import { useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { Alert, Platform, ScrollView, View } from "react-native";
Alert, import { Pressable } from "react-native-gesture-handler";
Platform,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
@@ -18,8 +13,10 @@ import ActiveDownloads from "@/components/downloads/ActiveDownloads";
import { DownloadSize } from "@/components/downloads/DownloadSize"; import { DownloadSize } from "@/components/downloads/DownloadSize";
import { MovieCard } from "@/components/downloads/MovieCard"; import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard"; import { SeriesCard } from "@/components/downloads/SeriesCard";
import useRouter from "@/hooks/useAppRouter";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { type DownloadedItem } from "@/providers/Downloads/types"; import { type DownloadedItem } from "@/providers/Downloads/types";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { queueAtom } from "@/utils/atoms/queue"; import { queueAtom } from "@/utils/atoms/queue";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
@@ -103,12 +100,12 @@ export default function page() {
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
<TouchableOpacity <Pressable
onPress={bottomSheetModalRef.current?.present} onPress={bottomSheetModalRef.current?.present}
className='px-2' className='px-2'
> >
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} /> <DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
</TouchableOpacity> </Pressable>
), ),
}); });
}, [downloadedFiles]); }, [downloadedFiles]);
@@ -166,145 +163,99 @@ export default function page() {
); );
return ( return (
<ScrollView <OfflineModeProvider isOffline={true}>
showsVerticalScrollIndicator={false} <ScrollView
contentInsetAdjustmentBehavior='automatic' showsVerticalScrollIndicator={false}
> contentInsetAdjustmentBehavior='automatic'
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}> >
<View className='mb-4 flex flex-col space-y-4 px-4'> <View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
{/* Queue card - hidden */} <View className='mb-4 flex flex-col space-y-4 px-4'>
{/* <View className='bg-neutral-900 p-4 rounded-2xl'> <ActiveDownloads />
</View>
{movies.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'> <Text className='text-lg font-bold'>
{t("home.downloads.queue")} {t("home.downloads.movies")}
</Text> </Text>
<Text className='text-xs opacity-70 text-red-600'> <View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
{t("home.downloads.queue_hint")} <Text className='text-xs font-bold'>{movies?.length}</Text>
</Text> </View>
<View className='flex flex-col space-y-2 mt-2'> </View>
{queue.map((q, index) => ( <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<TouchableOpacity <View className='px-4 flex flex-row'>
onPress={() => {movies?.map((item) => (
router.push(`/(auth)/items/page?id=${q.item.Id}`) <TouchableItemRouter item={item.item} key={item.item.Id}>
} <MovieCard item={item.item} />
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between' </TouchableItemRouter>
key={index}
>
<View>
<Text className='font-semibold'>{q.item.Name}</Text>
<Text className='text-xs opacity-50'>
{q.item.Type}
</Text>
</View>
<TouchableOpacity
onPress={() => {
removeProcess(q.id);
setQueue((prev) => {
if (!prev) return [];
return [...prev.filter((i) => i.id !== q.id)];
});
}}
>
<Ionicons name='close' size={24} color='red' />
</TouchableOpacity>
</TouchableOpacity>
))} ))}
</View> </View>
</ScrollView>
{queue.length === 0 && (
<Text className='opacity-50'>
{t("home.downloads.no_items_in_queue")}
</Text>
)}
</View> */}
<ActiveDownloads />
</View>
{movies.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.movies")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{movies?.length}</Text>
</View>
</View> </View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}> )}
<View className='px-4 flex flex-row'> {groupedBySeries.length > 0 && (
{movies?.map((item) => ( <View className='mb-4'>
<TouchableItemRouter <View className='flex flex-row items-center justify-between mb-2 px-4'>
item={item.item} <Text className='text-lg font-bold'>
isOffline {t("home.downloads.tvseries")}
key={item.item.Id}
>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
</View>
</ScrollView>
</View>
)}
{groupedBySeries.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.tvseries")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{groupedBySeries?.length}
</Text> </Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{groupedBySeries?.length}
</Text>
</View>
</View> </View>
</View> <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<ScrollView horizontal showsHorizontalScrollIndicator={false}> <View className='px-4 flex flex-row'>
<View className='px-4 flex flex-row'> {groupedBySeries?.map((items) => (
{groupedBySeries?.map((items) => ( <View
<View className='mb-2 last:mb-0' key={items[0].item.SeriesId}> className='mb-2 last:mb-0'
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId} key={items[0].item.SeriesId}
/> >
</View> <SeriesCard
))} items={items.map((i) => i.item)}
</View> key={items[0].item.SeriesId}
</ScrollView> />
</View> </View>
)} ))}
</View>
{otherMedia.length > 0 && ( </ScrollView>
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.other_media")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{otherMedia?.length}</Text>
</View>
</View> </View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}> )}
<View className='px-4 flex flex-row'>
{otherMedia?.map((item) => ( {otherMedia.length > 0 && (
<TouchableItemRouter <View className='mb-4'>
item={item.item} <View className='flex flex-row items-center justify-between mb-2 px-4'>
isOffline <Text className='text-lg font-bold'>
key={item.item.Id} {t("home.downloads.other_media")}
> </Text>
<MovieCard item={item.item} /> <View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
</TouchableItemRouter> <Text className='text-xs font-bold'>
))} {otherMedia?.length}
</Text>
</View>
</View> </View>
</ScrollView> <ScrollView horizontal showsHorizontalScrollIndicator={false}>
</View> <View className='px-4 flex flex-row'>
)} {otherMedia?.map((item) => (
{downloadedFiles?.length === 0 && ( <TouchableItemRouter item={item.item} key={item.item.Id}>
<View className='flex px-4'> <MovieCard item={item.item} />
<Text className='opacity-50'> </TouchableItemRouter>
{t("home.downloads.no_downloaded_items")} ))}
</Text> </View>
</View> </ScrollView>
)} </View>
</View> )}
</ScrollView> {downloadedFiles?.length === 0 && (
<View className='flex px-4'>
<Text className='opacity-50'>
{t("home.downloads.no_downloaded_items")}
</Text>
</View>
)}
</View>
</ScrollView>
</OfflineModeProvider>
); );
} }

View File

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

View File

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

View File

@@ -71,7 +71,7 @@ export default function page() {
))} ))}
</ListGroup> </ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'> <Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_liraries_you_want_to_hide")} {t("home.settings.other.select_libraries_you_want_to_hide")}
</Text> </Text>
</DisabledSetting> </DisabledSetting>
</ScrollView> </ScrollView>

View File

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

View File

@@ -60,7 +60,7 @@ export default function page() {
))} ))}
</ListGroup> </ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'> <Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_liraries_you_want_to_hide")} {t("home.settings.other.select_libraries_you_want_to_hide")}
</Text> </Text>
</DisabledSetting> </DisabledSetting>
); );

View File

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

View File

@@ -0,0 +1,251 @@
import { Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, View } from "react-native";
import { Switch } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { useHaptic } from "@/hooks/useHaptic";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import {
clearCache,
clearPermanentDownloads,
getStorageStats,
} from "@/providers/AudioStorage";
import { useSettings } from "@/utils/atoms/settings";
const CACHE_SIZE_OPTIONS = [
{ label: "100 MB", value: 100 },
{ label: "250 MB", value: 250 },
{ label: "500 MB", value: 500 },
{ label: "1 GB", value: 1024 },
{ label: "2 GB", value: 2048 },
];
const LOOKAHEAD_COUNT_OPTIONS = [
{ label: "1 song", value: 1 },
{ label: "2 songs", value: 2 },
{ label: "3 songs", value: 3 },
{ label: "5 songs", value: 5 },
];
export default function MusicSettingsPage() {
const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();
const queryClient = useNetworkAwareQueryClient();
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const { data: musicCacheStats } = useQuery({
queryKey: ["musicCacheStats"],
queryFn: () => getStorageStats(),
});
const onClearMusicCacheClicked = useCallback(async () => {
try {
await clearCache();
queryClient.invalidateQueries({ queryKey: ["musicCacheStats"] });
queryClient.invalidateQueries({ queryKey: ["appSize"] });
successHapticFeedback();
toast.success(t("home.settings.storage.music_cache_cleared"));
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
}
}, [queryClient, successHapticFeedback, errorHapticFeedback, t]);
const onDeleteDownloadedSongsClicked = useCallback(async () => {
try {
await clearPermanentDownloads();
queryClient.invalidateQueries({ queryKey: ["musicCacheStats"] });
queryClient.invalidateQueries({ queryKey: ["appSize"] });
successHapticFeedback();
toast.success(t("home.settings.storage.downloaded_songs_deleted"));
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
}
}, [queryClient, successHapticFeedback, errorHapticFeedback, t]);
const cacheSizeOptions = useMemo(
() => [
{
options: CACHE_SIZE_OPTIONS.map((option) => ({
type: "radio" as const,
label: option.label,
value: String(option.value),
selected: option.value === settings?.audioMaxCacheSizeMB,
onPress: () => updateSettings({ audioMaxCacheSizeMB: option.value }),
})),
},
],
[settings?.audioMaxCacheSizeMB, updateSettings],
);
const currentCacheSizeLabel =
CACHE_SIZE_OPTIONS.find((o) => o.value === settings?.audioMaxCacheSizeMB)
?.label ?? `${settings?.audioMaxCacheSizeMB} MB`;
const lookaheadCountOptions = useMemo(
() => [
{
options: LOOKAHEAD_COUNT_OPTIONS.map((option) => ({
type: "radio" as const,
label: option.label,
value: String(option.value),
selected: option.value === settings?.audioLookaheadCount,
onPress: () => updateSettings({ audioLookaheadCount: option.value }),
})),
},
],
[settings?.audioLookaheadCount, updateSettings],
);
const currentLookaheadLabel =
LOOKAHEAD_COUNT_OPTIONS.find(
(o) => o.value === settings?.audioLookaheadCount,
)?.label ?? `${settings?.audioLookaheadCount} songs`;
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<ListGroup
title={t("home.settings.music.playback_title")}
description={
<Text className='text-[#8E8D91] text-xs'>
{t("home.settings.music.playback_description")}
</Text>
}
>
<ListItem
title={t("home.settings.music.prefer_downloaded")}
disabled={pluginSettings?.preferLocalAudio?.locked}
>
<Switch
value={settings.preferLocalAudio}
disabled={pluginSettings?.preferLocalAudio?.locked}
onValueChange={(value) =>
updateSettings({ preferLocalAudio: value })
}
/>
</ListItem>
</ListGroup>
<View className='mt-4'>
<ListGroup
title={t("home.settings.music.caching_title")}
description={
<Text className='text-[#8E8D91] text-xs'>
{t("home.settings.music.caching_description")}
</Text>
}
>
<ListItem
title={t("home.settings.music.lookahead_enabled")}
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
>
<Switch
value={settings.audioLookaheadEnabled}
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
onValueChange={(value) =>
updateSettings({ audioLookaheadEnabled: value })
}
/>
</ListItem>
<ListItem
title={t("home.settings.music.lookahead_count")}
disabled={
pluginSettings?.audioLookaheadCount?.locked ||
!settings.audioLookaheadEnabled
}
>
<PlatformDropdown
groups={lookaheadCountOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{currentLookaheadLabel}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.music.lookahead_count")}
/>
</ListItem>
<ListItem
title={t("home.settings.music.max_cache_size")}
disabled={pluginSettings?.audioMaxCacheSizeMB?.locked}
>
<PlatformDropdown
groups={cacheSizeOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{currentCacheSizeLabel}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.music.max_cache_size")}
/>
</ListItem>
</ListGroup>
</View>
{!Platform.isTV && (
<View className='mt-4'>
<ListGroup
title={t("home.settings.storage.music_cache_title")}
description={
<Text className='text-[#8E8D91] text-xs'>
{t("home.settings.storage.music_cache_description")}
</Text>
}
>
<ListItem
onPress={onClearMusicCacheClicked}
title={t("home.settings.storage.clear_music_cache")}
subtitle={t("home.settings.storage.music_cache_size", {
size: (musicCacheStats?.cacheSize ?? 0).bytesToReadable(),
})}
/>
</ListGroup>
<ListGroup>
<ListItem
textColor='red'
onPress={onDeleteDownloadedSongsClicked}
title={t("home.settings.storage.delete_all_downloaded_songs")}
subtitle={t("home.settings.storage.downloaded_songs_size", {
size: (musicCacheStats?.permanentSize ?? 0).bytesToReadable(),
})}
/>
</ListGroup>
</View>
)}
</View>
</ScrollView>
);
}

View File

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

View File

@@ -1,7 +1,7 @@
import { ScrollView } from "react-native"; import { ScrollView } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr"; import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export default function page() { export default function page() {
@@ -17,10 +17,10 @@ export default function page() {
}} }}
> >
<DisabledSetting <DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true} disabled={pluginSettings?.useKefinTweaks?.locked === true}
className='px-4' className='px-4'
> >
<JellyseerrSettings /> <KefinTweaksSettings />
</DisabledSetting> </DisabledSetting>
</ScrollView> </ScrollView>
); );

View File

@@ -1,4 +1,3 @@
import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -16,6 +15,7 @@ import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export default function page() { export default function page() {
@@ -26,7 +26,7 @@ export default function page() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
const queryClient = useQueryClient(); const queryClient = useNetworkAwareQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || ""); const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
@@ -60,7 +60,7 @@ export default function page() {
), ),
}); });
} }
}, [navigation, value]); }, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]);
if (!settings) return null; if (!settings) return null;
@@ -75,7 +75,10 @@ export default function page() {
<DisabledSetting disabled={disabled} className='px-4'> <DisabledSetting disabled={disabled} className='px-4'>
<ListGroup> <ListGroup>
<DisabledSetting <DisabledSetting
disabled={pluginSettings?.searchEngine?.locked === true} disabled={
pluginSettings?.searchEngine?.locked === true ||
!!pluginSettings?.streamyStatsServerUrl?.value
}
showText={!pluginSettings?.marlinServerUrl?.locked} showText={!pluginSettings?.marlinServerUrl?.locked}
> >
<ListItem <ListItem
@@ -89,6 +92,7 @@ export default function page() {
> >
<Switch <Switch
value={settings.searchEngine === "Marlin"} value={settings.searchEngine === "Marlin"}
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
onValueChange={(value) => { onValueChange={(value) => {
updateSettings({ updateSettings({
searchEngine: value ? "Marlin" : "Jellyfin", searchEngine: value ? "Marlin" : "Jellyfin",

View File

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

View File

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

View File

@@ -1,163 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import type React from "react";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
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 navigation = useNavigation();
const { t } = useTranslation();
const params = useLocalSearchParams();
const { id: seriesId, seasonIndex } = params as {
id: string;
seasonIndex: string;
};
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: item } = useQuery({
queryKey: ["series", seriesId],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: seriesId,
}),
staleTime: 60 * 1000,
});
const backdropUrl = useMemo(
() =>
getBackdropUrl({
api,
item,
quality: 90,
width: 1000,
}),
[item],
);
const logoUrl = useMemo(
() =>
getLogoImageUrlById({
api,
item,
}),
[item],
);
const { data: allEpisodes, isLoading } = useQuery({
queryKey: ["AllEpisodes", item?.Id],
queryFn: async () => {
if (!api || !user?.Id || !item?.Id) return [];
const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.Id,
userId: user.Id,
enableUserData: true,
// Note: Including trick play is necessary to enable trick play downloads
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
});
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,
enabled: !!api && !!user?.Id && !!item?.Id,
});
useEffect(() => {
navigation.setOptions({
headerRight: () =>
!isLoading &&
item &&
allEpisodes &&
allEpisodes.length > 0 && (
<View className='flex flex-row items-center space-x-2'>
<AddToFavorites item={item} />
{!Platform.isTV && (
<DownloadItems
size='large'
title={t("item_card.download.download_series")}
items={allEpisodes || []}
MissingDownloadIconComponent={() => (
<Ionicons name='download' size={22} color='white' />
)}
DownloadedIconComponent={() => (
<Ionicons
name='checkmark-done-outline'
size={24}
color='#9333ea'
/>
)}
/>
)}
</View>
),
});
}, [allEpisodes, isLoading, item]);
if (!item || !backdropUrl) return null;
return (
<ParallaxScrollView
headerHeight={400}
headerImage={
<Image
source={{
uri: backdropUrl,
}}
style={{
width: "100%",
height: "100%",
}}
/>
}
logo={
logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
}}
contentFit='contain'
/>
) : undefined
}
>
<View className='flex flex-col pt-4'>
<SeriesHeader item={item} />
<View className='mb-4'>
<NextUp seriesId={seriesId} />
</View>
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
</View>
</ParallaxScrollView>
);
};
export default page;

View File

@@ -13,6 +13,7 @@ import Animated, {
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ItemContent } from "@/components/ItemContent"; import { ItemContent } from "@/components/ItemContent";
import { useItemQuery } from "@/hooks/useItemQuery"; import { useItemQuery } from "@/hooks/useItemQuery";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
const Page: React.FC = () => { const Page: React.FC = () => {
const { id } = useLocalSearchParams() as { id: string }; const { id } = useLocalSearchParams() as { id: string };
@@ -21,14 +22,16 @@ const Page: React.FC = () => {
const { offline } = useLocalSearchParams() as { offline?: string }; const { offline } = useLocalSearchParams() as { offline?: string };
const isOffline = offline === "true"; const isOffline = offline === "true";
const { data: item, isError } = useItemQuery(id, false, undefined, [ // Exclude MediaSources/MediaStreams from initial fetch for faster loading
// (especially important for plugins like Gelato)
const { data: item, isError } = useItemQuery(id, isOffline, undefined, [
ItemFields.MediaSources, ItemFields.MediaSources,
ItemFields.MediaSourceCount, ItemFields.MediaSourceCount,
ItemFields.MediaStreams, ItemFields.MediaStreams,
]); ]);
// preload media sources // Lazily preload item with full media sources in background
const { data: itemWithSources } = useItemQuery(id, false, undefined, []); const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, []);
const opacity = useSharedValue(1); const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => { const animatedStyle = useAnimatedStyle(() => {
@@ -73,39 +76,35 @@ const Page: React.FC = () => {
); );
return ( return (
<View className='flex flex-1 relative'> <OfflineModeProvider isOffline={isOffline}>
<Animated.View <View className='flex flex-1 relative'>
pointerEvents={"none"} <Animated.View
style={[animatedStyle]} pointerEvents={"none"}
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black' style={[animatedStyle]}
> className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black'
<View >
style={{ <View
height: item?.Type === "Episode" ? 300 : 450, style={{
}} height: item?.Type === "Episode" ? 300 : 450,
className='bg-transparent rounded-lg mb-4 w-full' }}
/> className='bg-transparent rounded-lg mb-4 w-full'
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' /> />
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' /> <View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' /> <View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
<View className='flex flex-row space-x-1 mb-8'> <View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' /> <View className='flex flex-row space-x-1 mb-8'>
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' /> <View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' /> <View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
</View> <View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' /> </View>
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' /> <View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' /> <View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' /> <View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
</Animated.View> <View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
{item && ( </Animated.View>
<ItemContent {item && <ItemContent item={item} itemWithSources={itemWithSources} />}
item={item} </View>
isOffline={isOffline} </OfflineModeProvider>
itemWithSources={itemWithSources}
/>
)}
</View>
); );
}; };

View File

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

View File

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

View File

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

View File

@@ -3,9 +3,9 @@ import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { uniqBy } from "lodash"; import { uniqBy } from "lodash";
import { useMemo } from "react"; import { useMemo } from "react";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow"; import SeerrPoster from "@/components/posters/SeerrPoster";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster"; import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr"; import { Endpoints, useSeerr } from "@/hooks/useSeerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import { import {
type MovieResult, type MovieResult,
@@ -13,34 +13,33 @@ import {
} 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";
export default function page() { export default function CompanyPage() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr(); const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
const { companyId, 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;
type: DiscoverSliderType; type: DiscoverSliderType; //This gets converted to a string because it's a url param
}; };
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({ const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
queryKey: ["jellyseerr", "company", type, companyId], queryKey: ["seerr", "company", type, companyId],
queryFn: async ({ pageParam }) => { queryFn: async ({ pageParam }) => {
const params: any = { const params: any = {
page: Number(pageParam), page: Number(pageParam),
}; };
return seerrApi?.discover(
return jellyseerrApi?.discover(
`${ `${
type === DiscoverSliderType.NETWORKS Number(type) === DiscoverSliderType.NETWORKS
? Endpoints.DISCOVER_TV_NETWORK ? Endpoints.DISCOVER_TV_NETWORK
: Endpoints.DISCOVER_MOVIES_STUDIO : Endpoints.DISCOVER_MOVIES_STUDIO
}/${companyId}`, }/${companyId}`,
params, params,
); );
}, },
enabled: !!jellyseerrApi && !!companyId, enabled: !!seerrApi && !!companyId,
initialPageParam: 1, initialPageParam: 1,
getNextPageParam: (lastPage, pages) => getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) + (lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
@@ -54,25 +53,24 @@ export default function page() {
data?.pages data?.pages
?.filter((p) => p?.results.length) ?.filter((p) => p?.results.length)
.flatMap( .flatMap(
(p) => (p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r)) ?? [],
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
), ),
"id", "id",
) ?? [], ) ?? [],
[data], [data, isSeerrMovieOrTvResult],
); );
const backdrops = useMemo( const backdrops = useMemo(
() => () =>
jellyseerrApi seerrApi
? flatData.map((r) => ? flatData.map((r) =>
jellyseerrApi.imageProxy( seerrApi.imageProxy(
(r as TvResult | MovieResult).backdropPath, (r as TvResult | MovieResult).backdropPath,
"w1920_and_h800_multi_faces", "w1920_and_h800_multi_faces",
), ),
) )
: [], : [],
[jellyseerrApi, flatData], [seerrApi, flatData],
); );
return ( return (
@@ -86,13 +84,14 @@ export default function page() {
fetchNextPage(); fetchNextPage();
} }
}} }}
isLoading={isLoading}
logo={ logo={
<Image <Image
id={companyId} id={companyId}
key={companyId} key={companyId}
className='bottom-1 w-1/2' className='bottom-1 w-1/2'
source={{ source={{
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER), uri: seerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
}} }}
cachePolicy={"memory-disk"} cachePolicy={"memory-disk"}
contentFit='contain' contentFit='contain'
@@ -101,7 +100,7 @@ export default function page() {
}} }}
/> />
} }
renderItem={(item, _index) => <JellyseerrPoster item={item} />} renderItem={(item, _index) => <SeerrPoster item={item} />}
/> />
); );
} }

View File

@@ -3,15 +3,15 @@ import { useLocalSearchParams } from "expo-router";
import { uniqBy } from "lodash"; import { uniqBy } from "lodash";
import { useMemo } from "react"; import { useMemo } from "react";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard"; import SeerrPoster from "@/components/posters/SeerrPoster";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow"; import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster"; import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr"; import { Endpoints, useSeerr } from "@/hooks/useSeerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
export default function page() { export default function GenrePage() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr(); const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
const { genreId, name, type } = local as unknown as { const { genreId, name, type } = local as unknown as {
genreId: string; genreId: string;
@@ -20,21 +20,21 @@ export default function page() {
}; };
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({ const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["jellyseerr", "company", type, genreId], queryKey: ["seerr", "genre", type, genreId],
queryFn: async ({ pageParam }) => { queryFn: async ({ pageParam }) => {
const params: any = { const params: any = {
page: Number(pageParam), page: Number(pageParam),
genre: genreId, genre: genreId,
}; };
return jellyseerrApi?.discover( return seerrApi?.discover(
type === DiscoverSliderType.MOVIE_GENRES type === DiscoverSliderType.MOVIE_GENRES
? Endpoints.DISCOVER_MOVIES ? Endpoints.DISCOVER_MOVIES
: Endpoints.DISCOVER_TV, : Endpoints.DISCOVER_TV,
params, params,
); );
}, },
enabled: !!jellyseerrApi && !!genreId, enabled: !!seerrApi && !!genreId,
initialPageParam: 1, initialPageParam: 1,
getNextPageParam: (lastPage, pages) => getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) + (lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
@@ -48,8 +48,7 @@ export default function page() {
data?.pages data?.pages
?.filter((p) => p?.results.length) ?.filter((p) => p?.results.length)
.flatMap( .flatMap(
(p) => (p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r)) ?? [],
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
), ),
"id", "id",
) ?? [], ) ?? [],
@@ -58,15 +57,12 @@ export default function page() {
const backdrops = useMemo( const backdrops = useMemo(
() => () =>
jellyseerrApi seerrApi
? flatData.map((r) => ? flatData.map((r) =>
jellyseerrApi.imageProxy( seerrApi.imageProxy(r.backdropPath, "w1920_and_h800_multi_faces"),
r.backdropPath,
"w1920_and_h800_multi_faces",
),
) )
: [], : [],
[jellyseerrApi, flatData], [seerrApi, flatData],
); );
return ( return (
@@ -91,7 +87,7 @@ export default function page() {
{name} {name}
</Text> </Text>
} }
renderItem={(item, _index) => <JellyseerrPoster item={item} />} renderItem={(item, _index) => <SeerrPoster item={item} />}
/> />
); );
} }

View File

@@ -8,7 +8,7 @@ import {
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -18,17 +18,18 @@ import { toast } from "sonner-native";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags"; import { GenreTags } from "@/components/GenreTags";
import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
import RequestModal from "@/components/jellyseerr/RequestModal";
import { OverviewText } from "@/components/OverviewText"; import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
import { PlatformDropdown } from "@/components/PlatformDropdown"; import { PlatformDropdown } from "@/components/PlatformDropdown";
import { JellyserrRatings } from "@/components/Ratings"; import { SeerrRatings } from "@/components/Ratings";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons"; import Cast from "@/components/seerr/Cast";
import DetailFacts from "@/components/seerr/DetailFacts";
import RequestModal from "@/components/seerr/RequestModal";
import SeerrSeasons from "@/components/series/SeerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions"; import { ItemActions } from "@/components/series/SeriesActions";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import useRouter from "@/hooks/useAppRouter";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest"; import { useSeerr } from "@/hooks/useSeerr";
import { useSeerrCanRequest } from "@/utils/_seerr/useSeerrCanRequest";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants"; import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import { import {
type IssueType, type IssueType,
@@ -67,7 +68,7 @@ const Page: React.FC = () => {
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>; } & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
const navigation = useNavigation(); const navigation = useNavigation();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); const { seerrApi, seerrUser, requestMedia } = useSeerr();
const [issueType, setIssueType] = useState<IssueType>(); const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>(); const [issueMessage, setIssueMessage] = useState<string>();
@@ -82,8 +83,8 @@ const Page: React.FC = () => {
isLoading, isLoading,
refetch, refetch,
} = useQuery({ } = useQuery({
enabled: !!jellyseerrApi && !!result && !!result.id, enabled: !!seerrApi && !!result && !!result.id,
queryKey: ["jellyseerr", "detail", mediaType, result.id], queryKey: ["seerr", "detail", mediaType, result.id],
staleTime: 0, staleTime: 0,
refetchOnMount: true, refetchOnMount: true,
refetchOnReconnect: true, refetchOnReconnect: true,
@@ -92,21 +93,18 @@ const Page: React.FC = () => {
refetchInterval: 0, refetchInterval: 0,
queryFn: async () => { queryFn: async () => {
return mediaType === MediaType.MOVIE return mediaType === MediaType.MOVIE
? jellyseerrApi?.movieDetails(result.id!) ? seerrApi?.movieDetails(result.id!)
: jellyseerrApi?.tvDetails(result.id!); : seerrApi?.tvDetails(result.id!);
}, },
}); });
const [canRequest, hasAdvancedRequestPermission] = const [canRequest, hasAdvancedRequestPermission] =
useJellyseerrCanRequest(details); useSeerrCanRequest(details);
const canManageRequests = useMemo(() => { const canManageRequests = useMemo(() => {
if (!jellyseerrUser) return false; if (!seerrUser) return false;
return hasPermission( return hasPermission(Permission.MANAGE_REQUESTS, seerrUser.permissions);
Permission.MANAGE_REQUESTS, }, [seerrUser]);
jellyseerrUser.permissions,
);
}, [jellyseerrUser]);
const pendingRequest = useMemo(() => { const pendingRequest = useMemo(() => {
return details?.mediaInfo?.requests?.find( return details?.mediaInfo?.requests?.find(
@@ -118,27 +116,27 @@ const Page: React.FC = () => {
if (!pendingRequest?.id) return; if (!pendingRequest?.id) return;
try { try {
await jellyseerrApi?.approveRequest(pendingRequest.id); await seerrApi?.approveRequest(pendingRequest.id);
toast.success(t("jellyseerr.toasts.request_approved")); toast.success(t("seerr.toasts.request_approved"));
refetch(); refetch();
} catch (error) { } catch (error) {
toast.error(t("jellyseerr.toasts.failed_to_approve_request")); toast.error(t("seerr.toasts.failed_to_approve_request"));
console.error("Failed to approve request:", error); console.error("Failed to approve request:", error);
} }
}, [jellyseerrApi, pendingRequest, refetch, t]); }, [seerrApi, pendingRequest, refetch, t]);
const handleDeclineRequest = useCallback(async () => { const handleDeclineRequest = useCallback(async () => {
if (!pendingRequest?.id) return; if (!pendingRequest?.id) return;
try { try {
await jellyseerrApi?.declineRequest(pendingRequest.id); await seerrApi?.declineRequest(pendingRequest.id);
toast.success(t("jellyseerr.toasts.request_declined")); toast.success(t("seerr.toasts.request_declined"));
refetch(); refetch();
} catch (error) { } catch (error) {
toast.error(t("jellyseerr.toasts.failed_to_decline_request")); toast.error(t("seerr.toasts.failed_to_decline_request"));
console.error("Failed to decline request:", error); console.error("Failed to decline request:", error);
} }
}, [jellyseerrApi, pendingRequest, refetch, t]); }, [seerrApi, pendingRequest, refetch, t]);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => ( (props: BottomSheetBackdropProps) => (
@@ -153,7 +151,7 @@ const Page: React.FC = () => {
const submitIssue = useCallback(() => { const submitIssue = useCallback(() => {
if (result.id && issueType && issueMessage && details) { if (result.id && issueType && issueMessage && details) {
jellyseerrApi seerrApi
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage) ?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
.then(() => { .then(() => {
setIssueType(undefined); setIssueType(undefined);
@@ -161,7 +159,7 @@ const Page: React.FC = () => {
bottomSheetModalRef?.current?.close(); bottomSheetModalRef?.current?.close();
}); });
} }
}, [jellyseerrApi, details, result, issueType, issueMessage]); }, [seerrApi, details, result, issueType, issueMessage]);
const handleIssueModalDismiss = useCallback(() => { const handleIssueModalDismiss = useCallback(() => {
setIssueTypeDropdownOpen(false); setIssueTypeDropdownOpen(false);
@@ -213,7 +211,7 @@ const Page: React.FC = () => {
const issueTypeOptionGroups = useMemo( const issueTypeOptionGroups = useMemo(
() => [ () => [
{ {
title: t("jellyseerr.types"), title: t("seerr.types"),
options: Object.entries(IssueTypeName) options: Object.entries(IssueTypeName)
.reverse() .reverse()
.map(([key, value]) => ({ .map(([key, value]) => ({
@@ -264,7 +262,7 @@ const Page: React.FC = () => {
height: "100%", height: "100%",
}} }}
source={{ source={{
uri: jellyseerrApi?.imageProxy( uri: seerrApi?.imageProxy(
result.backdropPath, result.backdropPath,
"w1920_and_h800_multi_faces", "w1920_and_h800_multi_faces",
), ),
@@ -294,7 +292,7 @@ const Page: React.FC = () => {
<View className='px-4'> <View className='px-4'>
<View className='flex flex-row justify-between w-full'> <View className='flex flex-row justify-between w-full'>
<View className='flex flex-col w-56'> <View className='flex flex-col w-56'>
<JellyserrRatings <SeerrRatings
result={ result={
result as result as
| MovieResult | MovieResult
@@ -329,7 +327,7 @@ const Page: React.FC = () => {
/> />
) : canRequest ? ( ) : canRequest ? (
<Button color='purple' onPress={request} className='mt-4'> <Button color='purple' onPress={request} className='mt-4'>
{t("jellyseerr.request_button")} {t("seerr.request_button")}
</Button> </Button>
) : ( ) : (
details?.mediaInfo?.jellyfinMediaId && ( details?.mediaInfo?.jellyfinMediaId && (
@@ -352,7 +350,7 @@ const Page: React.FC = () => {
}} }}
> >
<Text className='text-sm'> <Text className='text-sm'>
{t("jellyseerr.report_issue_button")} {t("seerr.report_issue_button")}
</Text> </Text>
</Button> </Button>
)} )}
@@ -388,12 +386,12 @@ const Page: React.FC = () => {
<View className='flex flex-row items-center space-x-2'> <View className='flex flex-row items-center space-x-2'>
<Ionicons name='person-outline' size={16} color='#9CA3AF' /> <Ionicons name='person-outline' size={16} color='#9CA3AF' />
<Text className='text-sm text-neutral-400'> <Text className='text-sm text-neutral-400'>
{t("jellyseerr.requested_by", { {t("seerr.requested_by", {
user: user:
pendingRequest.requestedBy?.displayName || pendingRequest.requestedBy?.displayName ||
pendingRequest.requestedBy?.username || pendingRequest.requestedBy?.username ||
pendingRequest.requestedBy?.jellyfinUsername || pendingRequest.requestedBy?.jellyfinUsername ||
t("jellyseerr.unknown_user"), t("seerr.unknown_user"),
})} })}
</Text> </Text>
</View> </View>
@@ -414,7 +412,7 @@ const Page: React.FC = () => {
borderStyle: "solid", borderStyle: "solid",
}} }}
> >
<Text className='text-sm'>{t("jellyseerr.approve")}</Text> <Text className='text-sm'>{t("seerr.approve")}</Text>
</Button> </Button>
<Button <Button
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100' className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
@@ -432,7 +430,7 @@ const Page: React.FC = () => {
borderStyle: "solid", borderStyle: "solid",
}} }}
> >
<Text className='text-sm'>{t("jellyseerr.decline")}</Text> <Text className='text-sm'>{t("seerr.decline")}</Text>
</Button> </Button>
</View> </View>
</View> </View>
@@ -441,7 +439,7 @@ const Page: React.FC = () => {
</View> </View>
{mediaType === MediaType.TV && ( {mediaType === MediaType.TV && (
<JellyseerrSeasons <SeerrSeasons
isLoading={isLoading || isFetching} isLoading={isLoading || isFetching}
details={details as TvDetails} details={details as TvDetails}
refetch={refetch} refetch={refetch}
@@ -490,13 +488,13 @@ const Page: React.FC = () => {
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'> <View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View> <View>
<Text className='font-bold text-2xl text-neutral-100'> <Text className='font-bold text-2xl text-neutral-100'>
{t("jellyseerr.whats_wrong")} {t("seerr.whats_wrong")}
</Text> </Text>
</View> </View>
<View className='flex flex-col space-y-2 items-start'> <View className='flex flex-col space-y-2 items-start'>
<View className='flex flex-col w-full'> <View className='flex flex-col w-full'>
<Text className='opacity-50 mb-1 text-xs'> <Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.issue_type")} {t("seerr.issue_type")}
</Text> </Text>
<PlatformDropdown <PlatformDropdown
groups={issueTypeOptionGroups} groups={issueTypeOptionGroups}
@@ -505,11 +503,11 @@ const Page: React.FC = () => {
<Text numberOfLines={1}> <Text numberOfLines={1}>
{issueType {issueType
? IssueTypeName[issueType] ? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")} : t("seerr.select_an_issue")}
</Text> </Text>
</View> </View>
} }
title={t("jellyseerr.types")} title={t("seerr.types")}
open={issueTypeDropdownOpen} open={issueTypeDropdownOpen}
onOpenChange={setIssueTypeDropdownOpen} onOpenChange={setIssueTypeDropdownOpen}
/> />
@@ -521,7 +519,7 @@ const Page: React.FC = () => {
maxLength={254} maxLength={254}
style={{ color: "white" }} style={{ color: "white" }}
clearButtonMode='always' clearButtonMode='always'
placeholder={t("jellyseerr.describe_the_issue")} placeholder={t("seerr.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
@@ -531,7 +529,7 @@ const Page: React.FC = () => {
</View> </View>
</View> </View>
<Button className='mt-auto' onPress={submitIssue} color='purple'> <Button className='mt-auto' onPress={submitIssue} color='purple'>
{t("jellyseerr.submit_button")} {t("seerr.submit_button")}
</Button> </Button>
</View> </View>
</BottomSheetView> </BottomSheetView>

View File

@@ -5,31 +5,27 @@ import { orderBy, uniqBy } from "lodash";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import { OverviewText } from "@/components/OverviewText"; import { OverviewText } from "@/components/OverviewText";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster"; import SeerrPoster from "@/components/posters/SeerrPoster";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
import { useSeerr } from "@/hooks/useSeerr";
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person"; import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
export default function page() { export default function PersonPage() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { t } = useTranslation(); const { t } = useTranslation();
const { const { seerrApi, seerrLocale: locale } = useSeerr();
jellyseerrApi,
jellyseerrRegion: region,
jellyseerrLocale: locale,
} = useJellyseerr();
const { personId } = local as { personId: string }; const { personId } = local as { personId: string };
const { data } = useQuery({ const { data } = useQuery({
queryKey: ["jellyseerr", "person", personId], queryKey: ["seerr", "person", personId],
queryFn: async () => ({ queryFn: async () => ({
details: await jellyseerrApi?.personDetails(personId), details: await seerrApi?.personDetails(personId),
combinedCredits: await jellyseerrApi?.personCombinedCredits(personId), combinedCredits: await seerrApi?.personCombinedCredits(personId),
}), }),
enabled: !!jellyseerrApi && !!personId, enabled: !!seerrApi && !!personId,
}); });
const castedRoles: PersonCreditCast[] = useMemo( const castedRoles: PersonCreditCast[] = useMemo(
@@ -46,22 +42,19 @@ export default function page() {
); );
const backdrops = useMemo( const backdrops = useMemo(
() => () =>
jellyseerrApi seerrApi
? castedRoles.map((c) => ? castedRoles.map((c) =>
jellyseerrApi.imageProxy( seerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"),
c.backdropPath,
"w1920_and_h800_multi_faces",
),
) )
: [], : [],
[jellyseerrApi, data?.combinedCredits], [seerrApi, data?.combinedCredits],
); );
return ( return (
<ParallaxSlideShow <ParallaxSlideShow
data={castedRoles} data={castedRoles}
images={backdrops} images={backdrops}
listHeader={t("jellyseerr.appearances")} listHeader={t("seerr.appearances")}
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
logo={ logo={
<Image <Image
@@ -69,7 +62,7 @@ export default function page() {
id={data?.details?.id.toString()} id={data?.details?.id.toString()}
className='rounded-full bottom-1' className='rounded-full bottom-1'
source={{ source={{
uri: jellyseerrApi?.imageProxy( uri: seerrApi?.imageProxy(
data?.details?.profilePath, data?.details?.profilePath,
"w600_and_h600_bestv2", "w600_and_h600_bestv2",
), ),
@@ -86,16 +79,13 @@ export default function page() {
<> <>
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text> <Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
<Text className='opacity-50'> <Text className='opacity-50'>
{t("jellyseerr.born")}{" "} {t("seerr.born")}{" "}
{data?.details?.birthday && {data?.details?.birthday &&
new Date(data.details.birthday).toLocaleDateString( new Date(data.details.birthday).toLocaleDateString(locale, {
`${locale}-${region}`, year: "numeric",
{ month: "long",
year: "numeric", day: "numeric",
month: "long", })}{" "}
day: "numeric",
},
)}{" "}
| {data?.details?.placeOfBirth} | {data?.details?.placeOfBirth}
</Text> </Text>
</> </>
@@ -103,7 +93,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) => <JellyseerrPoster item={item} />} renderItem={(item, _index) => <SeerrPoster item={item} />}
/> />
); );
} }

View File

@@ -0,0 +1,216 @@
import { Ionicons } from "@expo/vector-icons";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import type React from "react";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
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 { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import {
buildOfflineSeriesFromEpisodes,
getDownloadedEpisodesForSeries,
} from "@/utils/downloads/offline-series";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { storage } from "@/utils/mmkv";
const page: React.FC = () => {
const navigation = useNavigation();
const { t } = useTranslation();
const params = useLocalSearchParams();
const {
id: seriesId,
seasonIndex,
offline: offlineParam,
} = params as {
id: string;
seasonIndex: string;
offline?: string;
};
const isOffline = offlineParam === "true";
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { getDownloadedItems, downloadedItems } = useDownload();
// For offline mode, construct series data from downloaded episodes
// Include downloadedItems.length so query refetches when items are deleted
const { data: item } = useQuery({
queryKey: ["series", seriesId, isOffline, downloadedItems.length],
queryFn: async () => {
if (isOffline) {
return buildOfflineSeriesFromEpisodes(getDownloadedItems(), seriesId);
}
return await getUserItemData({
api,
userId: user?.Id,
itemId: seriesId,
});
},
staleTime: isOffline ? Infinity : 60 * 1000,
enabled: isOffline || (!!api && !!user?.Id),
});
// For offline mode, use stored base64 image
const base64Image = useMemo(() => {
if (isOffline) {
return storage.getString(seriesId);
}
return null;
}, [isOffline, seriesId]);
const backdropUrl = useMemo(() => {
if (isOffline && base64Image) {
return `data:image/jpeg;base64,${base64Image}`;
}
return getBackdropUrl({
api,
item,
quality: 90,
width: 1000,
});
}, [isOffline, base64Image, api, item]);
const logoUrl = useMemo(() => {
if (isOffline) {
return null; // No logo in offline mode
}
return getLogoImageUrlById({
api,
item,
});
}, [isOffline, api, item]);
const { data: allEpisodes, isLoading } = useQuery({
queryKey: ["AllEpisodes", seriesId, isOffline, downloadedItems.length],
queryFn: async () => {
if (isOffline) {
return getDownloadedEpisodesForSeries(getDownloadedItems(), seriesId);
}
if (!api || !user?.Id) return [];
const res = await getTvShowsApi(api).getEpisodes({
seriesId: seriesId,
userId: user.Id,
enableUserData: true,
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
});
return res?.data.Items || [];
},
select: (data) =>
[...(data || [])].sort(
(a, b) =>
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
),
staleTime: isOffline ? Infinity : 60,
enabled: isOffline || (!!api && !!user?.Id),
});
useEffect(() => {
// Don't show header buttons in offline mode
if (isOffline) {
navigation.setOptions({
headerRight: () => null,
});
return;
}
navigation.setOptions({
headerRight: () =>
!isLoading && item && allEpisodes && allEpisodes.length > 0 ? (
<View className='flex flex-row items-center space-x-2'>
<AddToFavorites item={item} />
{!Platform.isTV && (
<DownloadItems
size='large'
title={t("item_card.download.download_series")}
items={allEpisodes || []}
MissingDownloadIconComponent={() => (
<Ionicons name='download' size={22} color='white' />
)}
DownloadedIconComponent={() => (
<Ionicons
name='checkmark-done-outline'
size={24}
color='#9333ea'
/>
)}
/>
)}
</View>
) : null,
});
}, [allEpisodes, isLoading, item, isOffline]);
// For offline mode, we can show the page even without backdropUrl
if (!item || (!isOffline && !backdropUrl)) return null;
return (
<OfflineModeProvider isOffline={isOffline}>
<ParallaxScrollView
headerHeight={400}
headerImage={
backdropUrl ? (
<Image
source={{
uri: backdropUrl,
}}
style={{
width: "100%",
height: "100%",
}}
/>
) : (
<View
style={{
width: "100%",
height: "100%",
backgroundColor: "#1a1a1a",
}}
/>
)
}
logo={
logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
}}
contentFit='contain'
/>
) : undefined
}
>
<View className='flex flex-col pt-4'>
<SeriesHeader item={item} />
{!isOffline && (
<View className='mb-4'>
<NextUp seriesId={seriesId} />
</View>
)}
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
</View>
</ParallaxScrollView>
</OfflineModeProvider>
);
};
export default page;

View File

@@ -2,6 +2,7 @@ import type {
BaseItemDto, BaseItemDto,
BaseItemDtoQueryResult, BaseItemDtoQueryResult,
BaseItemKind, BaseItemKind,
ItemFilter,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { import {
getFilterApi, getFilterApi,
@@ -27,7 +28,11 @@ import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { import {
FilterByOption,
FilterByPreferenceAtom,
filterByAtom,
genreFilterAtom, genreFilterAtom,
getFilterByPreference,
getSortByPreference, getSortByPreference,
getSortOrderPreference, getSortOrderPreference,
SortByOption, SortByOption,
@@ -39,12 +44,19 @@ import {
sortOrderOptions, sortOrderOptions,
sortOrderPreferenceAtom, sortOrderPreferenceAtom,
tagsFilterAtom, tagsFilterAtom,
useFilterOptions,
yearFilterAtom, yearFilterAtom,
} from "@/utils/atoms/filters"; } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings";
const Page = () => { const Page = () => {
const searchParams = useLocalSearchParams(); const searchParams = useLocalSearchParams() as {
const { libraryId } = searchParams as { libraryId: string }; libraryId: string;
sortBy?: string;
sortOrder?: string;
filterBy?: string;
};
const { libraryId } = searchParams;
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
@@ -54,9 +66,13 @@ const Page = () => {
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom); const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom); const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [sortBy, _setSortBy] = useAtom(sortByAtom); const [sortBy, _setSortBy] = useAtom(sortByAtom);
const [filterBy, _setFilterBy] = useAtom(filterByAtom);
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom); const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom); const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
const [sortOrderPreference, setOderByPreference] = useAtom( const [filterByPreference, setFilterByPreference] = useAtom(
FilterByPreferenceAtom,
);
const [sortOrderPreference, setOrderByPreference] = useAtom(
sortOrderPreferenceAtom, sortOrderPreferenceAtom,
); );
@@ -65,17 +81,33 @@ const Page = () => {
const { t } = useTranslation(); const { t } = useTranslation();
useEffect(() => { useEffect(() => {
const sop = getSortOrderPreference(libraryId, sortOrderPreference); // Check for URL params first (from "See All" navigation)
if (sop) { const urlSortBy = searchParams.sortBy as SortByOption | undefined;
_setSortOrder([sop]); const urlSortOrder = searchParams.sortOrder as SortOrderOption | undefined;
const urlFilterBy = searchParams.filterBy as FilterByOption | undefined;
// Apply sortOrder: URL param > saved preference > default
if (urlSortOrder && Object.values(SortOrderOption).includes(urlSortOrder)) {
_setSortOrder([urlSortOrder]);
} else { } else {
_setSortOrder([SortOrderOption.Ascending]); const sop = getSortOrderPreference(libraryId, sortOrderPreference);
_setSortOrder([sop || SortOrderOption.Ascending]);
} }
const obp = getSortByPreference(libraryId, sortByPreference);
if (obp) { // Apply sortBy: URL param > saved preference > default
_setSortBy([obp]); if (urlSortBy && Object.values(SortByOption).includes(urlSortBy)) {
_setSortBy([urlSortBy]);
} else { } else {
_setSortBy([SortByOption.SortName]); const obp = getSortByPreference(libraryId, sortByPreference);
_setSortBy([obp || SortByOption.SortName]);
}
// Apply filterBy: URL param > saved preference > default
if (urlFilterBy && Object.values(FilterByOption).includes(urlFilterBy)) {
_setFilterBy([urlFilterBy]);
} else {
const fp = getFilterByPreference(libraryId, filterByPreference);
_setFilterBy(fp ? [fp] : []);
} }
}, [ }, [
libraryId, libraryId,
@@ -83,6 +115,11 @@ const Page = () => {
sortByPreference, sortByPreference,
_setSortOrder, _setSortOrder,
_setSortBy, _setSortBy,
filterByPreference,
_setFilterBy,
searchParams.sortBy,
searchParams.sortOrder,
searchParams.filterBy,
]); ]);
const setSortBy = useCallback( const setSortBy = useCallback(
@@ -100,14 +137,28 @@ const Page = () => {
(sortOrder: SortOrderOption[]) => { (sortOrder: SortOrderOption[]) => {
const sop = getSortOrderPreference(libraryId, sortOrderPreference); const sop = getSortOrderPreference(libraryId, sortOrderPreference);
if (sortOrder[0] !== sop) { if (sortOrder[0] !== sop) {
setOderByPreference({ setOrderByPreference({
...sortOrderPreference, ...sortOrderPreference,
[libraryId]: sortOrder[0], [libraryId]: sortOrder[0],
}); });
} }
_setSortOrder(sortOrder); _setSortOrder(sortOrder);
}, },
[libraryId, sortOrderPreference, setOderByPreference, _setSortOrder], [libraryId, sortOrderPreference, setOrderByPreference, _setSortOrder],
);
const setFilter = useCallback(
(filterBy: FilterByOption[]) => {
const fp = getFilterByPreference(libraryId, filterByPreference);
if (filterBy[0] !== fp) {
setFilterByPreference({
...filterByPreference,
[libraryId]: filterBy[0],
});
}
_setFilterBy(filterBy);
},
[libraryId, filterByPreference, setFilterByPreference, _setFilterBy],
); );
const nrOfCols = useMemo(() => { const nrOfCols = useMemo(() => {
@@ -158,6 +209,10 @@ const Page = () => {
itemType = "Series"; itemType = "Series";
} else if (library.CollectionType === "boxsets") { } else if (library.CollectionType === "boxsets") {
itemType = "BoxSet"; itemType = "BoxSet";
} else if (library.CollectionType === "homevideos") {
itemType = "Video";
} else if (library.CollectionType === "musicvideos") {
itemType = "MusicVideo";
} }
const response = await getItemsApi(api).getItems({ const response = await getItemsApi(api).getItems({
@@ -168,6 +223,7 @@ const Page = () => {
sortBy: [sortBy[0], "SortName", "ProductionYear"], sortBy: [sortBy[0], "SortName", "ProductionYear"],
sortOrder: [sortOrder[0]], sortOrder: [sortOrder[0]],
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"], enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
filters: filterBy as ItemFilter[],
// true is needed for merged versions // true is needed for merged versions
recursive: true, recursive: true,
imageTypeLimit: 1, imageTypeLimit: 1,
@@ -190,6 +246,7 @@ const Page = () => {
selectedTags, selectedTags,
sortBy, sortBy,
sortOrder, sortOrder,
filterBy,
], ],
); );
@@ -203,6 +260,7 @@ const Page = () => {
selectedTags, selectedTags,
sortBy, sortBy,
sortOrder, sortOrder,
filterBy,
], ],
queryFn: fetchItems, queryFn: fetchItems,
getNextPageParam: (lastPage, pages) => { getNextPageParam: (lastPage, pages) => {
@@ -268,7 +326,8 @@ const Page = () => {
); );
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const generalFilters = useFilterOptions();
const settings = useSettings();
const ListHeaderComponent = useCallback( const ListHeaderComponent = useCallback(
() => ( () => (
<FlatList <FlatList
@@ -404,6 +463,26 @@ const Page = () => {
/> />
), ),
}, },
{
key: "filterOptions",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='filters'
queryFn={async () => generalFilters.map((s) => s.key)}
set={setFilter}
values={filterBy}
title={t("library.filters.filter_by")}
renderItemLabel={(item) =>
generalFilters.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
]} ]}
renderItem={({ item }) => item.component} renderItem={({ item }) => item.component}
keyExtractor={(item) => item.key} keyExtractor={(item) => item.key}
@@ -424,6 +503,9 @@ const Page = () => {
sortOrder, sortOrder,
setSortOrder, setSortOrder,
isFetching, isFetching,
filterBy,
setFilter,
settings,
], ],
); );

View File

@@ -39,7 +39,6 @@ export default function index() {
() => () =>
data data
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)) ?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
.filter((l) => l.CollectionType !== "music")
.filter((l) => l.CollectionType !== "books") || [], .filter((l) => l.CollectionType !== "books") || [],
[data, settings?.hiddenLibraries], [data, settings?.hiddenLibraries],
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,17 +33,17 @@ export default function SearchLayout() {
headerShadowVisible: false, headerShadowVisible: false,
}} }}
/> />
<Stack.Screen name='jellyseerr/page' options={commonScreenOptions} /> <Stack.Screen name='seerr/page' options={commonScreenOptions} />
<Stack.Screen <Stack.Screen
name='jellyseerr/person/[personId]' name='seerr/person/[personId]'
options={commonScreenOptions} options={commonScreenOptions}
/> />
<Stack.Screen <Stack.Screen
name='jellyseerr/company/[companyId]' name='seerr/company/[companyId]'
options={commonScreenOptions} options={commonScreenOptions}
/> />
<Stack.Screen <Stack.Screen
name='jellyseerr/genre/[genreId]' name='seerr/genre/[genreId]'
options={commonScreenOptions} options={commonScreenOptions}
/> />
</Stack> </Stack>

View File

@@ -3,9 +3,11 @@ import type {
BaseItemKind, BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useAsyncDebouncer } from "@tanstack/react-pacer";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { router, useLocalSearchParams, useNavigation } from "expo-router"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { import {
useCallback, useCallback,
@@ -19,26 +21,28 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Input } from "@/components/common/Input"; import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import {
JellyseerrSearchSort,
JellyserrIndexPage,
} from "@/components/jellyseerr/JellyseerrIndexPage";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster"; import SeriesPoster from "@/components/posters/SeriesPoster";
import { DiscoverFilters } from "@/components/search/DiscoverFilters"; import { DiscoverFilters } from "@/components/search/DiscoverFilters";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton"; import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper"; import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { SearchTabButtons } from "@/components/search/SearchTabButtons"; import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import {
SeerrIndexPage,
SeerrSearchSort,
} from "@/components/seerr/SeerrIndexPage";
import useRouter from "@/hooks/useAppRouter";
import { useSeerr } from "@/hooks/useSeerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { createStreamystatsApi } from "@/utils/streamystats";
type SearchType = "Library" | "Discover"; type SearchType = "Library" | "Discover";
@@ -51,9 +55,10 @@ const exampleSearches = [
"The Mandalorian", "The Mandalorian",
]; ];
export default function search() { export default function SearchPage() {
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter();
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
@@ -67,21 +72,32 @@ export default function search() {
const [searchType, setSearchType] = useState<SearchType>("Library"); const [searchType, setSearchType] = useState<SearchType>("Library");
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
const [debouncedSearch] = useDebounce(search, 500); const [debouncedSearch, setDebouncedSearch] = useState("");
const abortControllerRef = useRef<AbortController | null>(null);
const searchDebouncer = useAsyncDebouncer(
async (query: string) => {
// Cancel previous in-flight requests
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
setDebouncedSearch(query);
return query;
},
{ wait: 200 },
);
useEffect(() => {
searchDebouncer.maybeExecute(search);
}, [search]);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const { settings } = useSettings(); const { settings } = useSettings();
const { jellyseerrApi } = useJellyseerr(); const { seerrApi } = useSeerr();
const [jellyseerrOrderBy, setJellyseerrOrderBy] = const [seerrOrderBy, setSeerrOrderBy] = useState<SeerrSearchSort>(
useState<JellyseerrSearchSort>( SeerrSearchSort[SeerrSearchSort.DEFAULT] as unknown as SeerrSearchSort,
JellyseerrSearchSort[ );
JellyseerrSearchSort.DEFAULT const [seerrSortOrder, setSeerrSortOrder] = useState<"asc" | "desc">("desc");
] as unknown as JellyseerrSearchSort,
);
const [jellyseerrSortOrder, setJellyseerrSortOrder] = useState<
"asc" | "desc"
>("desc");
const searchEngine = useMemo(() => { const searchEngine = useMemo(() => {
return settings?.searchEngine || "Jellyfin"; return settings?.searchEngine || "Jellyfin";
@@ -97,9 +113,11 @@ export default function search() {
async ({ async ({
types, types,
query, query,
signal,
}: { }: {
types: BaseItemKind[]; types: BaseItemKind[];
query: string; query: string;
signal?: AbortSignal;
}): Promise<BaseItemDto[]> => { }): Promise<BaseItemDto[]> => {
if (!api || !query) { if (!api || !query) {
return []; return [];
@@ -107,16 +125,71 @@ export default function search() {
try { try {
if (searchEngine === "Jellyfin") { if (searchEngine === "Jellyfin") {
const searchApi = await getItemsApi(api).getItems({ const searchApi = await getItemsApi(api).getItems(
searchTerm: query, {
limit: 10, searchTerm: query,
includeItemTypes: types, limit: 10,
recursive: true, includeItemTypes: types,
userId: user?.Id, recursive: true,
}); userId: user?.Id,
},
{ signal },
);
return (searchApi.data.Items as BaseItemDto[]) || []; return (searchApi.data.Items as BaseItemDto[]) || [];
} }
if (searchEngine === "Streamystats") {
if (!settings?.streamyStatsServerUrl || !api.accessToken) {
return [];
}
const streamyStatsApi = createStreamystatsApi({
serverUrl: settings.streamyStatsServerUrl,
jellyfinToken: api.accessToken,
});
const typeMap: Record<BaseItemKind, string> = {
Movie: "movies",
Series: "series",
Episode: "episodes",
Person: "actors",
BoxSet: "movies",
Audio: "audio",
} as Record<BaseItemKind, string>;
const searchType = types.length === 1 ? typeMap[types[0]] : "media";
const response = await streamyStatsApi.searchIds(
query,
searchType as "movies" | "series" | "episodes" | "actors" | "media",
10,
signal,
);
const allIds: string[] = [
...(response.data.movies || []),
...(response.data.series || []),
...(response.data.episodes || []),
...(response.data.actors || []),
...(response.data.audio || []),
];
if (!allIds.length) {
return [];
}
const itemsResponse = await getItemsApi(api).getItems(
{
ids: allIds,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
},
{ signal },
);
return (itemsResponse.data.Items as BaseItemDto[]) || [];
}
// Marlin search
if (!settings?.marlinServerUrl) { if (!settings?.marlinServerUrl) {
return []; return [];
} }
@@ -127,7 +200,7 @@ export default function search() {
.map((type) => encodeURIComponent(type)) .map((type) => encodeURIComponent(type))
.join("&includeItemTypes=")}`; .join("&includeItemTypes=")}`;
const response1 = await axios.get(url); const response1 = await axios.get(url, { signal });
const ids = response1.data.ids; const ids = response1.data.ids;
@@ -135,18 +208,63 @@ export default function search() {
return []; return [];
} }
const response2 = await getItemsApi(api).getItems({ const response2 = await getItemsApi(api).getItems(
ids, {
enableImageTypes: ["Primary", "Backdrop", "Thumb"], ids,
}); enableImageTypes: ["Primary", "Backdrop", "Thumb"],
},
{ signal },
);
return (response2.data.Items as BaseItemDto[]) || []; return (response2.data.Items as BaseItemDto[]) || [];
} catch (error) { } catch (error) {
console.error("Error during search:", error); // Silently handle aborted requests
return []; // Ensure an empty array is returned in case of an error if (error instanceof Error && error.name === "AbortError") {
return [];
}
return [];
} }
}, },
[api, searchEngine, settings], [api, searchEngine, settings, user?.Id],
);
// Separate search function for music types - always uses Jellyfin since Streamystats doesn't support music
const jellyfinSearchFn = useCallback(
async ({
types,
query,
signal,
}: {
types: BaseItemKind[];
query: string;
signal?: AbortSignal;
}): Promise<BaseItemDto[]> => {
if (!api || !query) {
return [];
}
try {
const searchApi = await getItemsApi(api).getItems(
{
searchTerm: query,
limit: 10,
includeItemTypes: types,
recursive: true,
userId: user?.Id,
},
{ signal },
);
return (searchApi.data.Items as BaseItemDto[]) || [];
} catch (error) {
// Silently handle aborted requests
if (error instanceof Error && error.name === "AbortError") {
return [];
}
return [];
}
},
[api, user?.Id],
); );
type HeaderSearchBarRef = { type HeaderSearchBarRef = {
@@ -195,6 +313,7 @@ export default function search() {
searchFn({ searchFn({
query: debouncedSearch, query: debouncedSearch,
types: ["Movie"], types: ["Movie"],
signal: abortControllerRef.current?.signal,
}), }),
enabled: searchType === "Library" && debouncedSearch.length > 0, enabled: searchType === "Library" && debouncedSearch.length > 0,
}); });
@@ -205,6 +324,7 @@ export default function search() {
searchFn({ searchFn({
query: debouncedSearch, query: debouncedSearch,
types: ["Series"], types: ["Series"],
signal: abortControllerRef.current?.signal,
}), }),
enabled: searchType === "Library" && debouncedSearch.length > 0, enabled: searchType === "Library" && debouncedSearch.length > 0,
}); });
@@ -215,6 +335,7 @@ export default function search() {
searchFn({ searchFn({
query: debouncedSearch, query: debouncedSearch,
types: ["Episode"], types: ["Episode"],
signal: abortControllerRef.current?.signal,
}), }),
enabled: searchType === "Library" && debouncedSearch.length > 0, enabled: searchType === "Library" && debouncedSearch.length > 0,
}); });
@@ -225,6 +346,7 @@ export default function search() {
searchFn({ searchFn({
query: debouncedSearch, query: debouncedSearch,
types: ["BoxSet"], types: ["BoxSet"],
signal: abortControllerRef.current?.signal,
}), }),
enabled: searchType === "Library" && debouncedSearch.length > 0, enabled: searchType === "Library" && debouncedSearch.length > 0,
}); });
@@ -235,6 +357,52 @@ export default function search() {
searchFn({ searchFn({
query: debouncedSearch, query: debouncedSearch,
types: ["Person"], types: ["Person"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
// Music search queries - always use Jellyfin since Streamystats doesn't support music
const { data: artists, isFetching: l9 } = useQuery({
queryKey: ["search", "artists", debouncedSearch],
queryFn: () =>
jellyfinSearchFn({
query: debouncedSearch,
types: ["MusicArtist"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: albums, isFetching: l10 } = useQuery({
queryKey: ["search", "albums", debouncedSearch],
queryFn: () =>
jellyfinSearchFn({
query: debouncedSearch,
types: ["MusicAlbum"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: songs, isFetching: l11 } = useQuery({
queryKey: ["search", "songs", debouncedSearch],
queryFn: () =>
jellyfinSearchFn({
query: debouncedSearch,
types: ["Audio"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: playlists, isFetching: l12 } = useQuery({
queryKey: ["search", "playlists", debouncedSearch],
queryFn: () =>
jellyfinSearchFn({
query: debouncedSearch,
types: ["Playlist"],
signal: abortControllerRef.current?.signal,
}), }),
enabled: searchType === "Library" && debouncedSearch.length > 0, enabled: searchType === "Library" && debouncedSearch.length > 0,
}); });
@@ -245,13 +413,27 @@ export default function search() {
episodes?.length || episodes?.length ||
series?.length || series?.length ||
collections?.length || collections?.length ||
actors?.length actors?.length ||
artists?.length ||
albums?.length ||
songs?.length ||
playlists?.length
); );
}, [episodes, movies, series, collections, actors]); }, [
episodes,
movies,
series,
collections,
actors,
artists,
albums,
songs,
playlists,
]);
const loading = useMemo(() => { const loading = useMemo(() => {
return l1 || l2 || l3 || l7 || l8; return l1 || l2 || l3 || l7 || l8 || l9 || l10 || l11 || l12;
}, [l1, l2, l3, l7, l8]); }, [l1, l2, l3, l7, l8, l9, l10, l11, l12]);
return ( return (
<ScrollView <ScrollView
@@ -260,6 +442,7 @@ export default function search() {
contentContainerStyle={{ contentContainerStyle={{
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
paddingBottom: 60,
}} }}
> >
{/* <View {/* <View
@@ -286,7 +469,7 @@ export default function search() {
className='flex flex-col' className='flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }} style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
> >
{jellyseerrApi && ( {seerrApi && (
<View className='pl-4 pr-4 flex flex-row'> <View className='pl-4 pr-4 flex flex-row'>
<SearchTabButtons <SearchTabButtons
searchType={searchType} searchType={searchType}
@@ -300,10 +483,10 @@ export default function search() {
<DiscoverFilters <DiscoverFilters
searchFilterId={searchFilterId} searchFilterId={searchFilterId}
orderFilterId={orderFilterId} orderFilterId={orderFilterId}
jellyseerrOrderBy={jellyseerrOrderBy} seerrOrderBy={seerrOrderBy}
setJellyseerrOrderBy={setJellyseerrOrderBy} setSeerrOrderBy={setSeerrOrderBy}
jellyseerrSortOrder={jellyseerrSortOrder} seerrSortOrder={seerrSortOrder}
setJellyseerrSortOrder={setJellyseerrSortOrder} setSeerrSortOrder={setSeerrSortOrder}
t={t} t={t}
/> />
)} )}
@@ -398,12 +581,178 @@ export default function search() {
</TouchableItemRouter> </TouchableItemRouter>
)} )}
/> />
{/* Music search results */}
<SearchItemWrapper
items={artists}
header={t("search.artists")}
renderItem={(item: BaseItemDto) => {
const imageUrl = getPrimaryImageUrl({ api, item });
return (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-24 mr-2 items-center'
>
<View
style={{
width: 80,
height: 80,
borderRadius: 40,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Text className='text-xl'>👤</Text>
</View>
)}
</View>
<Text numberOfLines={2} className='mt-2 text-center'>
{item.Name}
</Text>
</TouchableItemRouter>
);
}}
/>
<SearchItemWrapper
items={albums}
header={t("search.albums")}
renderItem={(item: BaseItemDto) => {
const imageUrl = getPrimaryImageUrl({ api, item });
return (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-28 mr-2'
>
<View
style={{
width: 112,
height: 112,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Text className='text-4xl'>🎵</Text>
</View>
)}
</View>
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className='opacity-50 text-xs' numberOfLines={1}>
{item.AlbumArtist || item.Artists?.join(", ")}
</Text>
</TouchableItemRouter>
);
}}
/>
<SearchItemWrapper
items={songs}
header={t("search.songs")}
renderItem={(item: BaseItemDto) => {
const imageUrl = getPrimaryImageUrl({ api, item });
return (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-28 mr-2'
>
<View
style={{
width: 112,
height: 112,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Text className='text-4xl'>🎵</Text>
</View>
)}
</View>
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className='opacity-50 text-xs' numberOfLines={1}>
{item.Artists?.join(", ") || item.AlbumArtist}
</Text>
</TouchableItemRouter>
);
}}
/>
<SearchItemWrapper
items={playlists}
header={t("search.playlists")}
renderItem={(item: BaseItemDto) => {
const imageUrl = getPrimaryImageUrl({ api, item });
return (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-28 mr-2'
>
<View
style={{
width: 112,
height: 112,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Text className='text-4xl'>🎶</Text>
</View>
)}
</View>
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className='opacity-50 text-xs'>
{item.ChildCount} tracks
</Text>
</TouchableItemRouter>
);
}}
/>
</View> </View>
) : ( ) : (
<JellyserrIndexPage <SeerrIndexPage
searchQuery={debouncedSearch} searchQuery={debouncedSearch}
sortType={jellyseerrOrderBy} sortType={seerrOrderBy}
order={jellyseerrSortOrder} order={seerrSortOrder}
/> />
)} )}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,15 +7,15 @@ import type {
ParamListBase, ParamListBase,
TabNavigationState, TabNavigationState,
} from "@react-navigation/native"; } from "@react-navigation/native";
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router"; import { withLayoutContext } from "expo-router";
import { useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform } from "react-native"; import { Platform, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
import { storage } from "@/utils/mmkv";
const { Navigator } = createNativeBottomTabNavigator(); const { Navigator } = createNativeBottomTabNavigator();
@@ -29,25 +29,9 @@ export const NativeTabs = withLayoutContext<
export default function TabLayout() { export default function TabLayout() {
const { settings } = useSettings(); const { settings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
useFocusEffect(
useCallback(() => {
const hasShownIntro = storage.getBoolean("hasShownIntro");
if (!hasShownIntro) {
const timer = setTimeout(() => {
router.push("/intro/page");
}, 1000);
return () => {
clearTimeout(timer);
};
}
}, []),
);
return ( return (
<> <View style={{ flex: 1 }}>
<SystemBars hidden={false} style='light' /> <SystemBars hidden={false} style='light' />
<NativeTabs <NativeTabs
sidebarAdaptable={false} sidebarAdaptable={false}
@@ -100,6 +84,18 @@ export default function TabLayout() {
: (_e) => ({ sfSymbol: "heart.fill" }), : (_e) => ({ sfSymbol: "heart.fill" }),
}} }}
/> />
<NativeTabs.Screen
name='(watchlists)'
options={{
title: t("watchlists.title"),
tabBarItemHidden:
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab,
tabBarIcon:
Platform.OS === "android"
? (_e) => require("@/assets/icons/list.png")
: (_e) => ({ sfSymbol: "list.bullet.rectangle" }),
}}
/>
<NativeTabs.Screen <NativeTabs.Screen
name='(libraries)' name='(libraries)'
options={{ options={{
@@ -122,6 +118,8 @@ export default function TabLayout() {
}} }}
/> />
</NativeTabs> </NativeTabs>
</> <MiniPlayerBar />
<MusicPlaybackEngine />
</View>
); );
} }

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

@@ -0,0 +1,845 @@
import { ExpoAvRoutePickerView } from "@douglowder/expo-av-route-picker-view";
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
ActivityIndicator,
Dimensions,
Platform,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import DraggableFlatList, {
type RenderItemParams,
ScaleDecorator,
} from "react-native-draggable-flatlist";
import { CastButton, CastState } from "react-native-google-cast";
import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import TextTicker from "react-native-text-ticker";
import type { VolumeResult } from "react-native-volume-manager";
import { Badge } from "@/components/Badge";
import { Text } from "@/components/common/Text";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
import useRouter from "@/hooks/useAppRouter";
import { useFavorite } from "@/hooks/useFavorite";
import { useMusicCast } from "@/hooks/useMusicCast";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
type RepeatMode,
useMusicPlayer,
} from "@/providers/MusicPlayerProvider";
import { formatBitrate } from "@/utils/bitrate";
import { formatDuration } from "@/utils/time";
// Conditionally require VolumeManager (not available on TV)
const VolumeManager = Platform.isTV
? null
: require("react-native-volume-manager");
const formatFileSize = (bytes?: number | null) => {
if (!bytes) return null;
const sizes = ["B", "KB", "MB", "GB"];
if (bytes === 0) return "0 B";
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
};
const formatSampleRate = (sampleRate?: number | null) => {
if (!sampleRate) return null;
return `${(sampleRate / 1000).toFixed(1)} kHz`;
};
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const ARTWORK_SIZE = SCREEN_WIDTH - 80;
type ViewMode = "player" | "queue";
export default function NowPlayingScreen() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const router = useRouter();
const insets = useSafeAreaInsets();
const [viewMode, setViewMode] = useState<ViewMode>("player");
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
const {
isConnected: isCastConnected,
castQueue,
castState,
} = useMusicCast({
api,
userId: user?.Id,
});
const {
currentTrack,
queue,
queueIndex,
isPlaying,
isLoading,
progress,
duration,
repeatMode,
shuffleEnabled,
mediaSource,
isTranscoding,
togglePlayPause,
next,
previous,
seek,
setRepeatMode,
toggleShuffle,
jumpToIndex,
removeFromQueue,
reorderQueue,
stop,
pause,
} = useMusicPlayer();
const { isFavorite, toggleFavorite } = useFavorite(
currentTrack ?? ({ Id: "" } as BaseItemDto),
);
const sliderProgress = useSharedValue(0);
const sliderMin = useSharedValue(0);
const sliderMax = useSharedValue(1);
useEffect(() => {
sliderProgress.value = progress;
}, [progress, sliderProgress]);
useEffect(() => {
sliderMax.value = duration > 0 ? duration : 1;
}, [duration, sliderMax]);
// Auto-cast queue when Chromecast becomes connected and pause local playback
const prevCastState = useRef<CastState | null | undefined>(null);
useEffect(() => {
if (
castState === CastState.CONNECTED &&
prevCastState.current !== CastState.CONNECTED &&
queue.length > 0
) {
// Just connected - pause local playback and cast the queue
pause();
castQueue({ queue, startIndex: queueIndex });
}
prevCastState.current = castState;
}, [castState, queue, queueIndex, castQueue, pause]);
const imageUrl = useMemo(() => {
if (!api || !currentTrack) return null;
const albumId = currentTrack.AlbumId || currentTrack.ParentId;
if (albumId) {
return `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=600&maxWidth=600`;
}
return `${api.basePath}/Items/${currentTrack.Id}/Images/Primary?maxHeight=600&maxWidth=600`;
}, [api, currentTrack]);
const progressText = useMemo(() => {
const progressTicks = progress * 10000000;
return formatDuration(progressTicks);
}, [progress]);
const _durationText = useMemo(() => {
const durationTicks = duration * 10000000;
return formatDuration(durationTicks);
}, [duration]);
const remainingText = useMemo(() => {
const remaining = Math.max(0, duration - progress);
const remainingTicks = remaining * 10000000;
return `-${formatDuration(remainingTicks)}`;
}, [duration, progress]);
const handleSliderComplete = useCallback(
(value: number) => {
seek(value);
},
[seek],
);
const handleClose = useCallback(() => {
router.back();
}, [router]);
const _handleStop = useCallback(() => {
stop();
router.back();
}, [stop, router]);
const cycleRepeatMode = useCallback(() => {
const modes: RepeatMode[] = ["off", "all", "one"];
const currentIndex = modes.indexOf(repeatMode);
const nextMode = modes[(currentIndex + 1) % modes.length];
setRepeatMode(nextMode);
}, [repeatMode, setRepeatMode]);
const handleOptionsPress = useCallback(() => {
setTrackOptionsOpen(true);
}, []);
const handleAddToPlaylist = useCallback(() => {
setPlaylistPickerOpen(true);
}, []);
const handleCreateNewPlaylist = useCallback(() => {
setCreatePlaylistOpen(true);
}, []);
const getRepeatIcon = (): string => {
switch (repeatMode) {
case "one":
return "repeat";
case "all":
return "repeat";
default:
return "repeat";
}
};
const canGoNext = queueIndex < queue.length - 1 || repeatMode === "all";
const canGoPrevious = queueIndex > 0 || progress > 3 || repeatMode === "all";
if (!currentTrack) {
return (
<BottomSheetModalProvider>
<View
className='flex-1 bg-[#121212] items-center justify-center'
style={{
paddingTop: Platform.OS === "android" ? insets.top : 0,
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}}
>
<Text className='text-neutral-500'>No track playing</Text>
</View>
</BottomSheetModalProvider>
);
}
return (
<BottomSheetModalProvider>
<View
className='flex-1 bg-[#121212]'
style={{
paddingTop: Platform.OS === "android" ? insets.top : 0,
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}}
>
{/* Header */}
<View className='flex-row items-center justify-between px-4 pt-3 pb-2'>
<TouchableOpacity
onPress={handleClose}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='p-2'
>
<Ionicons name='chevron-down' size={28} color='white' />
</TouchableOpacity>
<View className='flex-row'>
<TouchableOpacity
onPress={() => setViewMode("player")}
className='px-3 py-1'
>
<Text
className={
viewMode === "player"
? "text-white font-semibold"
: "text-neutral-500"
}
>
Now Playing
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setViewMode("queue")}
className='px-3 py-1'
>
<Text
className={
viewMode === "queue"
? "text-white font-semibold"
: "text-neutral-500"
}
>
Queue ({queue.length})
</Text>
</TouchableOpacity>
</View>
{/* Empty placeholder to balance header layout */}
<View className='p-2' style={{ width: 44 }} />
</View>
{viewMode === "player" ? (
<PlayerView
api={api}
currentTrack={currentTrack}
imageUrl={imageUrl}
sliderProgress={sliderProgress}
sliderMin={sliderMin}
sliderMax={sliderMax}
progressText={progressText}
remainingText={remainingText}
isPlaying={isPlaying}
isLoading={isLoading}
repeatMode={repeatMode}
shuffleEnabled={shuffleEnabled}
canGoNext={canGoNext}
canGoPrevious={canGoPrevious}
onSliderComplete={handleSliderComplete}
onTogglePlayPause={togglePlayPause}
onNext={next}
onPrevious={previous}
onCycleRepeat={cycleRepeatMode}
onToggleShuffle={toggleShuffle}
getRepeatIcon={getRepeatIcon}
mediaSource={mediaSource}
isTranscoding={isTranscoding}
isFavorite={isFavorite}
onToggleFavorite={toggleFavorite}
onOptionsPress={handleOptionsPress}
isCastConnected={isCastConnected}
/>
) : (
<QueueView
api={api}
queue={queue}
queueIndex={queueIndex}
onJumpToIndex={jumpToIndex}
onRemoveFromQueue={removeFromQueue}
onReorderQueue={reorderQueue}
/>
)}
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={currentTrack}
onAddToPlaylist={handleAddToPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={currentTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={currentTrack?.Id}
/>
</View>
</BottomSheetModalProvider>
);
}
interface PlayerViewProps {
api: any;
currentTrack: BaseItemDto;
imageUrl: string | null;
sliderProgress: any;
sliderMin: any;
sliderMax: any;
progressText: string;
remainingText: string;
isPlaying: boolean;
isLoading: boolean;
repeatMode: RepeatMode;
shuffleEnabled: boolean;
canGoNext: boolean;
canGoPrevious: boolean;
onSliderComplete: (value: number) => void;
onTogglePlayPause: () => void;
onNext: () => void;
onPrevious: () => void;
onCycleRepeat: () => void;
onToggleShuffle: () => void;
getRepeatIcon: () => string;
mediaSource: MediaSourceInfo | null;
isTranscoding: boolean;
isFavorite: boolean | undefined;
onToggleFavorite: () => void;
onOptionsPress: () => void;
isCastConnected: boolean;
}
const PlayerView: React.FC<PlayerViewProps> = ({
currentTrack,
imageUrl,
sliderProgress,
sliderMin,
sliderMax,
progressText,
remainingText,
isPlaying,
isLoading,
repeatMode,
shuffleEnabled,
canGoNext,
canGoPrevious,
onSliderComplete,
onTogglePlayPause,
onNext,
onPrevious,
onCycleRepeat,
onToggleShuffle,
getRepeatIcon,
mediaSource,
isTranscoding,
isFavorite,
onToggleFavorite,
onOptionsPress,
isCastConnected,
}) => {
const audioStream = useMemo(() => {
return mediaSource?.MediaStreams?.find((stream) => stream.Type === "Audio");
}, [mediaSource]);
// Volume slider state
const volumeProgress = useSharedValue(0);
const volumeMin = useSharedValue(0);
const volumeMax = useSharedValue(1);
const isTv = Platform.isTV;
useEffect(() => {
if (isTv || !VolumeManager) return;
// Get initial volume
VolumeManager.getVolume().then(({ volume }: { volume: number }) => {
volumeProgress.value = volume;
});
// Listen to volume changes
const listener = VolumeManager.addVolumeListener((result: VolumeResult) => {
volumeProgress.value = result.volume;
});
return () => listener.remove();
}, [isTv, volumeProgress]);
const handleVolumeChange = useCallback((value: number) => {
if (VolumeManager) {
VolumeManager.setVolume(value);
}
}, []);
const fileSize = formatFileSize(mediaSource?.Size);
const codec = audioStream?.Codec?.toUpperCase();
const bitrate = formatBitrate(audioStream?.BitRate);
const sampleRate = formatSampleRate(audioStream?.SampleRate);
const playbackMethod = isTranscoding ? "Transcoding" : "Direct";
const hasAudioStats =
mediaSource && (fileSize || codec || bitrate || sampleRate);
return (
<ScrollView className='flex-1 px-6' showsVerticalScrollIndicator={false}>
{/* Album artwork */}
<View
className='self-center mb-8 mt-4'
style={{
width: ARTWORK_SIZE,
height: ARTWORK_SIZE,
borderRadius: 12,
overflow: "hidden",
backgroundColor: "#1a1a1a",
shadowColor: "#000",
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.4,
shadowRadius: 16,
elevation: 10,
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Ionicons name='musical-note' size={80} color='#666' />
</View>
)}
</View>
{/* Track info with actions */}
<View className='mb-6'>
<View className='flex-row items-start justify-between'>
<View className='flex-1 mr-4'>
<TextTicker
style={{ color: "white", fontSize: 24, fontWeight: "bold" }}
duration={Math.max(4000, (currentTrack.Name?.length || 0) * 250)}
loop
bounce={false}
repeatSpacer={80}
marqueeDelay={1500}
scroll={false}
animationType='scroll'
easing={(t) => t}
>
{currentTrack.Name}
</TextTicker>
<TextTicker
style={{ color: "#a3a3a3", fontSize: 18 }}
duration={Math.max(
4000,
(
currentTrack.Artists?.join(", ") ||
currentTrack.AlbumArtist ||
""
).length * 250,
)}
loop
bounce={false}
repeatSpacer={80}
marqueeDelay={2000}
scroll={false}
animationType='scroll'
easing={(t) => t}
>
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
</TextTicker>
</View>
<TouchableOpacity
onPress={onToggleFavorite}
className='p-2'
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons
name={isFavorite ? "heart" : "heart-outline"}
size={24}
color={isFavorite ? "#ec4899" : "white"}
/>
</TouchableOpacity>
<TouchableOpacity onPress={onOptionsPress} className='p-2'>
<Ionicons name='ellipsis-horizontal' size={24} color='white' />
</TouchableOpacity>
</View>
{/* Audio Stats */}
{hasAudioStats && (
<View className='flex-row flex-wrap gap-1.5 mt-3'>
{fileSize && <Badge variant='gray' text={fileSize} />}
{codec && <Badge variant='gray' text={codec} />}
<Badge
variant='gray'
text={playbackMethod}
iconLeft={
<Ionicons
name={isTranscoding ? "swap-horizontal" : "play"}
size={12}
color='white'
/>
}
/>
{bitrate && bitrate !== "N/A" && (
<Badge variant='gray' text={bitrate} />
)}
{sampleRate && <Badge variant='gray' text={sampleRate} />}
</View>
)}
</View>
{/* Progress slider */}
<View className='mb-4'>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#666",
}}
progress={sliderProgress}
minimumValue={sliderMin}
maximumValue={sliderMax}
onSlidingComplete={onSliderComplete}
renderThumb={() => null}
sliderHeight={8}
containerStyle={{ borderRadius: 100 }}
renderBubble={() => null}
/>
<View className='flex flex-row justify-between mt-2'>
<Text className='text-neutral-500 text-xs'>{progressText}</Text>
<Text className='text-neutral-500 text-xs'>{remainingText}</Text>
</View>
</View>
{/* Main Controls with Shuffle & Repeat */}
<View className='flex flex-row items-center justify-center mb-6'>
<TouchableOpacity onPress={onToggleShuffle} className='p-3'>
<Ionicons
name='shuffle'
size={24}
color={shuffleEnabled ? "#9334E9" : "#666"}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={onPrevious}
disabled={!canGoPrevious || isLoading}
className='p-4'
style={{ opacity: canGoPrevious && !isLoading ? 1 : 0.3 }}
>
<Ionicons name='play-skip-back' size={32} color='white' />
</TouchableOpacity>
<TouchableOpacity
onPress={onTogglePlayPause}
disabled={isLoading}
className='mx-4 bg-white rounded-full p-4'
>
{isLoading ? (
<ActivityIndicator size={36} color='#121212' />
) : (
<Ionicons
name={isPlaying ? "pause" : "play"}
size={36}
color='#121212'
style={isPlaying ? {} : { marginLeft: 4 }}
/>
)}
</TouchableOpacity>
<TouchableOpacity
onPress={onNext}
disabled={!canGoNext || isLoading}
className='p-4'
style={{ opacity: canGoNext && !isLoading ? 1 : 0.3 }}
>
<Ionicons name='play-skip-forward' size={32} color='white' />
</TouchableOpacity>
<TouchableOpacity onPress={onCycleRepeat} className='p-3 relative'>
<Ionicons
name={getRepeatIcon() as any}
size={24}
color={repeatMode !== "off" ? "#9334E9" : "#666"}
/>
{repeatMode === "one" && (
<View className='absolute right-0 top-1 bg-purple-600 rounded-full w-4 h-4 items-center justify-center'>
<Text className='text-white text-[10px] font-bold'>1</Text>
</View>
)}
</TouchableOpacity>
</View>
{/* Volume Slider */}
{!isTv && VolumeManager && (
<View className='flex-row items-center mb-6'>
<Ionicons name='volume-low' size={20} color='#666' />
<View className='flex-1 mx-3'>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
}}
progress={volumeProgress}
minimumValue={volumeMin}
maximumValue={volumeMax}
onSlidingComplete={handleVolumeChange}
renderThumb={() => null}
sliderHeight={8}
containerStyle={{ borderRadius: 100 }}
renderBubble={() => null}
/>
</View>
<Ionicons name='volume-high' size={20} color='#666' />
</View>
)}
{/* AirPlay & Chromecast Buttons */}
{!isTv && (
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 32,
marginBottom: 16,
}}
>
{/* AirPlay (iOS only) */}
{Platform.OS === "ios" && (
<View style={{ transform: [{ scale: 2.8 }] }}>
<ExpoAvRoutePickerView
style={{ width: 24, height: 24 }}
tintColor='#666666'
activeTintColor='#9334E9'
/>
</View>
)}
{/* Chromecast */}
<CastButton
style={{
width: 24,
height: 24,
tintColor: isCastConnected ? "#9334E9" : "#666",
transform: [{ translateY: 1 }],
}}
/>
</View>
)}
</ScrollView>
);
};
interface QueueViewProps {
api: any;
queue: BaseItemDto[];
queueIndex: number;
onJumpToIndex: (index: number) => void;
onRemoveFromQueue: (index: number) => void;
onReorderQueue: (newQueue: BaseItemDto[]) => void;
}
const QueueView: React.FC<QueueViewProps> = ({
api,
queue,
queueIndex,
onJumpToIndex,
onRemoveFromQueue,
onReorderQueue,
}) => {
const renderQueueItem = useCallback(
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
const index = getIndex() ?? 0;
const isCurrentTrack = index === queueIndex;
const isPast = index < queueIndex;
const albumId = item.AlbumId || item.ParentId;
const imageUrl = api
? albumId
? `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=80&maxWidth=80`
: `${api.basePath}/Items/${item.Id}/Images/Primary?maxHeight=80&maxWidth=80`
: null;
return (
<ScaleDecorator>
<TouchableOpacity
onPress={() => onJumpToIndex(index)}
onLongPress={drag}
disabled={isActive}
className='flex-row items-center px-4 py-3'
style={{
opacity: isPast && !isActive ? 0.5 : 1,
backgroundColor: isActive
? "#2a2a2a"
: isCurrentTrack
? "rgba(147, 52, 233, 0.3)"
: "#121212",
}}
>
{/* Drag handle */}
<TouchableOpacity
onPressIn={drag}
disabled={isActive}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='pr-2'
>
<Ionicons
name='reorder-three'
size={20}
color={isActive ? "#9334E9" : "#666"}
/>
</TouchableOpacity>
{/* Album art */}
<View className='w-12 h-12 rounded overflow-hidden bg-neutral-800 mr-3'>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View className='flex-1 items-center justify-center'>
<Ionicons name='musical-note' size={16} color='#666' />
</View>
)}
</View>
{/* Track info */}
<View className='flex-1 mr-2'>
<Text
numberOfLines={1}
className={`text-base ${isCurrentTrack ? "text-purple-400 font-semibold" : "text-white"}`}
>
{item.Name}
</Text>
<Text numberOfLines={1} className='text-neutral-500 text-sm'>
{item.Artists?.join(", ") || item.AlbumArtist}
</Text>
</View>
{/* Now playing indicator */}
{isCurrentTrack && (
<Ionicons name='musical-note' size={16} color='#9334E9' />
)}
{/* Remove button (not for current track) */}
{!isCurrentTrack && (
<TouchableOpacity
onPress={() => onRemoveFromQueue(index)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='p-2'
>
<Ionicons name='close' size={20} color='#666' />
</TouchableOpacity>
)}
</TouchableOpacity>
</ScaleDecorator>
);
},
[api, queueIndex, onJumpToIndex, onRemoveFromQueue],
);
const handleDragEnd = useCallback(
({ data }: { data: BaseItemDto[] }) => {
onReorderQueue(data);
},
[onReorderQueue],
);
const history = queue.slice(0, queueIndex);
return (
<DraggableFlatList
data={queue}
keyExtractor={(item, index) => `${item.Id}-${index}`}
renderItem={renderQueueItem}
onDragEnd={handleDragEnd}
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<View className='px-4 py-2'>
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
{history.length > 0 ? "Playing from queue" : "Up next"}
</Text>
</View>
}
ListEmptyComponent={
<View className='flex-1 items-center justify-center py-20'>
<Text className='text-neutral-500'>Queue is empty</Text>
</View>
}
/>
);
};

View File

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

View File

@@ -3,7 +3,6 @@ import {
type MediaSourceInfo, type MediaSourceInfo,
PlaybackOrder, PlaybackOrder,
PlaybackProgressInfo, PlaybackProgressInfo,
PlaybackStartInfo,
RepeatMode, RepeatMode,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { import {
@@ -11,64 +10,76 @@ import {
getUserLibraryApi, getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { router, useGlobalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, Platform, View } from "react-native"; import { Alert, Platform, useWindowDimensions, View } from "react-native";
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated"; import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
import { BITRATES } from "@/components/BitrateSelector"; import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls"; import { Controls } from "@/components/video-player/controls/Controls";
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
import { import {
OUTLINE_THICKNESS, PlaybackSpeedScope,
OutlineThickness, updatePlaybackSpeedSettings,
VLC_COLORS, } from "@/components/video-player/controls/utils/playback-speed-settings";
VLCColor, import useRouter from "@/hooks/useAppRouter";
} from "@/constants/SubtitleConstants";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets"; import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules"; import {
import type { type MpvOnErrorEventPayload,
PlaybackStatePayload, type MpvOnPlaybackStateChangePayload,
ProgressUpdatePayload, type MpvOnProgressEventPayload,
VlcPlayerViewRef, MpvPlayerView,
} from "@/modules/VlcPlayer.types"; type MpvPlayerViewRef,
type MpvVideoSource,
} from "@/modules";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types"; import { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import {
getMpvAudioId,
getMpvSubtitleId,
} from "@/utils/jellyfin/subtitleUtils";
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";
export default function page() { export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null); const videoRef = useRef<MpvPlayerViewRef>(null);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const navigation = useNavigation(); const navigation = useNavigation();
const router = useRouter();
const { settings, updateSettings } = useSettings();
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
const [isPipMode, setIsPipMode] = useState(false); const [isPipMode, setIsPipMode] = useState(false);
const [aspectRatio, setAspectRatio] = useState< const [isZoomedToFill, setIsZoomedToFill] = useState(false);
"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);
const [isVideoLoaded, setIsVideoLoaded] = useState(false); const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [tracksReady, setTracksReady] = useState(false);
const [hasPlaybackStarted, setHasPlaybackStarted] = useState(false);
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0);
const [showTechnicalInfo, setShowTechnicalInfo] = useState(false);
const progress = useSharedValue(0); const progress = useSharedValue(0);
const isSeeking = useSharedValue(false); const isSeeking = useSharedValue(false);
@@ -78,10 +89,9 @@ export default function page() {
: require("react-native-volume-manager"); : require("react-native-volume-manager");
const downloadUtils = useDownload(); const downloadUtils = useDownload();
const downloadedFiles = useMemo( // Call directly instead of useMemo - the function reference doesn't change
() => downloadUtils.getDownloadedItems(), // when data updates, only when the provider initializes
[downloadUtils.getDownloadedItems], const downloadedFiles = downloadUtils.getDownloadedItems();
);
const revalidateProgressCache = useInvalidatePlaybackProgressCache(); const revalidateProgressCache = useInvalidatePlaybackProgressCache();
@@ -100,7 +110,7 @@ export default function page() {
bitrateValue: bitrateValueStr, bitrateValue: bitrateValueStr,
offline: offlineStr, offline: offlineStr,
playbackPosition: playbackPositionFromUrl, playbackPosition: playbackPositionFromUrl,
} = useGlobalSearchParams<{ } = useLocalSearchParams<{
itemId: string; itemId: string;
audioIndex: string; audioIndex: string;
subtitleIndex: string; subtitleIndex: string;
@@ -110,13 +120,14 @@ export default function page() {
/** Playback position in ticks. */ /** Playback position in ticks. */
playbackPosition?: string; playbackPosition?: string;
}>(); }>();
const { settings } = useSettings();
const { lockOrientation, unlockOrientation } = useOrientation(); const { lockOrientation, unlockOrientation } = useOrientation();
const offline = offlineStr === "true"; const offline = offlineStr === "true";
const playbackManager = usePlaybackManager(); const playbackManager = usePlaybackManager({ isOffline: offline });
const audioIndex = audioIndexStr // Audio index: use URL param if provided, otherwise use stored index for offline playback
// This is computed after downloadedItem is available, see audioIndexResolved below
const audioIndexFromUrl = audioIndexStr
? Number.parseInt(audioIndexStr, 10) ? Number.parseInt(audioIndexStr, 10)
: undefined; : undefined;
const subtitleIndex = subtitleIndexStr const subtitleIndex = subtitleIndexStr
@@ -135,13 +146,49 @@ export default function page() {
isError: false, isError: false,
}); });
// Resolve audio index: use URL param if provided, otherwise use stored index for offline playback
const audioIndex = useMemo(() => {
if (audioIndexFromUrl !== undefined) {
return audioIndexFromUrl;
}
if (offline && downloadedItem?.userData?.audioStreamIndex !== undefined) {
return downloadedItem.userData.audioStreamIndex;
}
return undefined;
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
// Get the playback speed for this item based on settings
const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed(
item,
settings,
);
// Handler for changing playback speed
const handleSetPlaybackSpeed = useCallback(
async (speed: number, scope: PlaybackSpeedScope) => {
// Update settings based on scope
updatePlaybackSpeedSettings(
speed,
scope,
item ?? undefined,
settings,
updateSettings,
);
// Apply speed to the current player (MPV)
setCurrentPlaybackSpeed(speed);
await videoRef.current?.setSpeed?.(speed);
},
[item, settings, updateSettings],
);
/** Gets the initial playback position from the URL. */ /** Gets the initial playback position from the URL. */
const getInitialPlaybackTicks = useCallback((): number => { const getInitialPlaybackTicks = useCallback((): number => {
if (playbackPositionFromUrl) { if (playbackPositionFromUrl) {
return Number.parseInt(playbackPositionFromUrl, 10); return Number.parseInt(playbackPositionFromUrl, 10);
} }
return item?.UserData?.PlaybackPositionTicks ?? 0; return item?.UserData?.PlaybackPositionTicks ?? 0;
}, [playbackPositionFromUrl]); }, [playbackPositionFromUrl, item?.UserData?.PlaybackPositionTicks]);
useEffect(() => { useEffect(() => {
const fetchItemData = async () => { const fetchItemData = async () => {
@@ -174,6 +221,7 @@ export default function page() {
} }
}, [itemId, offline, api, user?.Id]); }, [itemId, offline, api, user?.Id]);
// Lock orientation based on user settings
useEffect(() => { useEffect(() => {
if (settings?.defaultVideoOrientation) { if (settings?.defaultVideoOrientation) {
lockOrientation(settings.defaultVideoOrientation); lockOrientation(settings.defaultVideoOrientation);
@@ -182,7 +230,7 @@ export default function page() {
return () => { return () => {
unlockOrientation(); unlockOrientation();
}; };
}, [settings?.defaultVideoOrientation]); }, [settings?.defaultVideoOrientation, lockOrientation, unlockOrientation]);
interface Stream { interface Stream {
mediaSource: MediaSourceInfo; mediaSource: MediaSourceInfo;
@@ -230,21 +278,25 @@ export default function page() {
return; return;
} }
const native = generateDeviceProfile(); // Calculate start ticks directly from item to avoid stale closure
const transcoding = generateDeviceProfile({ transcode: true }); const startTicks = playbackPositionFromUrl
? Number.parseInt(playbackPositionFromUrl, 10)
: (item?.UserData?.PlaybackPositionTicks ?? 0);
const res = await getStreamUrl({ const res = await getStreamUrl({
api, api,
item, item,
startTimeTicks: getInitialPlaybackTicks(), startTimeTicks: startTicks,
userId: user.Id, userId: user.Id,
audioStreamIndex: audioIndex, audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue, maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex, subtitleStreamIndex: subtitleIndex,
deviceProfile: bitrateValue ? transcoding : native, deviceProfile: generateDeviceProfile(),
}); });
if (!res) return; if (!res) return;
const { mediaSource, sessionId, url } = res; const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) { if (!sessionId || !mediaSource || !url) {
Alert.alert( Alert.alert(
t("player.error"), t("player.error"),
@@ -273,36 +325,43 @@ export default function page() {
]); ]);
useEffect(() => { useEffect(() => {
if (!stream || !api) return; if (!stream || !api || offline) return;
const reportPlaybackStart = async () => { const reportPlaybackStart = async () => {
await getPlaystateApi(api).reportPlaybackStart({ const progressInfo = currentPlayStateInfo();
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo, if (progressInfo) {
}); await getPlaystateApi(api).reportPlaybackStart({
playbackStartInfo: progressInfo,
});
}
}; };
reportPlaybackStart(); reportPlaybackStart();
}, [stream, api]); }, [stream, api, offline]);
const togglePlay = async () => { const togglePlay = async () => {
lightHapticFeedback(); lightHapticFeedback();
setIsPlaying(!isPlaying); setIsPlaying(!isPlaying);
if (isPlaying) { if (isPlaying) {
await videoRef.current?.pause(); await videoRef.current?.pause();
playbackManager.reportPlaybackProgress( const progressInfo = currentPlayStateInfo();
currentPlayStateInfo() as PlaybackProgressInfo, if (progressInfo) {
); playbackManager.reportPlaybackProgress(progressInfo);
}
} else { } else {
videoRef.current?.play(); videoRef.current?.play();
await getPlaystateApi(api!).reportPlaybackStart({ const progressInfo = currentPlayStateInfo();
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo, if (!offline && api) {
}); await getPlaystateApi(api).reportPlaybackStart({
playbackStartInfo: progressInfo,
});
}
} }
}; };
const reportPlaybackStopped = useCallback(async () => { const reportPlaybackStopped = useCallback(async () => {
if (!item?.Id || !stream?.sessionId) return; if (!item?.Id || !stream?.sessionId || offline || !api) return;
const currentTimeInTicks = msToTicks(progress.get()); const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({ await getPlaystateApi(api).onPlaybackStopped({
itemId: item.Id, itemId: item.Id,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
positionTicks: currentTimeInTicks, positionTicks: currentTimeInTicks,
@@ -325,7 +384,7 @@ export default function page() {
}); });
reportPlaybackStopped(); reportPlaybackStopped();
setIsPlaybackStopped(true); setIsPlaybackStopped(true);
videoRef.current?.stop(); videoRef.current?.pause();
revalidateProgressCache(); revalidateProgressCache();
}, [videoRef, reportPlaybackStopped, progress]); }, [videoRef, reportPlaybackStopped, progress]);
@@ -336,21 +395,24 @@ export default function page() {
}; };
}, [navigation, stop]); }, [navigation, stop]);
const currentPlayStateInfo = useCallback(() => { const currentPlayStateInfo = useCallback(():
| PlaybackProgressInfo
| undefined => {
if (!stream || !item?.Id) return; if (!stream || !item?.Id) return;
return { return {
itemId: item.Id, ItemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined, AudioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, SubtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId, MediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.get()), PositionTicks: msToTicks(progress.get()),
isPaused: !isPlaying, IsPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", PlayMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId, PlaySessionId: stream.sessionId,
isMuted: isMuted, IsMuted: isMuted,
canSeek: true, CanSeek: true,
repeatMode: RepeatMode.RepeatNone, RepeatMode: RepeatMode.RepeatNone,
playbackOrder: PlaybackOrder.Default, PlaybackOrder: PlaybackOrder.Default,
}; };
}, [ }, [
stream, stream,
@@ -379,11 +441,15 @@ export default function page() {
[], [],
); );
/** Progress handler for MPV - position in seconds */
const onProgress = useCallback( const onProgress = useCallback(
async (data: ProgressUpdatePayload) => { async (data: { nativeEvent: MpvOnProgressEventPayload }) => {
if (isSeeking.get() || isPlaybackStopped) return; if (isSeeking.get() || isPlaybackStopped) return;
const { currentTime } = data.nativeEvent; const { position } = data.nativeEvent;
// MPV reports position in seconds, convert to ms
const currentTime = position * 1000;
if (isBuffering) { if (isBuffering) {
setIsBuffering(false); setIsBuffering(false);
} }
@@ -425,10 +491,85 @@ export default function page() {
); );
/** Gets the initial playback position in seconds. */ /** Gets the initial playback position in seconds. */
const startPosition = useMemo(() => { const _startPosition = useMemo(() => {
return ticksToSeconds(getInitialPlaybackTicks()); return ticksToSeconds(getInitialPlaybackTicks());
}, [getInitialPlaybackTicks]); }, [getInitialPlaybackTicks]);
/** Build video source config for MPV */
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
if (!stream?.url) return undefined;
const mediaSource = stream.mediaSource;
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
// Get external subtitle URLs
// - Online: prepend API base path to server URLs
// - Offline: use local file paths (stored in DeliveryUrl during download)
let externalSubs: string[] | undefined;
if (!offline && api?.basePath) {
externalSubs = mediaSource?.MediaStreams?.filter(
(s) =>
s.Type === "Subtitle" &&
s.DeliveryMethod === "External" &&
s.DeliveryUrl,
).map((s) => `${api.basePath}${s.DeliveryUrl}`);
} else if (offline) {
externalSubs = mediaSource?.MediaStreams?.filter(
(s) =>
s.Type === "Subtitle" &&
s.DeliveryMethod === "External" &&
s.DeliveryUrl,
).map((s) => s.DeliveryUrl!);
}
// Calculate track IDs for initial selection
const initialSubtitleId = getMpvSubtitleId(
mediaSource,
subtitleIndex,
isTranscoding,
);
const initialAudioId = getMpvAudioId(mediaSource, audioIndex);
// Calculate start position directly here to avoid timing issues
const startTicks = playbackPositionFromUrl
? Number.parseInt(playbackPositionFromUrl, 10)
: (item?.UserData?.PlaybackPositionTicks ?? 0);
const startPos = ticksToSeconds(startTicks);
// Build source config - headers only needed for online streaming
const source: MpvVideoSource = {
url: stream.url,
startPosition: startPos,
autoplay: true,
initialSubtitleId,
initialAudioId,
};
// Add external subtitles only for online playback
if (externalSubs && externalSubs.length > 0) {
source.externalSubtitles = externalSubs;
}
// Add auth headers only for online streaming (not for local file:// URLs)
if (!offline && api?.accessToken) {
source.headers = {
Authorization: `MediaBrowser Token="${api.accessToken}"`,
};
}
return source;
}, [
stream?.url,
stream?.mediaSource,
item?.UserData?.PlaybackPositionTicks,
playbackPositionFromUrl,
api?.basePath,
api?.accessToken,
subtitleIndex,
audioIndex,
offline,
]);
const volumeUpCb = useCallback(async () => { const volumeUpCb = useCallback(async () => {
if (Platform.isTV) return; if (Platform.isTV) return;
@@ -508,11 +649,15 @@ export default function page() {
setVolume: setVolumeCb, setVolume: setVolumeCb,
}); });
/** Playback state handler for MPV */
const onPlaybackStateChanged = useCallback( const onPlaybackStateChanged = useCallback(
async (e: PlaybackStatePayload) => { async (e: { nativeEvent: MpvOnPlaybackStateChangePayload }) => {
const { state, isBuffering, isPlaying } = e.nativeEvent; const { isPaused, isPlaying: playing, isLoading } = e.nativeEvent;
if (state === "Playing") {
if (playing) {
setIsPlaying(true); setIsPlaying(true);
setIsBuffering(false);
setHasPlaybackStarted(true);
if (item?.Id) { if (item?.Id) {
playbackManager.reportPlaybackProgress( playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo, currentPlayStateInfo() as PlaybackProgressInfo,
@@ -522,7 +667,7 @@ export default function page() {
return; return;
} }
if (state === "Paused") { if (isPaused) {
setIsPlaying(false); setIsPlaying(false);
if (item?.Id) { if (item?.Id) {
playbackManager.reportPlaybackProgress( playbackManager.reportPlaybackProgress(
@@ -533,86 +678,25 @@ export default function page() {
return; return;
} }
if (isPlaying) { if (isLoading !== undefined) {
setIsPlaying(true); setIsBuffering(isLoading);
setIsBuffering(false);
} else if (isBuffering) {
setIsBuffering(true);
} }
}, },
[playbackManager, item?.Id, progress], [playbackManager, item?.Id, progress],
); );
const allAudio = /** PiP handler for MPV */
stream?.mediaSource.MediaStreams?.filter( const _onPictureInPictureChange = useCallback(
(audio) => audio.Type === "Audio", (e: { nativeEvent: { isActive: boolean } }) => {
) || []; const { isActive } = e.nativeEvent;
setIsPipMode(isActive);
// Move all the external subtitles last, because vlc places them last. // Hide controls when entering PiP
const allSubs = if (isActive) {
stream?.mediaSource.MediaStreams?.filter( _setShowControls(false);
(sub) => sub.Type === "Subtitle", }
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || []; },
[],
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl,
}));
/** The text based subtitle tracks */
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
/** The user chosen subtitle track from the server */
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex,
); );
/** The user chosen audio track from the server */
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
/** Whether the stream we're playing is not transcoding*/
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
/** The initial options to pass to the VLC Player */
const initOptions = [``];
if (
chosenSubtitleTrack &&
(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
? allSubs.indexOf(chosenSubtitleTrack)
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`);
// Add VLC subtitle styling options from settings
const textColor = (settings.vlcTextColor ?? "White") as VLCColor;
const backgroundColor = (settings.vlcBackgroundColor ??
"Black") as VLCColor;
const outlineColor = (settings.vlcOutlineColor ?? "Black") as VLCColor;
const outlineThickness = (settings.vlcOutlineThickness ??
"Normal") as OutlineThickness;
const backgroundOpacity = settings.vlcBackgroundOpacity ?? 128;
const outlineOpacity = settings.vlcOutlineOpacity ?? 255;
const isBold = settings.vlcIsBold ?? false;
// Add subtitle styling options
initOptions.push(`--freetype-color=${VLC_COLORS[textColor]}`);
initOptions.push(`--freetype-background-opacity=${backgroundOpacity}`);
initOptions.push(
`--freetype-background-color=${VLC_COLORS[backgroundColor]}`,
);
initOptions.push(`--freetype-outline-opacity=${outlineOpacity}`);
initOptions.push(`--freetype-outline-color=${VLC_COLORS[outlineColor]}`);
initOptions.push(
`--freetype-outline-thickness=${OUTLINE_THICKNESS[outlineThickness]}`,
);
initOptions.push(`--sub-text-scale=${settings.subtitleSize}`);
initOptions.push("--sub-margin=40");
if (isBold) {
initOptions.push("--freetype-bold");
}
}
if (notTranscoding && chosenAudioTrack) {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
}
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
@@ -626,6 +710,7 @@ export default function page() {
const startPictureInPicture = useCallback(async () => { const startPictureInPicture = useCallback(async () => {
return videoRef.current?.startPictureInPicture?.(); return videoRef.current?.startPictureInPicture?.();
}, []); }, []);
const play = useCallback(() => { const play = useCallback(() => {
videoRef.current?.play?.(); videoRef.current?.play?.();
}, []); }, []);
@@ -635,69 +720,145 @@ export default function page() {
}, []); }, []);
const seek = useCallback((position: number) => { const seek = useCallback((position: number) => {
videoRef.current?.seekTo?.(position); // MPV expects seconds, convert from ms
}, []); videoRef.current?.seekTo?.(position / 1000);
const getAudioTracks = useCallback(async () => {
return videoRef.current?.getAudioTracks?.() || null;
}, []); }, []);
const getSubtitleTracks = useCallback(async () => { // Technical info toggle handler
return videoRef.current?.getSubtitleTracks?.() || null; const handleToggleTechnicalInfo = useCallback(() => {
setShowTechnicalInfo((prev) => !prev);
}, []); }, []);
const setSubtitleTrack = useCallback((index: number) => { // Get technical info from the player
videoRef.current?.setSubtitleTrack?.(index); const getTechnicalInfo = useCallback(async () => {
return (await videoRef.current?.getTechnicalInfo?.()) ?? {};
}, []); }, []);
const setSubtitleURL = useCallback((url: string, _customName?: string) => { // Determine play method based on stream URL and media source
// Note: VlcPlayer type only expects url parameter const playMethod = useMemo<
videoRef.current?.setSubtitleURL?.(url); "DirectPlay" | "DirectStream" | "Transcode" | undefined
}, []); >(() => {
if (!stream?.url) return undefined;
const setAudioTrack = useCallback((index: number) => { // Check if transcoding (m3u8 playlist or TranscodingUrl present)
videoRef.current?.setAudioTrack?.(index); if (stream.url.includes("m3u8") || stream.mediaSource?.TranscodingUrl) {
}, []); return "Transcode";
}
const setVideoAspectRatio = useCallback( // Check if direct play (no container remuxing needed)
async (aspectRatio: string | null) => { // Direct play means the file is being served as-is
return ( if (stream.url.includes("/Videos/") && stream.url.includes("/stream")) {
videoRef.current?.setVideoAspectRatio?.(aspectRatio) || return "DirectStream";
Promise.resolve() }
// Default to direct play if we're not transcoding
return "DirectPlay";
}, [stream?.url, stream?.mediaSource?.TranscodingUrl]);
// Extract transcode reasons from the TranscodingUrl
const transcodeReasons = useMemo<string[]>(() => {
const transcodingUrl = stream?.mediaSource?.TranscodingUrl;
if (!transcodingUrl) return [];
try {
// Parse the TranscodeReasons parameter from the URL
const url = new URL(transcodingUrl, "http://localhost");
const reasons = url.searchParams.get("TranscodeReasons");
if (reasons) {
return reasons.split(",").filter(Boolean);
}
} catch {
// If URL parsing fails, try regex fallback
const match = transcodingUrl.match(/TranscodeReasons=([^&]+)/);
if (match) {
return match[1].split(",").filter(Boolean);
}
}
return [];
}, [stream?.mediaSource?.TranscodingUrl]);
const handleZoomToggle = useCallback(async () => {
const newZoomState = !isZoomedToFill;
await videoRef.current?.setZoomedToFill?.(newZoomState);
setIsZoomedToFill(newZoomState);
// Adjust subtitle position to compensate for video cropping when zoomed
if (newZoomState) {
// Get video dimensions from mediaSource
const videoStream = stream?.mediaSource?.MediaStreams?.find(
(s) => s.Type === "Video",
); );
}, const videoWidth = videoStream?.Width ?? 1920;
[], const videoHeight = videoStream?.Height ?? 1080;
);
const setVideoScaleFactor = useCallback(async (scaleFactor: number) => { const videoAR = videoWidth / videoHeight;
return ( const screenAR = screenWidth / screenHeight;
videoRef.current?.setVideoScaleFactor?.(scaleFactor) || Promise.resolve()
);
}, []);
// Prepare metadata for iOS native media controls if (screenAR > videoAR) {
const nowPlayingMetadata = useMemo(() => { // Screen is wider than video - video height extends beyond screen
if (!item || !api) return undefined; // Calculate how much of the video is cropped at the bottom (as % of video height)
const bottomCropPercent = 50 * (1 - videoAR / screenAR);
// Only adjust by 70% of the crop to keep a comfortable margin from the edge
// (subtitles already have some built-in padding from the bottom)
const adjustmentFactor = 0.7;
const newSubPos = Math.round(
100 - bottomCropPercent * adjustmentFactor,
);
await videoRef.current?.setSubtitlePosition?.(newSubPos);
}
// If videoAR >= screenAR, sides are cropped but bottom is visible, no adjustment needed
} else {
// Restore to default position (bottom of video frame)
await videoRef.current?.setSubtitlePosition?.(100);
}
}, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]);
const artworkUri = getPrimaryImageUrl({ // Apply subtitle settings when video loads
api, useEffect(() => {
item, if (!isVideoLoaded || !videoRef.current) return;
quality: 90,
width: 500,
});
return { const applySubtitleSettings = async () => {
title: item.Name || "", if (settings.mpvSubtitleScale !== undefined) {
artist: await videoRef.current?.setSubtitleScale?.(settings.mpvSubtitleScale);
item.Type === "Episode" }
? item.SeriesName || "" if (settings.mpvSubtitleMarginY !== undefined) {
: item.AlbumArtist || "", await videoRef.current?.setSubtitleMarginY?.(
albumTitle: settings.mpvSubtitleMarginY,
item.Type === "Episode" && item.SeasonName );
? item.SeasonName }
: undefined, if (settings.mpvSubtitleAlignX !== undefined) {
artworkUri: artworkUri || undefined, await videoRef.current?.setSubtitleAlignX?.(settings.mpvSubtitleAlignX);
}
if (settings.mpvSubtitleAlignY !== undefined) {
await videoRef.current?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY);
}
if (settings.mpvSubtitleFontSize !== undefined) {
await videoRef.current?.setSubtitleFontSize?.(
settings.mpvSubtitleFontSize,
);
}
// Apply subtitle size from general settings
if (settings.subtitleSize) {
await videoRef.current?.setSubtitleFontSize?.(settings.subtitleSize);
}
}; };
}, [item, api]);
applySubtitleSettings();
}, [isVideoLoaded, settings]);
// Apply initial playback speed when video loads
useEffect(() => {
if (!isVideoLoaded || !videoRef.current) return;
const applyInitialPlaybackSpeed = async () => {
if (initialPlaybackSpeed !== 1.0) {
setCurrentPlaybackSpeed(initialPlaybackSpeed);
await videoRef.current?.setSpeed?.(initialPlaybackSpeed);
}
};
applyInitialPlaybackSpeed();
}, [isVideoLoaded, initialPlaybackSpeed]);
// Show error UI first, before checking loading/missingdata // Show error UI first, before checking loading/missingdata
if (itemStatus.isError || streamStatus.isError) { if (itemStatus.isError || streamStatus.isError) {
@@ -708,7 +869,7 @@ export default function page() {
); );
} }
// Then show loader while either side is still fetching or data isnt present // Then show loader while either side is still fetching or data isn't present
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) { if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
// …loader UI… // …loader UI…
return ( return (
@@ -726,91 +887,103 @@ export default function page() {
); );
return ( return (
<View <OfflineModeProvider isOffline={offline}>
style={{ <PlayerProvider
flex: 1, playerRef={videoRef}
backgroundColor: "black", item={item}
height: "100%", mediaSource={stream?.mediaSource}
width: "100%", isVideoLoaded={isVideoLoaded}
}} tracksReady={tracksReady}
> downloadedItem={downloadedItem}
<View
style={{
display: "flex",
width: "100%",
height: "100%",
position: "relative",
flexDirection: "column",
justifyContent: "center",
}}
> >
<VlcPlayerView <VideoProvider>
ref={videoRef} <View
source={{ style={{
uri: stream?.url || "", flex: 1,
autoplay: true, backgroundColor: "black",
isNetwork: !offline, height: "100%",
startPosition, width: "100%",
externalSubtitles, }}
initOptions, >
}} <View
style={{ width: "100%", height: "100%" }} style={{
nowPlayingMetadata={nowPlayingMetadata} display: "flex",
onVideoProgress={onProgress} width: "100%",
progressUpdateInterval={1000} height: "100%",
onVideoStateChange={onPlaybackStateChanged} position: "relative",
onVideoLoadEnd={() => { flexDirection: "column",
setIsVideoLoaded(true); justifyContent: "center",
}} }}
onVideoError={(e) => { >
console.error("Video Error:", e.nativeEvent); <MpvPlayerView
Alert.alert( ref={videoRef}
t("player.error"), source={videoSource}
t("player.an_error_occured_while_playing_the_video"), style={{ width: "100%", height: "100%" }}
); onProgress={onProgress}
writeToLog("ERROR", "Video Error", e.nativeEvent); onPlaybackStateChange={onPlaybackStateChanged}
}} onLoad={() => setIsVideoLoaded(true)}
onPipStarted={(e) => { onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
setIsPipMode(e.nativeEvent.pipStarted); console.error("Video Error:", e.nativeEvent);
}} Alert.alert(
/> t("player.error"),
</View> t("player.an_error_occured_while_playing_the_video"),
{isMounted === true && item && !isPipMode && ( );
<Controls writeToLog("ERROR", "Video Error", e.nativeEvent);
mediaSource={stream?.mediaSource} }}
item={item} onTracksReady={() => {
videoRef={videoRef} setTracksReady(true);
togglePlay={togglePlay} }}
isPlaying={isPlaying} />
isSeeking={isSeeking} {!hasPlaybackStarted && (
progress={progress} <View
cacheProgress={cacheProgress} style={{
isBuffering={isBuffering} position: "absolute",
showControls={showControls} top: 0,
setShowControls={setShowControls} left: 0,
isVideoLoaded={isVideoLoaded} right: 0,
startPictureInPicture={startPictureInPicture} bottom: 0,
play={play} backgroundColor: "black",
pause={pause} justifyContent: "center",
seek={seek} alignItems: "center",
enableTrickplay={true} }}
getAudioTracks={getAudioTracks} >
getSubtitleTracks={getSubtitleTracks} <Loader />
offline={offline} </View>
setSubtitleTrack={setSubtitleTrack} )}
setSubtitleURL={setSubtitleURL} </View>
setAudioTrack={setAudioTrack} {isMounted === true && item && !isPipMode && (
setVideoAspectRatio={setVideoAspectRatio} <Controls
setVideoScaleFactor={setVideoScaleFactor} mediaSource={stream?.mediaSource}
aspectRatio={aspectRatio} item={item}
scaleFactor={scaleFactor} togglePlay={togglePlay}
setAspectRatio={setAspectRatio} isPlaying={isPlaying}
setScaleFactor={setScaleFactor} isSeeking={isSeeking}
isVlc progress={progress}
api={api} cacheProgress={cacheProgress}
downloadedFiles={downloadedFiles} isBuffering={isBuffering}
/> showControls={showControls}
)} setShowControls={setShowControls}
</View> startPictureInPicture={startPictureInPicture}
play={play}
pause={pause}
seek={seek}
enableTrickplay={true}
isZoomedToFill={isZoomedToFill}
onZoomToggle={handleZoomToggle}
api={api}
downloadedFiles={downloadedFiles}
playbackSpeed={currentPlaybackSpeed}
setPlaybackSpeed={handleSetPlaybackSpeed}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={handleToggleTechnicalInfo}
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
/>
)}
</View>
</VideoProvider>
</PlayerProvider>
</OfflineModeProvider>
); );
} }

View File

@@ -1,8 +1,11 @@
import "@/augmentations"; import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import NetInfo from "@react-native-community/netinfo";
import { DarkTheme, ThemeProvider } from "@react-navigation/native"; import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { onlineManager, QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import * as BackgroundTask from "expo-background-task"; import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device"; import * as Device from "expo-device";
import { Platform } from "react-native"; import { Platform } from "react-native";
@@ -10,13 +13,16 @@ import { GlobalModal } from "@/components/GlobalModal";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider"; import { DownloadProvider } from "@/providers/DownloadProvider";
import { GlobalModalProvider } from "@/providers/GlobalModalProvider"; import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
import { IntroSheetProvider } from "@/providers/IntroSheetProvider";
import { import {
apiAtom, apiAtom,
getOrSetDeviceId, getOrSetDeviceId,
JellyfinProvider, JellyfinProvider,
} from "@/providers/JellyfinProvider"; } from "@/providers/JellyfinProvider";
import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider"; import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { import {
@@ -42,7 +48,7 @@ import type {
NotificationResponse, NotificationResponse,
} from "expo-notifications/build/Notifications.types"; } from "expo-notifications/build/Notifications.types";
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types"; import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
import { router, Stack, useSegments } from "expo-router"; import { Stack, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen"; import * as SplashScreen from "expo-splash-screen";
import * as TaskManager from "expo-task-manager"; import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider, useAtom } from "jotai"; import { Provider as JotaiProvider, useAtom } from "jotai";
@@ -51,6 +57,7 @@ import { I18nextProvider } from "react-i18next";
import { Appearance } from "react-native"; import { Appearance } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import useRouter from "@/hooks/useAppRouter";
import { userAtom } from "@/providers/JellyfinProvider"; import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store"; import { store } from "@/utils/store";
import "react-native-reanimated"; import "react-native-reanimated";
@@ -75,14 +82,9 @@ SplashScreen.setOptions({
fade: true, fade: true,
}); });
function redirect(notification: typeof Notifications.Notification) {
const url = notification.request.content.data?.url;
if (url) {
router.push(url);
}
}
function useNotificationObserver() { function useNotificationObserver() {
const router = useRouter();
useEffect(() => { useEffect(() => {
if (Platform.isTV) return; if (Platform.isTV) return;
@@ -93,14 +95,17 @@ function useNotificationObserver() {
if (!isMounted || !response?.notification) { if (!isMounted || !response?.notification) {
return; return;
} }
redirect(response?.notification); const url = response?.notification.request.content.data?.url;
if (url) {
router.push(url);
}
}, },
); );
return () => { return () => {
isMounted = false; isMounted = false;
}; };
}, []); }, [router]);
} }
if (!Platform.isTV) { if (!Platform.isTV) {
@@ -184,11 +189,39 @@ export default function RootLayout() {
); );
} }
// Set up online manager for network-aware query behavior
onlineManager.setEventListener((setOnline) => {
return NetInfo.addEventListener((state) => {
setOnline(!!state.isConnected);
});
});
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 30000, staleTime: 0, // Always stale - triggers background refetch on mount
gcTime: 1000 * 60 * 60 * 24, // 24 hours - keep in cache for offline
networkMode: "offlineFirst", // Return cache first, refetch if online
refetchOnMount: true, // Refetch when component mounts
refetchOnReconnect: true, // Refetch when network reconnects
refetchOnWindowFocus: false, // Not needed for mobile
retry: (failureCount) => {
if (!onlineManager.isOnline()) return false;
return failureCount < 3;
},
}, },
mutations: {
networkMode: "online", // Only run mutations when online
},
},
});
// Create MMKV-based persister for offline support
const mmkvPersister = createSyncStoragePersister({
storage: {
getItem: (key) => storage.getString(key) ?? null,
setItem: (key, value) => storage.set(key, value),
removeItem: (key) => storage.remove(key),
}, },
}); });
@@ -197,6 +230,7 @@ function Layout() {
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const _segments = useSegments(); const _segments = useSegments();
const router = useRouter();
useEffect(() => { useEffect(() => {
i18n.changeLanguage( i18n.changeLanguage(
@@ -289,9 +323,6 @@ function Layout() {
responseListener.current = responseListener.current =
Notifications?.addNotificationResponseReceivedListener( Notifications?.addNotificationResponseReceivedListener(
(response: NotificationResponse) => { (response: NotificationResponse) => {
// redirect if internal notification
redirect(response?.notification);
// Currently the notifications supported by the plugin will send data for deep links. // Currently the notifications supported by the plugin will send data for deep links.
const { title, data } = response.notification.request.content; const { title, data } = response.notification.request.content;
writeInfoLog(`Notification ${title} opened`, data); writeInfoLog(`Notification ${title} opened`, data);
@@ -337,68 +368,94 @@ function Layout() {
}, [user]); }, [user]);
return ( return (
<QueryClientProvider client={queryClient}> <PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: mmkvPersister,
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
// Only persist successful queries
return query.state.status === "success";
},
},
}}
>
<JellyfinProvider> <JellyfinProvider>
<NetworkStatusProvider> <ServerUrlProvider>
<PlaySettingsProvider> <NetworkStatusProvider>
<LogProvider> <PlaySettingsProvider>
<WebSocketProvider> <LogProvider>
<DownloadProvider> <WebSocketProvider>
<GlobalModalProvider> <DownloadProvider>
<BottomSheetModalProvider> <MusicPlayerProvider>
<ThemeProvider value={DarkTheme}> <GlobalModalProvider>
<SystemBars style='light' hidden={false} /> <BottomSheetModalProvider>
<Stack initialRouteName='(auth)/(tabs)'> <IntroSheetProvider>
<Stack.Screen <ThemeProvider value={DarkTheme}>
name='(auth)/(tabs)' <SystemBars style='light' hidden={false} />
options={{ <Stack initialRouteName='(auth)/(tabs)'>
headerShown: false, <Stack.Screen
title: "", name='(auth)/(tabs)'
header: () => null, options={{
}} headerShown: false,
/> title: "",
<Stack.Screen header: () => null,
name='(auth)/player' }}
options={{ />
headerShown: false, <Stack.Screen
title: "", name='(auth)/player'
header: () => null, options={{
}} headerShown: false,
/> title: "",
<Stack.Screen header: () => null,
name='login' }}
options={{ />
headerShown: true, <Stack.Screen
title: "", name='(auth)/now-playing'
headerTransparent: Platform.OS === "ios", options={{
}} headerShown: false,
/> presentation: "modal",
<Stack.Screen name='+not-found' /> gestureEnabled: true,
</Stack> }}
<Toaster />
duration={4000} <Stack.Screen
toastOptions={{ name='login'
style: { options={{
backgroundColor: "#262626", headerShown: true,
borderColor: "#363639", title: "",
borderWidth: 1, headerTransparent: Platform.OS === "ios",
}, }}
titleStyle: { />
color: "white", <Stack.Screen name='+not-found' />
}, </Stack>
}} <Toaster
closeButton duration={4000}
/> toastOptions={{
<GlobalModal /> style: {
</ThemeProvider> backgroundColor: "#262626",
</BottomSheetModalProvider> borderColor: "#363639",
</GlobalModalProvider> borderWidth: 1,
</DownloadProvider> },
</WebSocketProvider> titleStyle: {
</LogProvider> color: "white",
</PlaySettingsProvider> },
</NetworkStatusProvider> }}
closeButton
/>
<GlobalModal />
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</NetworkStatusProvider>
</ServerUrlProvider>
</JellyfinProvider> </JellyfinProvider>
</QueryClientProvider> </PersistQueryClientProvider>
); );
} }

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -1,4 +1,3 @@
export * from "./api"; export * from "./api";
export * from "./mmkv"; export * from "./mmkv";
export * from "./number"; export * from "./number";
export * from "./string";

View File

@@ -3,7 +3,6 @@ declare global {
bytesToReadable(decimals?: number): string; bytesToReadable(decimals?: number): string;
secondsToMilliseconds(): number; secondsToMilliseconds(): number;
minutesToMilliseconds(): number; minutesToMilliseconds(): number;
hoursToMilliseconds(): number;
} }
} }
@@ -28,8 +27,4 @@ Number.prototype.minutesToMilliseconds = function () {
return this.valueOf() * (60).secondsToMilliseconds(); return this.valueOf() * (60).secondsToMilliseconds();
}; };
Number.prototype.hoursToMilliseconds = function () {
return this.valueOf() * (60).minutesToMilliseconds();
};
export {}; export {};

View File

@@ -1,14 +0,0 @@
declare global {
interface String {
toTitle(): string;
}
}
String.prototype.toTitle = function () {
return this.replaceAll("_", " ").replace(
/\w\S*/g,
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
);
};
export {};

View File

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

1147
bun.lock

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -16,6 +16,7 @@ export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
<RoundButton <RoundButton
size='large' size='large'
icon={isFavorite ? "heart" : "heart-outline"} icon={isFavorite ? "heart" : "heart-outline"}
color={isFavorite ? "purple" : "white"}
onPress={toggleFavorite} onPress={toggleFavorite}
/> />
</View> </View>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import {
type BottomSheetBackdropProps, type BottomSheetBackdropProps,
BottomSheetModal, BottomSheetModal,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { useCallback } from "react"; import { useCallback, useEffect } from "react";
import { useGlobalModal } from "@/providers/GlobalModalProvider"; import { useGlobalModal } from "@/providers/GlobalModalProvider";
/** /**
@@ -16,7 +16,13 @@ import { useGlobalModal } from "@/providers/GlobalModalProvider";
* after BottomSheetModalProvider. * after BottomSheetModalProvider.
*/ */
export const GlobalModal = () => { export const GlobalModal = () => {
const { hideModal, modalState, modalRef } = useGlobalModal(); const { hideModal, modalState, modalRef, isVisible } = useGlobalModal();
useEffect(() => {
if (isVisible && modalState.content) {
modalRef.current?.present();
}
}, [isVisible, modalState.content, modalRef]);
const handleSheetChanges = useCallback( const handleSheetChanges = useCallback(
(index: number) => { (index: number) => {

203
components/IntroSheet.tsx Normal file
View File

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

View File

@@ -11,6 +11,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { type Bitrate } from "@/components/BitrateSelector"; import { type Bitrate } from "@/components/BitrateSelector";
import { ItemImage } from "@/components/common/ItemImage"; import { ItemImage } from "@/components/common/ItemImage";
import { DownloadSingleItem } from "@/components/DownloadItem"; import { DownloadSingleItem } from "@/components/DownloadItem";
import { ItemPeopleSections } from "@/components/item/ItemPeopleSections";
import { MediaSourceButton } from "@/components/MediaSourceButton"; import { MediaSourceButton } from "@/components/MediaSourceButton";
import { OverviewText } from "@/components/OverviewText"; import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
@@ -18,7 +19,6 @@ import { ParallaxScrollView } from "@/components/ParallaxPage";
import { PlayButton } from "@/components/PlayButton"; import { PlayButton } from "@/components/PlayButton";
import { PlayedStatus } from "@/components/PlayedStatus"; import { PlayedStatus } from "@/components/PlayedStatus";
import { SimilarItems } from "@/components/SimilarItems"; import { SimilarItems } from "@/components/SimilarItems";
import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries"; import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel"; import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
@@ -26,12 +26,13 @@ import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { AddToFavorites } from "./AddToFavorites"; import { AddToFavorites } from "./AddToFavorites";
import { AddToWatchlist } from "./AddToWatchlist";
import { ItemHeader } from "./ItemHeader"; import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails"; import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
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;
@@ -45,13 +46,13 @@ export type SelectedOptions = {
interface ItemContentProps { interface ItemContentProps {
item: BaseItemDto; item: BaseItemDto;
isOffline: boolean;
itemWithSources?: BaseItemDto | null; itemWithSources?: BaseItemDto | null;
} }
export const ItemContent: React.FC<ItemContentProps> = React.memo( export const ItemContent: React.FC<ItemContentProps> = React.memo(
({ item, isOffline, itemWithSources }) => { ({ item, itemWithSources }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const isOffline = useOfflineMode();
const { settings } = useSettings(); const { settings } = useSettings();
const { orientation } = useOrientation(); const { orientation } = useOrientation();
const navigation = useNavigation(); const navigation = useNavigation();
@@ -67,18 +68,23 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
SelectedOptions | undefined SelectedOptions | undefined
>(undefined); >(undefined);
// Use itemWithSources for play settings since it has MediaSources data
const { const {
defaultAudioIndex, defaultAudioIndex,
defaultBitrate, defaultBitrate,
defaultMediaSource, defaultMediaSource,
defaultSubtitleIndex, defaultSubtitleIndex,
} = useDefaultPlaySettings(item!, settings); } = useDefaultPlaySettings(itemWithSources ?? item, settings);
const logoUrl = useMemo( const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null), () => (item ? getLogoImageUrlById({ api, item }) : null),
[api, item], [api, item],
); );
const onLogoLoad = React.useCallback(() => {
setLoadingLogo(false);
}, []);
const loading = useMemo(() => { const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo); return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]); }, [loadingLogo, logoUrl]);
@@ -87,7 +93,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
useEffect(() => { useEffect(() => {
setSelectedOptions(() => ({ setSelectedOptions(() => ({
bitrate: defaultBitrate, bitrate: defaultBitrate,
mediaSource: defaultMediaSource, mediaSource: defaultMediaSource ?? undefined,
subtitleIndex: defaultSubtitleIndex ?? -1, subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex, audioIndex: defaultAudioIndex,
})); }));
@@ -111,12 +117,17 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
{!Platform.isTV && ( {!Platform.isTV && (
<DownloadSingleItem item={itemWithSources} size='large' /> <DownloadSingleItem item={itemWithSources} size='large' />
)} )}
{user?.Policy?.IsAdministrator && ( {user?.Policy?.IsAdministrator &&
<PlayInRemoteSessionButton item={item} size='large' /> !settings.hideRemoteSessionButton && (
)} <PlayInRemoteSessionButton item={item} size='large' />
)}
<PlayedStatus items={[item]} size='large' /> <PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} /> <AddToFavorites item={item} />
{settings.streamyStatsServerUrl &&
!settings.hideWatchlistsTab && (
<AddToWatchlist item={item} />
)}
</View> </View>
)} )}
</View> </View>
@@ -128,19 +139,32 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
{!Platform.isTV && ( {!Platform.isTV && (
<DownloadSingleItem item={itemWithSources} size='large' /> <DownloadSingleItem item={itemWithSources} size='large' />
)} )}
{user?.Policy?.IsAdministrator && ( {user?.Policy?.IsAdministrator &&
<PlayInRemoteSessionButton item={item} size='large' /> !settings.hideRemoteSessionButton && (
)} <PlayInRemoteSessionButton item={item} size='large' />
)}
<PlayedStatus items={[item]} size='large' /> <PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} /> <AddToFavorites item={item} />
{settings.streamyStatsServerUrl &&
!settings.hideWatchlistsTab && (
<AddToWatchlist item={item} />
)}
</View> </View>
)} )}
</View> </View>
)), )),
}); });
} }
}, [item, navigation, user, itemWithSources]); }, [
item,
navigation,
user,
itemWithSources,
settings.hideRemoteSessionButton,
settings.streamyStatsServerUrl,
settings.hideWatchlistsTab,
]);
useEffect(() => { useEffect(() => {
if (item) { if (item) {
@@ -162,7 +186,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
}} }}
> >
<ParallaxScrollView <ParallaxScrollView
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`} className='flex-1'
headerHeight={headerHeight} headerHeight={headerHeight}
headerImage={ headerImage={
<View style={[{ flex: 1 }]}> <View style={[{ flex: 1 }]}>
@@ -189,8 +213,8 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
width: "100%", width: "100%",
}} }}
contentFit='contain' contentFit='contain'
onLoad={() => setLoadingLogo(false)} onLoad={onLogoLoad}
onError={() => setLoadingLogo(false)} onError={onLogoLoad}
/> />
) : ( ) : (
<View /> <View />
@@ -205,7 +229,6 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
<PlayButton <PlayButton
selectedOptions={selectedOptions} selectedOptions={selectedOptions}
item={item} item={item}
isOffline={isOffline}
colors={itemColors} colors={itemColors}
/> />
<View className='w-1' /> <View className='w-1' />
@@ -220,11 +243,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
</View> </View>
</View> </View>
{item.Type === "Episode" && ( {item.Type === "Episode" && (
<SeasonEpisodesCarousel <SeasonEpisodesCarousel item={item} loading={loading} />
item={item}
loading={loading}
isOffline={isOffline}
/>
)} )}
{!isOffline && {!isOffline &&
@@ -238,25 +257,10 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
{item.Type !== "Program" && ( {item.Type !== "Program" && (
<> <>
{item.Type === "Episode" && !isOffline && ( {item.Type === "Episode" && !isOffline && (
<CurrentSeries item={item} className='mb-4' /> <CurrentSeries item={item} className='mb-2' />
)} )}
{!isOffline && ( <ItemPeopleSections item={item} />
<CastAndCrew item={item} className='mb-4' loading={loading} />
)}
{item.People && item.People.length > 0 && !isOffline && (
<View className='mb-4'>
{item.People.slice(0, 3).map((person, idx) => (
<MoreMoviesWithActor
currentItem={item}
key={idx}
actorId={person.Id!}
className='mb-4'
/>
))}
</View>
)}
{!isOffline && <SimilarItems itemId={item.Id} />} {!isOffline && <SimilarItems itemId={item.Id} />}
</> </>

View File

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

View File

@@ -3,6 +3,7 @@ import { getItemsApi } 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 type React from "react"; import type React from "react";
import { useCallback } 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/HorizontalScroll"; import { HorizontalScroll } from "@/components/common/HorizontalScroll";
@@ -11,15 +12,19 @@ import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
const POSTER_CAROUSEL_HEIGHT = 220;
interface Props extends ViewProps { interface Props extends ViewProps {
actorId: string; actorId: string;
actorName?: string | null;
currentItem: BaseItemDto; currentItem: BaseItemDto;
} }
export const MoreMoviesWithActor: React.FC<Props> = ({ export const MoreMoviesWithActor: React.FC<Props> = ({
actorId, actorId,
actorName,
currentItem, currentItem,
...props ...props
}) => { }) => {
@@ -27,19 +32,6 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const { data: actor } = useQuery({
queryKey: ["actor", actorId],
queryFn: async () => {
if (!api || !user?.Id) return null;
return await getUserItemData({
api,
userId: user.Id,
itemId: actorId,
});
},
enabled: !!api && !!user?.Id && !!actorId,
});
const { data: items, isLoading } = useQuery({ const { data: items, isLoading } = useQuery({
queryKey: ["actor", "movies", actorId, currentItem.Id], queryKey: ["actor", "movies", actorId, currentItem.Id],
queryFn: async () => { queryFn: async () => {
@@ -72,29 +64,34 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
enabled: !!api && !!user?.Id && !!actorId, enabled: !!api && !!user?.Id && !!actorId,
}); });
const renderItem = useCallback(
(item: BaseItemDto, idx: number) => (
<TouchableItemRouter
key={item.Id ?? idx}
item={item}
className='flex flex-col w-28'
>
<View>
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
),
[],
);
if (items?.length === 0) return null; if (items?.length === 0) return null;
return ( return (
<View {...props}> <View {...props}>
<Text className='text-lg font-bold mb-2 px-4'> <Text className='text-lg font-bold mb-2 px-4'>
{t("item_card.more_with", { name: actor?.Name })} {t("item_card.more_with", { name: actorName ?? "" })}
</Text> </Text>
<HorizontalScroll <HorizontalScroll
data={items} data={items}
loading={isLoading} loading={isLoading}
height={247} height={POSTER_CAROUSEL_HEIGHT}
renderItem={(item: BaseItemDto, idx: number) => ( renderItem={renderItem}
<TouchableItemRouter
key={idx}
item={item}
className='flex flex-col w-28'
>
<View>
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
)}
/> />
</View> </View>
); );

View File

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

View File

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

View File

@@ -25,7 +25,14 @@ export type ToggleOption = {
disabled?: boolean; disabled?: boolean;
}; };
export type Option = RadioOption | ToggleOption; export type ActionOption = {
type: "action";
label: string;
onPress: () => void;
disabled?: boolean;
};
export type Option = RadioOption | ToggleOption | ActionOption;
// Option group structure // Option group structure
export type OptionGroup = { export type OptionGroup = {
@@ -54,9 +61,7 @@ const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`} className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
> >
<View <View
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${ className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${value ? "translate-x-6" : "translate-x-1"}`}
value ? "translate-x-6" : "translate-x-1"
}`}
/> />
</View> </View>
); );
@@ -66,21 +71,22 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
isLast, isLast,
}) => { }) => {
const isToggle = option.type === "toggle"; const isToggle = option.type === "toggle";
const handlePress = isToggle ? option.onToggle : option.onPress; const isAction = option.type === "action";
const handlePress = isToggle
? option.onToggle
: (option as RadioOption | ActionOption).onPress;
return ( return (
<> <>
<TouchableOpacity <TouchableOpacity
onPress={handlePress} onPress={handlePress}
disabled={option.disabled} disabled={option.disabled}
className={`px-4 py-3 flex flex-row items-center justify-between ${ className={`px-4 py-3 flex flex-row items-center justify-between ${option.disabled ? "opacity-50" : ""}`}
option.disabled ? "opacity-50" : ""
}`}
> >
<Text className='flex-1 text-white'>{option.label}</Text> <Text className='flex-1 text-white'>{option.label}</Text>
{isToggle ? ( {isToggle ? (
<ToggleSwitch value={option.value} /> <ToggleSwitch value={option.value} />
) : option.selected ? ( ) : isAction ? null : (option as RadioOption).selected ? (
<Ionicons name='checkmark-circle' size={24} color='#9333ea' /> <Ionicons name='checkmark-circle' size={24} color='#9333ea' />
) : ( ) : (
<Ionicons name='ellipse-outline' size={24} color='#6b7280' /> <Ionicons name='ellipse-outline' size={24} color='#6b7280' />
@@ -154,6 +160,15 @@ const BottomSheetContent: React.FC<{
}, },
}; };
} }
if (option.type === "action") {
return {
...option,
onPress: () => {
option.onPress();
onClose?.();
},
};
}
return option; return option;
}), }),
})); }));
@@ -219,11 +234,7 @@ const PlatformDropdownComponent = ({
return ( return (
<Host style={expoUIConfig?.hostStyle}> <Host style={expoUIConfig?.hostStyle}>
<ContextMenu> <ContextMenu>
<ContextMenu.Trigger> <ContextMenu.Trigger>{trigger}</ContextMenu.Trigger>
<View className=''>
{trigger || <Button variant='bordered'>Show Menu</Button>}
</View>
</ContextMenu.Trigger>
<ContextMenu.Items> <ContextMenu.Items>
{groups.flatMap((group, groupIndex) => { {groups.flatMap((group, groupIndex) => {
// Check if this group has radio options // Check if this group has radio options
@@ -233,6 +244,9 @@ const PlatformDropdownComponent = ({
const toggleOptions = group.options.filter( const toggleOptions = group.options.filter(
(opt) => opt.type === "toggle", (opt) => opt.type === "toggle",
) as ToggleOption[]; ) as ToggleOption[];
const actionOptions = group.options.filter(
(opt) => opt.type === "action",
) as ActionOption[];
const items = []; const items = [];
@@ -299,6 +313,21 @@ const PlatformDropdownComponent = ({
); );
}); });
// Add Buttons for action options (no icon)
actionOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`action-${groupIndex}-${optionIndex}`}
onPress={() => {
option.onPress();
}}
disabled={option.disabled}
>
{option.label}
</Button>,
);
});
return items; return items;
})} })}
</ContextMenu.Items> </ContextMenu.Items>

View File

@@ -1,14 +1,14 @@
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 } from "@expo/vector-icons";
import { BottomSheetView } from "@gorhom/bottom-sheet"; import { BottomSheetView } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, Platform, TouchableOpacity, View } from "react-native"; import { Alert, Platform, TouchableOpacity, View } from "react-native";
import CastContext, { import CastContext, {
CastButton, CastButton,
MediaStreamType,
PlayServicesState, PlayServicesState,
useMediaStatus, useMediaStatus,
useRemoteMediaClient, useRemoteMediaClient,
@@ -23,11 +23,13 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn"; import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { getDownloadedItemById } from "@/providers/Downloads/database"; import { getDownloadedItemById } from "@/providers/Downloads/database";
import { useGlobalModal } from "@/providers/GlobalModalProvider"; import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
@@ -43,7 +45,6 @@ import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof TouchableOpacity> { interface Props extends React.ComponentProps<typeof TouchableOpacity> {
item: BaseItemDto; item: BaseItemDto;
selectedOptions: SelectedOptions; selectedOptions: SelectedOptions;
isOffline?: boolean;
colors?: ThemeColors; colors?: ThemeColors;
} }
@@ -53,9 +54,9 @@ const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({ export const PlayButton: React.FC<Props> = ({
item, item,
selectedOptions, selectedOptions,
isOffline,
colors, colors,
}: Props) => { }: Props) => {
const isOffline = useOfflineMode();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient(); const client = useRemoteMediaClient();
const mediaStatus = useMediaStatus(); const mediaStatus = useMediaStatus();
@@ -185,11 +186,23 @@ export const PlayButton: React.FC<Props> = ({
return; return;
} }
// Calculate start time in seconds from playback position
const startTimeSeconds =
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
// Calculate stream duration in seconds from runtime
const streamDurationSeconds = item.RunTimeTicks
? item.RunTimeTicks / 10000000
: undefined;
client client
.loadMedia({ .loadMedia({
mediaInfo: { mediaInfo: {
contentId: item.Id,
contentUrl: data?.url, contentUrl: data?.url,
contentType: "video/mp4", contentType: "video/mp4",
streamType: MediaStreamType.BUFFERED,
streamDuration: streamDurationSeconds,
metadata: metadata:
item.Type === "Episode" item.Type === "Episode"
? { ? {
@@ -241,7 +254,7 @@ export const PlayButton: React.FC<Props> = ({
], ],
}, },
}, },
startTime: 0, startTime: startTimeSeconds,
}) })
.then(() => { .then(() => {
// state is already set when reopening current media, so skip it here. // state is already set when reopening current media, so skip it here.
@@ -280,7 +293,6 @@ export const PlayButton: React.FC<Props> = ({
]); ]);
const onPress = useCallback(async () => { const onPress = useCallback(async () => {
console.log("onPress");
if (!item) return; if (!item) return;
lightHapticFeedback(); lightHapticFeedback();
@@ -288,6 +300,19 @@ export const PlayButton: React.FC<Props> = ({
// Check if item is downloaded // Check if item is downloaded
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined; const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
// If already in offline mode, play downloaded file directly
if (isOffline && downloadedItem) {
const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
return;
}
// If online but file is downloaded, ask user which version to play
if (downloadedItem) { if (downloadedItem) {
if (Platform.OS === "android") { if (Platform.OS === "android") {
// Show bottom sheet for Android // Show bottom sheet for Android
@@ -474,52 +499,6 @@ export const PlayButton: React.FC<Props> = ({
), ),
})); }));
// if (Platform.OS === "ios")
// return (
// <Host
// style={{
// height: 50,
// flex: 1,
// flexShrink: 0,
// }}
// >
// <Button
// variant='glassProminent'
// onPress={onPress}
// color={effectiveColors.primary}
// modifiers={[fixedSize()]}
// >
// <View className='flex flex-row items-center space-x-2 h-full w-full justify-center -mb-3.5 '>
// <Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
// {runtimeTicksToMinutes(
// (item?.RunTimeTicks || 0) -
// (item?.UserData?.PlaybackPositionTicks || 0),
// )}
// {(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
// </Animated.Text>
// <Animated.Text style={animatedTextStyle}>
// <Ionicons name='play-circle' size={24} />
// </Animated.Text>
// {client && (
// <Animated.Text style={animatedTextStyle}>
// <Feather name='cast' size={22} />
// <CastButton tintColor='transparent' />
// </Animated.Text>
// )}
// {!client && settings?.openInVLC && (
// <Animated.Text style={animatedTextStyle}>
// <MaterialCommunityIcons
// name='vlc'
// size={18}
// color={animatedTextStyle.color}
// />
// </Animated.Text>
// )}
// </View>
// </Button>
// </Host>
// );
return ( return (
<TouchableOpacity <TouchableOpacity
disabled={!item} disabled={!item}
@@ -569,15 +548,6 @@ export const PlayButton: React.FC<Props> = ({
<CastButton tintColor='transparent' /> <CastButton tintColor='transparent' />
</Animated.Text> </Animated.Text>
)} )}
{!client && settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name='vlc'
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View> </View>
</View> </View>
</TouchableOpacity> </TouchableOpacity>

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
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 { useCallback } from "react";
import { View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
interface Props extends ViewProps { interface Props extends ViewProps {
items: BaseItemDto[]; items: BaseItemDto[];
isOffline?: boolean;
size?: "default" | "large"; size?: "default" | "large";
} }
@@ -14,14 +14,16 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
const allPlayed = items.every((item) => item.UserData?.Played); const allPlayed = items.every((item) => item.UserData?.Played);
const toggle = useMarkAsPlayed(items); const toggle = useMarkAsPlayed(items);
const handlePress = useCallback(() => {
void toggle(!allPlayed);
}, [allPlayed, toggle]);
return ( return (
<View {...props}> <View {...props}>
<RoundButton <RoundButton
color={allPlayed ? "purple" : "white"} color={allPlayed ? "purple" : "white"}
icon={allPlayed ? "checkmark" : "checkmark"} icon={allPlayed ? "checkmark" : "checkmark"}
onPress={async () => { onPress={handlePress}
await toggle(!allPlayed);
}}
size={props.size} size={props.size}
/> />
</View> </View>

View File

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

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