Compare commits

...

338 Commits

Author SHA1 Message Date
Uruk
2c0ed076d5 fix(security): prevent log injection in WebSocket message logging
Sanitize WebSocket messages before logging to prevent log injection attacks.
User-controlled data from WebSocket messages could contain newline characters
that allow forging fake log entries.

Changes:
- Convert message object to JSON string and remove newlines/carriage returns
- Use format specifier (%s) for safe string interpolation
- Applied fix to providers/WebSocketProvider.tsx and hooks/useWebsockets.ts

Resolves CodeQL security alert js/log-injection

Co-authored-by: GitHub Copilot Autofix <noreply@github.com>
2025-11-07 22:35:53 +01:00
Gauvain
118c24ee05 Potential fix for code scanning alert no. 219: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-10-26 15:32:43 +01:00
renovate[bot]
61d60c2e74 chore(deps): Update dependency @biomejs/biome to v2.2.7 (#1142)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (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 / 🚑 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-10-26 14:39:42 +01:00
Uruk
6c722e1fd9 fix: remove trailing space from translation key
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
Removes extraneous whitespace at the end of the "Enable Marlin Search" translation string to ensure consistent formatting across the localization file.
2025-10-25 16:06:14 +02:00
Chris
e117e98c8e docs: readme.md
Added translations, updated prerequisites, fixed Discord links, and minor tweaks
2025-10-25 15:40:59 +02:00
renovate[bot]
f5c791cce4 chore(deps): Update dependency lint-staged to v16.2.6 (#1149)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 13:05:34 +02:00
renovate[bot]
eaebb79f4c chore(deps): Update dependency lint-staged to v16.2.5 (#1144)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 12:56:28 +02:00
renovate[bot]
7e82d9c7b6 chore(deps): Update actions/upload-artifact action to v5 (#1147)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 12:56:16 +02:00
renovate[bot]
c62c8ca077 chore(deps): Update dependency @babel/core to v7.28.5 (#1148)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 12:56:07 +02:00
renovate[bot]
25cfcf0735 chore(deps): Update crowdin/github-action action to v2.12.0 (#1145)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 12:55:38 +02:00
renovate[bot]
f19b33eff6 chore(deps): Update github/codeql-action action to v4.31.0 (#1146)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 12:54:08 +02:00
renovate[bot]
1c4f54584f chore(deps): Update dependency expo-doctor to v1.17.11 (#1143)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 12:41:52 +02:00
Chris
87d02078c8 docs: Add AI Assistance Disclosure Clause
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
Update introduces a new section outlining requirements for contributors who use AI tools while working on the project.
2025-10-24 10:01:36 +02:00
Chris
1e9fba6f85 docs: updating CONTRIBUTING.md
Added AI assistance disclosure clause
2025-10-24 09:55:07 +02:00
Uruk
dbcf5759da docs: remove markdown link formatting from commit guide
Some checks failed
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🚦 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
Simplifies the Conventional Commits reference by converting it from markdown link syntax to plain text with URL, improving readability in the documentation file.
2025-10-19 22:45:23 +02:00
renovate[bot]
37364b235f chore(deps): Update github/codeql-action action to v4.30.9 (#1138)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-19 22:41:49 +02:00
renovate[bot]
fcb8e83845 chore(deps): Update actions/setup-node action to v6 (#1128)
Some checks failed
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (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
🏷️🔀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 / 🔍 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
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 22:08:14 +02:00
renovate[bot]
16046a4a15 chore(deps): Update dependency expo-doctor to v1.17.10 (#1125)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 22:07:31 +02:00
renovate[bot]
13b29bdee0 chore(deps): Update dependency lint-staged to v16.2.4 (#1126)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 22:06:53 +02:00
renovate[bot]
bf4a1f9462 chore(deps): Update actions/dependency-review-action action to v4.8.1 (#1123)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 22:04:40 +02:00
renovate[bot]
264439858d chore(deps): Update dependency @biomejs/biome to v2.2.6 (#1124)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 22:04:25 +02:00
renovate[bot]
6576a58884 chore(deps): Update github/codeql-action action to v4.30.8 (#1127)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 22:03:29 +02:00
Chris
3eae2f3fde docs: improve README.md
Some checks failed
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏗️ 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
🏷️🔀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
Improve structure and consistency, fix grammar and spelling issues, reorder content for clarity, add GitHub download button, update Discord badge to a more suitable version, and include various other improvements
2025-10-12 19:26:22 +02:00
Chris
deae6ab21a docs: improve README.md
Some checks failed
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀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
🚦 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
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
- Rename Jellyseer to Seerr
- Remove dots from feature list
- Add download button linking to GitHub
- Refine phrasing in Contributing section
- Correct grammar and spelling errors
2025-10-09 21:51:02 +02:00
Chris
5478179367 Get it on Github button
Button to replace text string "Or download the APKs here on GitHub for Android
2025-10-09 21:22:51 +02:00
Uruk
3e20050b64 chore: ignore AI assistant configuration directories
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
Moves AI assistant configuration folders (.cursor/ and .claude/) to .gitignore instead of tracking them in the repository.

Removes IDE-specific tooling configurations that are personal to individual developers and should not be version controlled.
2025-10-09 16:28:25 +02:00
Uruk
59e9913c78 refactor: separate type and value exports
Improves TypeScript export organization by explicitly distinguishing between value exports and type exports.

Separates the module export into two distinct export statements to follow TypeScript best practices, making it clearer which exports are runtime values versus compile-time types.
2025-10-09 16:16:22 +02:00
Uruk
2b2797005a chore: enhance TypeScript compiler configuration
Improves build performance and developer experience by enabling incremental compilation and adding essential compiler options.

Enables incremental builds with build info caching to speed up subsequent compilations.

Adds modern module resolution and interoperability options for better compatibility with bundlers and JavaScript modules.

Enforces stricter type checking with isolated modules and consistent file naming conventions.
2025-10-09 16:09:40 +02:00
Uruk
c53acb16fc chore: organize .gitignore and add VS Code workspace config
Restructures .gitignore with logical sections and comments to improve maintainability and clarity. Groups related patterns under headers like Dependencies, Build Artifacts, Certificates, and Secrets.

Adds VS Code workspace configuration to standardize development environment across the team. Includes recommended extensions for React Native/Expo development (Biome, Expo tools, React Native debugger, Tailwind IntelliSense) and comprehensive editor settings for formatting, TypeScript performance, and file navigation.

Configures Biome as the default formatter with format-on-save enabled for JavaScript/TypeScript files. Optimizes TypeScript settings for better auto-imports and IntelliSense. Enables file nesting in explorer and excludes build directories from file watcher to improve editor performance.
2025-10-09 16:08:59 +02:00
Uruk
53570a5ee5 Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop 2025-10-09 13:57:27 +02:00
Uruk
e3b7dd8241 docs(copilot): enhance instructions with Bun requirements and development standards
Expands Copilot instructions to enforce critical Bun-first package management workflow and prevent usage of npm/yarn/npx commands.

Adds comprehensive sections covering:
- Runtime and tooling stack details with Bun as primary runtime
- Explicit package management commands to prevent npm/yarn usage
- Performance optimization guidelines leveraging Bun's capabilities
- Testing approach using Bun's built-in test runner
- Enhanced coding standards and API integration patterns
- Cross-platform development considerations for mobile and TV

Improves developer onboarding by providing clearer context about project architecture, offline capabilities, and Chromecast support in the overview.
2025-10-09 13:57:07 +02:00
renovate[bot]
786d082706 chore(deps): Update actions/stale action to v10.1.0 (#1120)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-09 13:56:16 +02:00
Uruk
164de0af0d feat: add workflow failure notifications to Discord
Extends the Discord notification system to monitor and report workflow failures in addition to pull requests.

Adds a new workflow_run trigger that listens for completed workflows on the develop branch and sends Discord notifications when any workflow fails.

Separates notification logic into two jobs: one for pull request events and another for workflow failures, each with appropriate conditional guards and using different webhook URLs for distinct notification channels.

Renames the workflow from "Discord Pull Request Notification" to "Discord Notification" to reflect the expanded scope.
2025-10-09 13:41:19 +02:00
Simon Eklundh
820b30b7e2 feat: adds the hungarian option to i18n.ts (#1112)
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: lostb1t <coding-mosses0z@icloud.com>
2025-10-09 08:27:24 +02:00
Copilot
5bc4c4a856 fix: resolve TypeScript type errors in SubtitleToggles.tsx by using settings instead of pluginSettings for values (#1119)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lostb1t <168401+lostb1t@users.noreply.github.com>
2025-10-09 06:25:51 +02:00
Zach Ross-Clyne
f7e0667416 feat: Adding custom endpoint option for sections (#1118)
Co-authored-by: lostb1t <coding-mosses0z@icloud.com>
2025-10-09 01:41:59 +02:00
Chris
8c68283c56 Revert jellyseerr-logo.svg to previous version
Seerr branding is still pending finalization. The logo is awaiting approval before proceeding with updates. Reverting to the previous one for now
2025-10-09 00:48:16 +02:00
renovate[bot]
bb0149406c chore(deps): Update github/codeql-action action to v4 (#1116)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-08 13:49:57 +02:00
Chris
b1d5630025 Update jellyseerr-logo.svg
Replaced the old Jellyseerr logo with the new Seerr branding to align with the project's updated name and visual identity
2025-10-07 23:16:07 +02:00
Chris
a5f5531bb9 Update English translations for Seerr rebranding
Replaced user-facing instances of "Jellyseerr" with "Seerr" due to the product rebranding. Ensures all app strings reflect the new name consistently
2025-10-07 22:53:58 +02:00
Chris
c2a3817fa8 Standardize to "Box Sets" in English localization strings
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
Standardize "Boxset"/"Boxsets" → "Box Sets" for consistency
2025-10-07 01:35:42 +02:00
renovate[bot]
700bb2dc79 chore(deps): Pin dependency expo-dev-client to 5.2.4 (#1110)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-03 03:05:10 +02:00
renovate[bot]
d741ca3ecc chore(deps): Update github/codeql-action action to v3.30.6 (#1111)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-03 03:05:00 +02:00
renovate[bot]
ae9f6b1ce4 chore(deps): Update dependency @types/jest to v30 (#906)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-03 02:50:44 +02:00
Uruk
cd3f1a8cee refactor: move expo-dev-client to devDependencies
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
Moves expo-dev-client from runtime dependencies to devDependencies since it's only needed during development and testing phases, not in production builds.

Reduces bundle size and clarifies the dependency's intended usage scope.
2025-10-02 22:48:21 +02:00
Uruk
be745dc136 chore: cleanup unused files and reorganize dependencies
Updates Biome schema to latest version and removes template files that are no longer needed.

Moves expo-dev-client to devDependencies where it belongs for development-only usage.
2025-10-02 22:33:03 +02:00
renovate[bot]
7b6fe0a6c0 chore(deps): Pin dependencies (#1014)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-02 22:14:10 +02:00
Uruk
fc44283f09 feat: add Crowdin configuration for translation management
Enables automated translation workflow by configuring Crowdin integration.

Sets up source translation file mapping from English to multiple language codes with update approval workflow.
2025-10-02 22:10:22 +02:00
Uruk
b2f6edc54e ci: allow untranslated files in Crowdin workflow
Removes the skip_untranslated_files configuration to include files that may contain some untranslated strings in the export process.

This enables more comprehensive translation updates while still maintaining quality control through the skip_untranslated_strings option.
2025-10-02 22:00:46 +02:00
Simon Eklundh
b42d033b87 feat(ci): enhance Crowdin workflow (#1104)
Co-authored-by: Uruk <contact@uruk.dev>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-10-02 21:54:11 +02:00
VXsz
1fb166bcd1 Add Arabic Translation (#1058)
Co-authored-by: lostb1t <coding-mosses0z@icloud.com>
2025-10-02 20:17:28 +02:00
Uruk
de6133581b remove: postinstall-postinstall dependency
Removes unused postinstall-postinstall package from development dependencies and trusted dependencies list.

Cleans up package configuration by eliminating unnecessary dependency that was no longer serving a purpose in the project.
2025-10-02 19:26:31 +02:00
SuxAsLux
08c7382191 feat(lang): Add full Hungarian translation (#1090)
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
Co-authored-by: SuxAsLux <suxaslux@gmail.com>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
Co-authored-by: Simon Eklundh <simon.eklundh@proton.me>
2025-10-01 22:18:54 +02:00
Simon Eklundh
d7b4e01aa5 feat: add crowdin integration to streamyfin (#1103)
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2025-10-01 21:24:03 +02:00
Fredrik Burmester
2f2099e243 fix: android header not visible
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2025-10-01 09:12:49 +02:00
Fredrik Burmester
79b4a0869a chore: version 2025-10-01 09:12:34 +02:00
Fredrik Burmester
dececc477f Merge branch 'feature/subtitle-customizations' into develop 2025-10-01 08:33:54 +02:00
Fredrik Burmester
8d7416ae1c Merge branch 'develop' into feature/subtitle-customizations 2025-10-01 08:33:04 +02:00
Fredrik Burmester
e116d861f6 Merge branch 'develop' into fix/downloads-of-other-media 2025-10-01 08:32:44 +02:00
Fredrik Burmester
4a28352b53 chore: remove log 2025-10-01 08:32:34 +02:00
Uruk
b372c353c0 fix: improve fork detection logic in artifact comment workflow
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
Enhances fork detection by implementing more precise repository comparison logic
and adds comprehensive debugging output to troubleshoot permission issues.

Changes null-safe comparisons to prevent false positives when repository
information is undefined, ensuring the workflow only skips comment creation
for actual cross-repository forks rather than same-repository scenarios.
2025-09-30 12:44:13 +02:00
Uruk
48cb0b7013 fix: prevent permission errors when workflow runs from forks
Adds fork detection to skip comment operations when running from external repositories, preventing 403 permission errors.

Implements early exit when pull request or workflow run originates from a fork, and wraps comment operations in try-catch to handle remaining permission issues gracefully by logging build status instead.
2025-09-30 12:17:38 +02:00
Lance Chant
1eba074ebd chore: rework logic
Changed logic to use the settings component to store the changed values
rather than referencing storage directly
Deleted an unused file

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2025-09-30 11:18:36 +02:00
Lance Chant
2d46907351 Merge remote-tracking branch 'origin/develop' into feature/subtitle-customizations
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2025-09-30 10:43:03 +02:00
Lance Chant
e877d038ba fix: download handling of "other" media
Fixed an issue where downloaded items were not appearing in downloads

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2025-09-30 10:39:56 +02:00
Uruk
c6ad06b084 fix: improve workflow run detection for cancelled jobs
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
Enhances the artifact comment workflow to better handle cancelled workflow runs by checking individual job statuses instead of dismissing entire workflows.

Previously, workflows marked as cancelled at the top level were completely ignored, even if some jobs within them were still running or completed successfully.

Now prioritizes active jobs over cancelled ones and validates that at least one job is actually running before processing a workflow run, preventing false negatives while still filtering out truly cancelled workflows.
2025-09-30 02:15:25 +02:00
Uruk
71a3c5e92b feat: improve GitHub workflow status tracking
Enhances artifact comment workflow to provide more accurate build status reporting by tracking individual job statuses within consolidated workflows rather than using workflow-level status.

Excludes cancelled workflow runs from consideration and prioritizes active runs over completed ones when determining build status.

Maps specific job names to build targets (Android Phone, Android TV, iOS Phone) to provide granular status information for each platform and device combination.

Improves artifact collection logic to gather artifacts when any job completes successfully, not just when entire workflow completes.
2025-09-30 01:57:18 +02:00
Uruk
0a41962ddf fix: improve build status handling and artifact links
Replaces nightly.link with direct GitHub artifact URLs for better reliability.

Adds handling for cancelled builds and edge cases where workflows complete
but artifacts aren't immediately available or conclusions are pending.

Improves status messages to provide more detailed information for
unexpected build states.
2025-09-30 01:49:38 +02:00
Uruk
312a59c5b0 refactor(ci): improve build workflow handling and artifact collection logic
Restructures the GitHub Actions workflow to better handle consolidated vs separate build workflows.

Changes the artifact collection to trigger on workflow completion rather than just success, improving visibility of failed builds.

Adds explicit fallback logic for backward compatibility with separate Android and iOS workflows.

Introduces artifact pattern matching for more reliable build target identification and adds special handling to disable iOS TV builds.

Enhances debugging output to show which workflow type is being used and lists all discovered artifacts.
2025-09-30 01:42:17 +02:00
Uruk
fb0a70690e refactor: consolidate build workflows into unified app builder
Merges separate Android and iOS build workflows into a single "Build Apps" workflow to reduce duplication and simplify maintenance.

Updates the artifact comment workflow to handle both the new unified workflow and legacy separate workflows for backward compatibility during transition.

Removes redundant workflow files while preserving all existing functionality.
2025-09-30 01:20:11 +02:00
Uruk
788f420ce5 refactor: migrate artifact comment system from repository_dispatch to workflow_run
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
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (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
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
Replaces the repository_dispatch event system with workflow_run triggers to improve reliability and reduce complexity. The new approach automatically detects PR associations through commit SHA lookups rather than requiring manual payload construction.

Removes redundant notification steps from build workflows and simplifies the concurrency group logic. Enhances manual testing support with improved PR discovery fallbacks.
2025-09-30 00:58:25 +02:00
Uruk
2b761f15c8 feat!: replace workflow_run with repository_dispatch for real-time build status updates
Replaces the workflow_run trigger mechanism with repository_dispatch events to enable real-time build status communication between build workflows and the artifact comment system.

Build workflows now actively notify the comment workflow when builds start, complete, or fail, providing immediate status updates rather than polling for completed workflows.

Adds real-time payload processing to display current build status and target information in PR comments, improving visibility into ongoing build processes.

BREAKING CHANGE: Changes the trigger mechanism from workflow_run to repository_dispatch, requiring build workflows to explicitly send status notifications.
2025-09-30 00:51:02 +02:00
Uruk
44e489f40c refactor: improve workflow run tracking and status display
Enhances the artifact comment workflow by switching from tracking all build runs to focusing on the most recent run per workflow type (Android/iOS).

Changes include:
- Increases pagination limit to capture more workflow runs
- Sorts runs by creation time to identify latest builds
- Simplifies status tracking by workflow platform rather than individual runs
- Adds detailed logging for debugging build statuses
- Improves error handling for artifact collection
- Fixes emoji rendering issue in status display

Reduces complexity while ensuring accurate status reporting for the latest builds.
2025-09-30 00:29:13 +02:00
Uruk
e985adf062 feat: improve build status UI with progressive updates
Restructures the artifact comment workflow to display build progress in real-time with individual platform/device status tracking.

Changes the status table from workflow-based to target-based (Android Phone/TV, iOS Phone/TV) with dedicated status indicators and download links that update as builds complete.

Improves user experience by showing pending builds with appropriate messaging instead of waiting for all builds to finish before displaying any information.
2025-09-30 00:22:51 +02:00
Uruk
5b0d2f3f7b fix(ci): correct GitHub Actions run ID reference
Fixes incorrect variable reference in artifact comment generation that was using undefined `runId` instead of the proper `context.runId` from the GitHub Actions context.

Also adds descriptive job name for better workflow visibility.
2025-09-30 00:03:40 +02:00
Uruk
4f6863f317 feat: add direct pull request trigger to artifact comment workflow
Enables the workflow to run directly on pull request events (opened, synchronize, reopened) in addition to the existing workflow_run and manual dispatch triggers.

Provides immediate status updates in PR checks and improves user experience by showing workflow progress directly in the pull request interface rather than only after completion of upstream workflows.
2025-09-30 00:01:15 +02:00
Uruk
0d1aeaf8aa fix(ci): improve workflow run condition and artifact collection
Fixes workflow trigger condition by explicitly checking for workflow_run event type to prevent unintended executions.

Improves artifact collection reliability by switching to the correct API method and increasing page size to capture more artifacts from multiple builds.

Removes redundant artifact fetching logic that was duplicating collection efforts.
2025-09-29 23:55:36 +02:00
Uruk
1ff09a2d34 feat: enhance artifact workflow to show real-time build status
Improves the artifact comment workflow to provide better visibility into ongoing builds by:

- Triggering comments when builds start (requested event) instead of only on completion
- Using commit SHA for concurrency grouping to better handle multiple builds for the same commit
- Collecting artifacts from all recent build workflows for a PR rather than just the current run
- Adding a build status table showing progress of Android and iOS workflows
- Displaying progressive status updates even when builds are still in progress
- Enabling cancel-in-progress to prevent redundant workflow runs

This provides users with immediate feedback on build progress and comprehensive artifact availability across all platforms.
2025-09-29 23:51:03 +02:00
Gauvain
92e40c7aa0 feat: Build in pr (#1101)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-29 23:16:12 +02:00
Gauvain
7fe3ca8484 feat: add automated PR comments for build artifacts (#1100)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-29 22:31:41 +02:00
Gauvain
f104e952ab feat: add automated PR comments for build artifacts (#1099)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-29 18:37:02 +02:00
Fredrik Burmester
0ec44add7d fix: navigatte to item
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2025-09-29 15:02:46 +02:00
Gauvain
2273b7be0a feat: enhance download pause/resume with progress persistence (#1034) 2025-09-29 14:17:07 +02:00
Fredrik Burmester
1733db6c28 chore: version bump 2025-09-29 11:36:14 +02:00
Fredrik Burmester
cc2e634137 feat: new large carousel (#1098) 2025-09-29 11:32:34 +02:00
Fredrik Burmester
af6b18546e fix: ios 26 design improvements (#1097) 2025-09-29 11:22:39 +02:00
renovate[bot]
26c99cfc3d chore(deps): Update actions/dependency-review-action action to v4.8.0 (#1095)
Some checks failed
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ 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 / 🔍 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
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-28 14:59:24 +02:00
renovate[bot]
1b3a5443ef chore(deps): Update actions/dependency-review-action action to v4.7.4 (#1093)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-28 14:55:39 +02:00
renovate[bot]
8ca330d765 chore(deps): Update github/codeql-action action to v3.30.5 (#1094)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-28 14:55:31 +02:00
renovate[bot]
569b143eba chore(deps): Update github/codeql-action action to v3.30.4 (#1087)
Some checks failed
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-26 15:21:40 +02:00
Gauvain
ead37aa806 fix: resolve type issues and improve component reliability (#1078)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-09-25 22:47:49 +02:00
lance chant
d250295e36 Update direct-player.tsx
Fixed syntax
2025-09-25 09:11:24 +02:00
lance chant
28f6729ae2 Update direct-player.tsx
Added text scaling
2025-09-25 07:58:36 +02:00
renovate[bot]
2a195d9ba5 chore(deps): Update actions/cache action to v4.3.0 (#1084)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-25 00:15:46 +02:00
lance chant
577774c355 Merge branch 'develop' into feature/subtitle-customizations 2025-09-23 08:17:20 +02:00
asmithrsa
74c83c4f00 feat: standardize capitalization for english menu options (#1068)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ 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
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Uruk <contact@uruk.dev>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-09-23 02:19:20 +02:00
herrrta
97d9309855 fix: missing optional chaining for plugin setting (#1062) 2025-09-23 01:34:18 +02:00
lance chant
e03cefce47 Merge branch 'develop' into feature/subtitle-customizations 2025-09-22 12:45:06 +02:00
Simon Eklundh
91c4df1585 feat: fix playback reporting (#1077)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-09-22 12:11:14 +02:00
Gauvain
388342147e refactor: replace inline typecheck with dedicated script (#1075)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2025-09-21 20:31:56 +02:00
lostb1t
9b367fd8c2 fix: remove redudant episode requests (#1076)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-09-21 12:33:26 +02:00
Chris
2d4d3f5b1b Update splash screen background from gray to off-black
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ 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
Changing splash screen background color from a medium gray (#2E2E2E( to an "off-black" (#010101) for a darker, sleeker appearance on app launch
2025-09-20 14:38:19 +02:00
Lance Chant
388f65b443 chore: moved constant values to a file
Reduced duplication and removed constants to a new file

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2025-09-20 13:03:07 +02:00
Lance Chant
621d164402 feat: added more subtitle customization options
Subtitles can now be customized with the following extra options:
- Colour
- background opacity/colour
- outline opacity/colour
- boldness

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2025-09-20 12:35:00 +02:00
lance chant
77895983b0 fix: add better offline messages and checks (#1051)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-09-19 16:47:58 +02:00
lostb1t
fa8af5bc11 Update linting.yml 2025-09-19 15:57:13 +02:00
herrrta
1feb22854c chore: update jellyseerr submodule (#1059)
Some checks failed
🚦 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
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ 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 / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-09-17 15:38:03 +02:00
herrrta
09b3cc7835 fix: external deep links not working pt2 (#1064)
Some checks failed
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2025-09-14 10:55:04 +02:00
herrrta
9175c6a135 fix: external deep links not working (#1063)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-09-13 20:08:42 +02:00
renovate[bot]
7436ad90eb chore(deps): Update dependency @biomejs/biome to v2.2.4 (#1054)
Some checks failed
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (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 / 🔍 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>
Co-authored-by: Uruk <contact@uruk.dev>
2025-09-10 20:14:31 +02:00
renovate[bot]
fb2bdb0e2c chore(deps): Update github/codeql-action action to v3.30.3 (#1055)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-10 20:10:57 +02:00
Gauvain
0fd2d766a3 chore(deps): Update dependency @biomejs/biome to v2.2.3 (#1048)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ 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
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 17:51:41 +02:00
renovate[bot]
bdc0962d60 chore(deps): Update dependency @biomejs/biome to v2.2.3 (#1044)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 17:51:01 +02:00
renovate[bot]
2a49b766e7 chore(deps): Update github/codeql-action action to v3.30.1 (#1045)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 17:47:34 +02:00
Drew Bowman
b02f49fbd2 feat: improve quick connect bottom sheet (#1000)
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
Co-authored-by: lostb1t <coding-mosses0z@icloud.com>
2025-09-08 16:33:43 +02:00
Copilot
0b0592a699 fix: library sorting persistence by adding missing useEffect dependencies (#1047)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lostb1t <168401+lostb1t@users.noreply.github.com>
Co-authored-by: lance chant <13349722+lancechant@users.noreply.github.com>
2025-09-08 16:15:23 +02:00
lance chant
ceafdbf9ee fix: fixed permission request for activity (#1046)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2025-09-08 15:02:33 +02:00
sarendsen
2b77d0fefb fix: media source sheet was using the wrong prop
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2025-09-08 13:25:51 +02:00
retardgerman
32094fbc9f docs: add copilot-instructions.md for context-aware AI suggestions (#1035)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ 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
Co-authored-by: lostb1t <coding-mosses0z@icloud.com>
2025-09-05 16:10:16 +02:00
renovate[bot]
b5917821a0 chore(deps): Update actions/setup-node action to v5 (#1038)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ 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>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-09-05 00:55:24 +02:00
renovate[bot]
42922cc92b chore(deps): Update actions/stale action to v10 (#1039)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 00:23:23 +02:00
lostb1t
0298fb00aa fix: section titles (#1036)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ 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
fixes a bug where the section titles were just "section-[index]"
2025-09-04 16:31:56 +01:00
Fredrik Burmester
e47c863aa4 chore: version
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2025-09-04 07:22:15 +02:00
Fredrik Burmester
0b9bbb63eb fix: download card design and percentage negative number fix
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2025-09-03 22:26:57 +02:00
Fredrik Burmester
b4014c922e feat: native download notifications (#1006) 2025-09-03 21:50:25 +02:00
stenlan
957e60714a fix: BaseItemDto/BaseItemPerson routing bug (#1030) 2025-09-03 21:45:15 +02:00
stenlan
93a63f6b48 fix: #1007 Season episodes temporarily disappearing when marking one as watched (#1031)
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2025-09-03 21:42:03 +02:00
stenlan
c778956a52 fix: Jellyseerr discovery crash (#1032) 2025-09-03 21:41:35 +02:00
MarcoCoreDuo
6308375438 fix: remove unnecessary sort for getTvShowsApi.getEpisodes data in SeasonPicker (#984)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ 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
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-09-03 15:32:57 +02:00
lance chant
2d3344f013 fix: home screen translation for custom headers (#1001)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-09-03 15:31:43 +02:00
Fredrik Burmester
ae720d6bb4 fix: height for list items 2025-09-03 15:14:36 +02:00
lance chant
62b25d7bf7 fix: offline checking (#989) 2025-09-03 12:26:56 +02:00
Copilot
68e3b74e49 Fix Jellyseerr TV request permission logic bug (#1026)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2025-09-03 07:05:46 +02:00
Uruk
e8c9bb1730 Revert "feat: add password visibility toggle to login forms (#1019)"
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
This reverts commit ae09a59569.
2025-09-02 15:33:41 +02:00
Gauvain
ae09a59569 feat: add password visibility toggle to login forms (#1019)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-09-01 23:45:12 +02:00
renovate[bot]
8a6c6dbd69 chore(deps): Update github/codeql-action action to v3.30.0 (#1021) 2025-09-01 20:33:25 +02:00
Uruk
602a5fb7d9 chore: simplify renovate configuration
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ 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
Streamlines dependency management by removing complex package rules and switching to best-practices preset.

Key improvements:
- Reduces configuration complexity from 86 to 46 lines
- Enables OSV vulnerability alerts and config migration
- Separates minor and patch updates for better control
- Updates schedule to weekdays instead of Monday-only
- Consolidates vulnerability handling into lock file maintenance section
2025-08-31 23:56:29 +02:00
Uruk
041cd56d41 chore(deps): remove commit message suffix from Renovate configuration 2025-08-31 17:12:54 +02:00
Uruk
9beeaa2c23 chore: update Renovate configuration format
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ 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
Migrates from deprecated config:base to recommended preset and updates commit message configuration to use separate prefix and suffix properties instead of single commitMessage field.

Changes package matching from patterns to names for better specificity in security update rules.
2025-08-31 17:07:07 +02:00
Gauvain
df0b569f2d fix(typescript): resolve 44 TypeScript errors in core components (#1004) 2025-08-31 16:56:53 +02:00
Simon Eklundh
83c4aadbb4 feat: Add https/http testing for removing the need for adding them manually (#1005) 2025-08-31 12:33:44 +02:00
lostb1t
5b7af05a9c feat: prefer source name over display title
Some checks failed
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (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
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-08-30 15:47:17 +02:00
Uruk
954d65050d chore: remove outdated package rules for Expo, React Native, and build tools
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2025-08-30 03:22:28 +02:00
Uruk
02dfdfb2de chore: standardize renovate commit message format
Replaces the simple emoji prefix with a structured conventional commit format that includes dependency type and version information.

This improves commit message consistency and makes dependency updates more informative by clearly indicating the package name and target version.
2025-08-30 03:13:14 +02:00
Uruk
6de1cdad50 feat: enhance Renovate configuration with automerge and grouping rules
Improves dependency management automation by enabling automerge for patches, minors, and CI dependencies while adding comprehensive package grouping rules.

Adds vulnerability alerts with immediate scheduling and configures minimum release age for stability. Groups related packages like React ecosystem and build tools for better organization.

Includes enhanced scheduling with weekly updates and monthly major version reviews requiring dashboard approval for safety.
2025-08-30 03:05:26 +02:00
Fredrik Burmester
f7b0bf34a7 chore: version
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-08-29 22:08:07 +02:00
Gauvain
a68d8500a6 chore: update dependencies and refactor config plugin imports (#993) 2025-08-29 22:06:50 +02:00
Gauvain
f54da12fdb refactor: rework of issue and pr template and add contributing.md (#998)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-08-29 19:02:48 +02:00
lance chant
d3609e3499 fix: tv login layout (#972)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ 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
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-08-29 13:30:49 +02:00
Uruk
af323d4679 ci: enable EAS caching for iOS build workflow
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ 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
Improves build performance by enabling caching in the EAS CLI setup step.

Reduces build times and resource usage by leveraging cached dependencies and build artifacts.
2025-08-29 01:32:04 +02:00
Chris
adc46acd01 docs: update contact email
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ 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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
developer@streamyfin.app
2025-08-29 00:03:19 +02:00
Chris
22b18dbb2d Update SECURITY.md
Updated email address
2025-08-28 22:39:16 +02:00
Gauvain
cf482788fc feat: Enhance SECURITY.md with detailed security policy 2025-08-28 22:04:48 +02:00
Uruk
bede1fc3c6 ci: skip builds for [skip ci] commits and markdown changes
Adds [skip ci] detection to prevent unnecessary workflow runs when builds are not needed.

Excludes markdown files from triggering iOS builds to reduce resource usage for documentation changes.

Removes redundant node_modules caching step in Android workflow since Bun handles dependency management efficiently.
2025-08-28 18:09:54 +02:00
Gauvain
b25f8a67fc ci: Enable statistics for stale issue workflow 2025-08-28 17:34:15 +02:00
Gauvain
ade9dd6c3f ci: Remove debug log level from stale.yml 2025-08-28 17:32:49 +02:00
Uruk
568252b6d3 fix: update merge conflict and stale issue labels to include emojis
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ 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
2025-08-28 17:06:03 +02:00
Uruk
83538eb474 Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ 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
2025-08-28 00:51:54 +02:00
Uruk
2c8c7120e0 chore: update expo-doctor to version 1.17.0 and reorganize scripts in package.json 2025-08-28 00:51:45 +02:00
renovate[bot]
a034d4afa5 fix(deps): update dependency zod to v4 (#845)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Uruk <contact@uruk.dev>
2025-08-28 00:50:22 +02:00
Uruk
a3c3094e21 chore: update iOS build matrix to target only phone 2025-08-27 21:01:10 +02:00
Uruk
5b5187e49f chore: bump @biomejs/biome from 2.2.0 to 2.2.2
Updates Biome linter and formatter to latest patch version for bug fixes and improvements
2025-08-27 20:08:34 +02:00
renovate[bot]
d8c6ea20f3 chore(deps): update actions/dependency-review-action action to v4.7.3 (#991)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-27 14:54:30 +02:00
retardgerman
3f1897e981 feat(issue-template): add latest app version to bug report dropdown
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-08-25 11:57:48 +02:00
renovate[bot]
af05e60af4 chore(deps): update ci dependencies (#977)
Some checks failed
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (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 / 🚑 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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-23 12:51:38 +02:00
Fredrik Burmester
5f47eee6e4 chore: versions
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-08-21 18:22:41 +02:00
Fredrik Burmester
7b7aced881 feat: slide actions for skip, volume and brightness (#966) 2025-08-21 18:02:47 +02:00
Fredrik Burmester
aac9270b62 feat: more sheets in controls (#969) 2025-08-21 17:54:28 +02:00
lostb1t
2d69bd5103 feat: replace content item dropdowns with sheets (#968) 2025-08-21 17:19:53 +02:00
Fredrik Burmester
576a820c0c chore: version
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 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
2025-08-20 22:00:09 +02:00
Fredrik Burmester
55ce7d8cec feat: fade and slide in controls (#964) 2025-08-20 21:59:09 +02:00
Fredrik Burmester
f2219a1daa chore: version 2025-08-20 21:48:14 +02:00
Fredrik Burmester
5bc6494ba6 fix: refactor controls into smaller parts (#963) 2025-08-20 21:43:00 +02:00
Fredrik Burmester
7cab50750f chore: version
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-08-20 10:30:57 +02:00
Fredrik Burmester
d795e82581 fix: trickplay and re-rendering issues 2025-08-20 09:59:03 +02:00
Fredrik Burmester
e7161bc9ab fix: revert fade in controls 2025-08-20 08:21:01 +02:00
Fredrik Burmester
8e74363f32 Revert "chore: refactor controls (#946)"
This reverts commit 8389404975.
2025-08-20 08:18:12 +02:00
Alex
1cb28788d6 Fix selecting bit rate on whole series downloads (#956)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-08-20 00:12:51 +10:00
renovate[bot]
ff9f855d4c chore(deps): update amannn/action-semantic-pull-request action to v6.1.0 (#953) 2025-08-19 13:37:47 +02:00
Fredrik Burmester
13df2d1077 chore: version
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 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
2025-08-19 10:01:34 +02:00
Fredrik Burmester
8389404975 chore: refactor controls (#946)
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-08-19 09:02:56 +02:00
Fredrik Burmester
cd920e2d84 fix: small design change 2025-08-19 08:10:54 +02:00
Gauvain
92a11c18e0 docs: add new contributors to README (#951)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 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 (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
2025-08-19 04:25:49 +02:00
Gauvain
e05f10fe42 ci: add actions language to CodeQL analysis matrix
Expands security scanning to include GitHub Actions workflows alongside existing JavaScript/TypeScript analysis for more comprehensive code security coverage
2025-08-19 01:09:23 +02:00
renovate[bot]
2540ae22ce chore(deps): update actions/dependency-review-action action to v4.7.2 (#950)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 01:08:50 +02:00
Gauvain
f490957091 ci: add iOS 18.0 SDK installation step (#949) 2025-08-19 01:06:28 +02:00
renovate[bot]
a146fc8810 chore(deps): update dependency @biomejs/biome to v2.2.0 (#934)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Uruk <contact@uruk.dev>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-08-18 23:00:33 +02:00
renovate[bot]
100d7e0830 chore(deps): update github/codeql-action action to v3.29.10 (#948)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-18 21:18:54 +02:00
Fredrik Burmester
ebcdd5bbf7 feat: show when the stream ends, not only remaining time (#944)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-08-18 14:57:02 +02:00
lance chant
18b33884e6 fix: settings storage calc (#943)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2025-08-18 14:53:58 +02:00
Fredrik Burmester
9410239c48 feat: scale factor and aspect ratio (#942) 2025-08-18 14:24:45 +02:00
Fredrik Burmester
4fed25a3ab chore: version
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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 (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
2025-08-18 09:17:24 +02:00
Fredrik Burmester
a8810cae8a Merge branch 'feat/fade-in-controls' into develop 2025-08-18 09:16:38 +02:00
Fredrik Burmester
aff009de92 chore: version 2025-08-18 07:48:57 +02:00
Alex
1924efbef2 Fix more bugs (#939)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-08-17 15:25:51 +10:00
Alex
3b53d76a18 Hotfix/offline playback remaining bugs (#937)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-08-16 18:11:55 +10:00
Fredrik Burmester
b7221e5599 chore: version bump
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-08-15 21:45:37 +02:00
Fredrik Burmester
5384c34b27 feat: infinite scrolling in favorites tab (#929)
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-08-15 21:34:36 +02:00
Alex
ca92f61900 refactor: Feature/offline mode rework (#859)
Co-authored-by: lostb1t <coding-mosses0z@icloud.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
Co-authored-by: Gauvino <uruknarb20@gmail.com>
Co-authored-by: storm1er <le.storm1er@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Chris <182387676+whoopsi-daisy@users.noreply.github.com>
Co-authored-by: arch-fan <55891793+arch-fan@users.noreply.github.com>
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-08-15 21:34:22 +02:00
Uruk
4fba558c33 refactor: remove gradle optimization properties
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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 (format) (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 (lint) (push) Has been cancelled
Removes daemon, parallel processing, and configure-on-demand gradle properties to simplify configuration and potentially avoid build conflicts.

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

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

Reduces workflow complexity and potential cache conflicts while maintaining build performance.
2025-08-15 04:44:19 +02:00
Uruk
1e399297bd ci: remove Expo CLI cache step from iOS build workflow
Eliminates unnecessary caching of Expo CLI in the iOS build pipeline to streamline the workflow and reduce potential cache-related issues.
2025-08-15 04:30:43 +02:00
Gauvain
feaf82fa3f ci: remove CocoaPods cache and update EAS to latest
Removes CocoaPods caching step which may cause build inconsistencies and updates EAS CLI to use latest version instead of pinned version for improved tooling and bug fixes
2025-08-15 04:21:25 +02:00
Gauvain
781d199546 refactor: simplify renovate configuration and revert kotlin (#933)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2025-08-14 16:07:49 +02:00
liamwibo
3013251285 fix(readme): change discord invite link to discord badge since old link expired (#913)
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2025-08-14 14:14:18 +02:00
Gauvain
0e1ed71dc1 refactor: biome update and fix renovate and ci (#932) 2025-08-14 10:43:01 +02:00
renovate[bot]
5a781ba62c chore(deps): update amannn/action-semantic-pull-request action to v6 (#931)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-13 23:14:26 +02:00
renovate[bot]
0cea614423 chore(deps): update github/codeql-action action to v3.29.9 (#930)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-13 22:15:32 +02:00
Gauvain
24d006742b Merge branch 'develop' into feat/fade-in-controls 2025-08-13 20:32:53 +02:00
Gauvain
c7f0c2ec83 refactor(ci): Improves CI build performance with enhanced caching (#923)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-13 15:43:22 +02:00
Fredrik Burmester
c34c7fbe83 feat: fade in the controls (instead of on/off toggle) 2025-08-13 15:27:47 +02:00
renovate[bot]
57bbb59874 chore(deps): update actions/checkout action to v5 (#926)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 16:51:54 +02:00
renovate[bot]
e90d2e2244 chore(deps): update dependency @react-native-community/cli to v20 (#924)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 14:05:51 +02:00
renovate[bot]
917dabc4be chore(deps): update actions/checkout action to v4.3.0 (#925)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 14:05:17 +02:00
renovate[bot]
bc2defc8ef chore(deps): update dependency @biomejs/biome to v2.1.4 (#921)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 00:46:20 +02:00
Gauvain
3ce1480e10 fix: Adds Biome version management to Renovate config (#920)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-08-11 00:18:19 +02:00
renovate[bot]
9597b40726 chore(deps): update github/codeql-action action to v3.29.8 (#917)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-10 22:51:29 +02:00
Gauvain
1e6408d5be chore: resolve final biome warning with explicit type annotations (#908)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-08-08 14:38:00 +02:00
Gauvain
c2f6897f47 fix(ci): Disables fail-fast for CI build matrices (#910) 2025-08-08 14:37:43 +02:00
Jaakko Rantamäki
eaf3682384 fix: Android adaptive and themed icons (#762) 2025-08-08 10:30:00 +02:00
renovate[bot]
f3c7b636a8 chore(deps): update ci dependencies (#911)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-08-07 21:09:41 +02:00
Gauvain
64d34a9354 feat: Adds separate Android TV and iOS TV build workflows (#907) 2025-08-07 16:08:40 +02:00
Edmond
2a2ecf0526 feat: Add new translation for Traditional Chinese (zh-TW) (#796)
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (push) Has been cancelled
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
2025-08-07 13:26:02 +02:00
Ferran
a77c7e8e3c feat(lang): add Catalan localization support (#873)
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-08-07 13:19:48 +02:00
Gauvain
88791eccf9 fix: Adds conditional check to validate PR title job (#901) 2025-08-07 13:01:32 +02:00
Gauvain
515f7ea26d fix: only run iOS build if it’s on a branch of the repo (#872) 2025-08-07 13:01:22 +02:00
Nguyen Quang Huy
e83bbf3121 feat: Added Vietnamese translation (#834) 2025-08-07 13:01:01 +02:00
lance chant
89b34eddc1 fix: tv playback (#820)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Signed-off-by: lancechant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: Uruk <contact@uruk.dev>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-08-07 10:12:40 +02:00
Gauvain
89fd7f0e34 fix: add expo-doctor, fixed a warning (#895)
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (push) Has been cancelled
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-08-06 21:46:16 +02:00
Gauvain
ab9ae5b620 fix(deps): update biome (#894) 2025-08-06 21:45:58 +02:00
retardgerman
a9c519971e fix: loading conditionals (#753) (#805)
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (push) Has been cancelled
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-08-05 11:23:14 +02:00
renovate[bot]
e51b7351f8 chore(deps): update ci dependencies (#892)
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (push) Has been cancelled
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-04 23:42:17 +02:00
renovate[bot]
e0f9d6ea1c chore(deps): update github/codeql-action action to v3.29.4 (#874)
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (push) Has been cancelled
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-07-29 16:21:34 +02:00
Fredrik Burmester
1817c5dbd2 chore: update deps (#886) 2025-07-29 15:55:41 +02:00
Fredrik Burmester
0619c8c9c4 fix: update bottom tabs (#885)
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (push) Has been cancelled
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-07-28 13:07:19 +02:00
Chris
d6ed318eb8 docs: readme-rehaul
Some checks failed
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🤖 Android APK Build / 🏗️ Build Android APK (push) Has been cancelled
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
- Added a Streamyfin banner at the top
- Aligned the descriptive text for Streamyfin
- Moved the "Buy Me a Coffee" button to the top for better visibility and contrast
- The four screenshots are now fully aligned across the entire page grid instead of being left-aligned
2025-07-24 22:28:16 +07:00
Gauvain
5f39622ad6 fix: bump biome and fix error (#864)
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (push) Has been cancelled
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-07-21 09:44:24 +02:00
renovate[bot]
3b2a6bd40a chore(deps): update marocchino/sticky-pull-request-comment action to v2.9.4 (#866)
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (push) Has been cancelled
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-20 02:03:05 +02:00
Chris
8d3e165edf docs: README.md
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (push) Has been cancelled
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Implemented general improvements and introduced sponsorship.
2025-07-18 21:19:13 +07:00
lance chant
f3a9fc9d1c fix: always transcode with profile 5 (#703)
Some checks failed
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🤖 Android APK Build / 🏗️ Build Android APK (push) Has been cancelled
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2025-07-15 08:41:52 +02:00
Emre Sanden
820af06419 feat(lang): add Norwegian localization support (#670)
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2025-07-15 08:40:28 +02:00
Miro Rauhala
80192e65c4 feat(lang): add Finnish localization support (#676)
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2025-07-15 08:39:38 +02:00
Endrit Beqiri
ff930e2ad2 feat: Added Albanian and Danish translations (#657)
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-07-15 08:38:00 +02:00
djmeero
fafc2e65ac feat(i18n): add Romanian language support via ro.json (#706) 2025-07-15 08:37:37 +02:00
Fredrik Burmester
61c783fb55 chore
Some checks are pending
🤖 Android APK Build / 🏗️ Build Android APK (push) Waiting to run
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
2025-07-14 15:48:17 +02:00
Fredrik Burmester
59df18621b chore: version 2025-07-14 15:12:56 +02:00
Alex
0021b94e00 Fix correct time not being reported when switching quality in the beg… (#855)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-07-14 20:41:24 +10:00
Alex
53b43edc2a Revert "fix: Recently Added isn't updating correctly." (#852)
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (push) Has been cancelled
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
2025-07-13 04:47:26 +10:00
Alex
64e8514985 Changed || to ?? to account for 0 values (#851)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-07-13 02:57:49 +10:00
Alex
a3d9207bca Change to ts (#848)
Some checks are pending
🤖 Android APK Build / 🏗️ Build Android APK (push) Waiting to run
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-07-12 13:18:08 +10:00
Alex
ef0880695e Update package json from expo doctor update (#846)
Some checks are pending
🤖 Android APK Build / 🏗️ Build Android APK (push) Waiting to run
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-07-12 04:38:16 +10:00
Fredrik Burmester
073110fac9 chore: remove unnessesary file
Some checks are pending
🤖 Android APK Build / 🏗️ Build Android APK (push) Waiting to run
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
2025-07-10 21:42:52 +02:00
Fredrik Burmester
2d58157cf7 chore: version 2025-07-10 21:42:35 +02:00
Fredrik Burmester
571be9840f chore: version 2025-07-10 21:40:10 +02:00
Fredrik Burmester
a2cbc722c7 chore
Some checks are pending
🤖 Android APK Build / 🏗️ Build Android APK (push) Waiting to run
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
2025-07-10 15:34:33 +02:00
Fredrik Burmester
bc7c612cca feat: add CodeRabbit configuration for React Native project 2025-07-10 15:34:09 +02:00
Alex
fe8f07336a Fix orientation race condition (#841)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-07-10 15:25:57 +02:00
arch-fan
305b06f781 fix: expo issue by updating deps (#823) 2025-07-10 21:19:48 +10:00
renovate[bot]
7d57cf1a69 chore(deps): update github/codeql-action action to v3.29.2 (#821)
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (push) Has been cancelled
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 20:02:02 +02:00
renovate[bot]
3c56544a24 fix(deps): update dependency com.android.tools.build:gradle to v8.11.0 (#819)
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (push) Has been cancelled
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
2025-06-28 16:26:43 +02:00
renovate[bot]
d6696cc84e chore(deps): update github/codeql-action action to v3.29.1 (#818) 2025-06-28 15:01:55 +02:00
renovate[bot]
bf97e419ae fix(deps): update dependency react-native-safe-area-context to v5.5.0 (#774)
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (push) Has been cancelled
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-23 22:40:09 +02:00
renovate[bot]
1e8fe46f17 chore(deps): update dependency @react-native-community/cli to v18 (#783)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-23 22:38:42 +02:00
renovate[bot]
73317e9781 fix(deps): update dependency @shopify/flash-list to v1.8.3 (#736)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-23 22:38:12 +02:00
renovate[bot]
eba0bbc9cf chore(deps): update dependency @biomejs/biome to v2 (#811)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Gauvino <uruknarb20@gmail.com>
2025-06-23 20:18:51 +02:00
renovate[bot]
c69ec61656 chore(deps): update dependency @types/jest to v30 (#812)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-23 20:05:05 +02:00
renovate[bot]
de12e2b0a2 chore(deps): update marocchino/sticky-pull-request-comment action to v2.9.3 (#810)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-23 19:48:19 +02:00
Chris
87dc57a576 docs: Update README.md (#802)
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (push) Has been cancelled
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
2025-06-19 16:35:10 +02:00
Chris
52c8b99dd5 docs: Clarify legal use of Streamyfin with a piracy disclaimer in README (#801)
Some checks are pending
🤖 Android APK Build / 🏗️ Build Android APK (push) Waiting to run
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
2025-06-18 11:02:56 +02:00
renovate[bot]
7beabe4702 fix(deps): update dependency i18next to v25 (#784)
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (push) Has been cancelled
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
2025-06-13 12:37:47 +02:00
renovate[bot]
415d7d6e9a fix(deps): update dependency com.android.tools.build:gradle to v8 (#772) 2025-06-13 12:37:15 +02:00
renovate[bot]
51b47971e2 chore(deps): update dependency lint-staged to v16 (#771)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-13 10:37:43 +02:00
Gauvain
90b0d413bc fix: remove pull request target 2025-06-13 09:32:19 +02:00
renovate[bot]
a18bcae0fb chore(deps): update github/codeql-action action to v3.29.0 (#769)
Some checks are pending
🤖 Android APK Build / 🏗️ Build Android APK (push) Waiting to run
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-12 16:28:14 +02:00
Gauvain
4ccffad3e7 fix: pr build
Some checks are pending
🤖 Android APK Build / 🏗️ Build Android APK (push) Waiting to run
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
2025-06-12 11:44:25 +02:00
renovate[bot]
46b08007a4 chore(deps): update dependency node to v22 (#766)
Some checks are pending
🤖 Android APK Build / 🏗️ Build Android APK (push) Waiting to run
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-11 15:05:49 +02:00
renovate[bot]
7b05fe43cf chore(deps): update github/codeql-action action to v3.28.19 (#763)
Some checks are pending
🤖 Android APK Build / 🏗️ Build Android APK (push) Waiting to run
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-11 00:01:41 +02:00
Gauvain
8f7749160e fix: add dashboard for renovate 2025-06-10 23:55:57 +02:00
storm1er
d4c51697d4 feat: Persist ignore safe area accross stream and app restart (#701)
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (push) Has been cancelled
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
2025-06-06 11:00:52 +02:00
Gauvino
7091502667 fix: remove git commit from release sonce it's already present in artifact menu
Some checks failed
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🤖 Android APK Build / 🏗️ Build Android APK (push) Has been cancelled
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-06-04 18:47:19 +02:00
Gauvino
d6c7246cd1 fix: put @main instead of v8 to fix cache problem
Some checks are pending
🤖 Android APK Build / 🏗️ Build Android APK (push) Waiting to run
🤖 iOS IPA Build / 🏗️ Build iOS IPA (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
2025-06-04 13:31:06 +02:00
Gauvain
973d226c49 feat: update bun version (#745) 2025-06-04 12:25:01 +02:00
Gauvain
dd849b532b refactor: fix the ios-build action (#742) 2025-06-04 11:54:01 +02:00
Fredrik Burmester
1a58df27d2 chore: version
Some checks are pending
🤖 Android APK Build / 🏗️ Build Android APK (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
2025-06-03 08:26:23 +02:00
Fredrik Burmester
68b5fe3599 chore
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
2025-06-03 08:20:43 +02:00
Fredrik Burmester
67f73bfa39 fix: format 2025-06-03 08:20:35 +02:00
Fredrik Burmester
5de7cab285 Merge branch 'master' into develop 2025-06-03 08:20:32 +02:00
Fredrik Burmester
67d39c39ea fix: android bug 2025-06-03 08:06:49 +02:00
Gauvain
9d8e227609 fix: remove description of pr in message (#737)
Some checks are pending
🤖 Android APK Build / 🏗️ Build Android APK (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
2025-06-02 16:28:28 +02:00
renovate[bot]
962323a75c chore(deps): update github/codeql-action action to v3.28.18 (#727)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-02 16:18:42 +02:00
Gauvain
fc23201b4f fix: biome check, remove spell-check (#731) 2025-06-02 16:17:34 +02:00
lance chant
f0519ea88d fix: tv home screen navigation (#732) 2025-06-02 15:15:31 +02:00
renovate[bot]
f9f21606ff chore(deps): pin dependencies (#722)
Some checks failed
🤖 Android APK Build / 🏗️ Build Android APK (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
📝 Check Spelling / 🔎 Spelling Check (push) Has been cancelled
📝 Check Spelling / 💬 Report (PR) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-02 14:18:04 +02:00
Gauvain
c4d026f4d8 fix: remove cache bun (#730) 2025-06-02 14:15:46 +02:00
Gauvain
577827303e fix: correct name of dictonnary and use correct version in action (#728) 2025-06-02 14:13:44 +02:00
Gauvain
3e0a1af9fa fix: fix error on pr title for renovate bot (#729) 2025-06-02 14:13:36 +02:00
lance chant
63bc806a06 fix: made tv os compile (#721) 2025-06-02 14:13:13 +02:00
Jaakko Rantamäki
f05496a458 feat: Adaptive icons for iOS 18 and Android (#606) 2025-06-02 14:04:41 +02:00
Gauvain
d3660b45b1 fix: rename merge conflict label (#725) 2025-06-02 13:52:12 +02:00
Sim
1b812ebed5 fix: Recently Added isn't updating correctly. (#686) 2025-06-02 13:21:50 +02:00
Chris
6703299da9 docs: fix typo in README.md (#719) 2025-06-02 13:17:37 +02:00
Kamil Kosek
80d63c0219 feat: remotecontrol (#705) 2025-06-02 13:16:15 +02:00
Nyanmisaka
c2f8145e74 fix: Fixed container name mp4 in transcoding profiles (#696) 2025-06-02 13:14:31 +02:00
Gauvain
ce00aeb5f1 feat(ci/cd): Add Android builds and quality checks (#694) 2025-06-02 13:14:20 +02:00
Fredrik Burmester
5899cc8625 chore: version code
Some checks failed
Handle Stale Issues / stale (push) Has been cancelled
2025-05-30 21:00:02 +02:00
sarendsen
90217bb495 fix: error race conditions 2025-05-29 16:39:46 +02:00
lostb1t
16e88cca8c fix: error race conditions 2025-05-29 16:04:22 +02:00
lostb1t
e8e62061ae fix: remove unwanted detail calls on search (#707) 2025-05-28 14:24:22 +02:00
retardgerman
3adc4d2a21 fix(lang): uk.json 2025-05-19 13:44:19 +02:00
Chris
185524c06c feat(lang): add Klingon and Esperanto localization support (#672) 2025-05-19 12:39:02 +02:00
Simon Eklundh
6a208ee201 fix: improve readme to reduce the questions we tend to receive rather… (#699) 2025-05-18 20:36:08 +02:00
Ahmed Sbai
99938ddf5a feat: add "Are you still watching" modal overlay with configurable options (#663)
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2025-05-18 09:21:50 +02:00
lostb1t
963a54a36c fix: cancel for direct downloads 2025-05-14 21:18:48 +02:00
sarendsen
e939c9b933 Revert "style: horizontal width"
This reverts commit 31f662a582.
2025-05-14 21:07:18 +02:00
lostb1t
2ffd569bba chore: fix build 2025-05-14 19:42:56 +02:00
storm1er
c8ea494d6f fix: downgrade expo-sharing version until expo 53 (#688) 2025-05-14 19:18:46 +02:00
Danylo Kozhushko
577a61a452 fix: Fixed Ukrainian translation filename and also some typos (#682) 2025-05-14 19:09:54 +02:00
retardgerman
a731c4eebd fix: remove Feature that doesn’t exist. 2025-05-12 20:55:37 +02:00
Fredrik Burmester
8a664757b8 fix: android popup crash patch 2025-05-05 11:19:06 +02:00
sarendsen
655a78900d fix: try to enable ios background downloads plugin 2025-05-04 18:38:27 +02:00
sarendsen
87a33af8d1 fix: restore downloads if missing 2025-05-04 18:12:16 +02:00
sarendsen
36b1c48fdd fix: use ts for downloads 2025-05-04 12:50:21 +02:00
lostb1t
0454ba9f29 Update DownloadProvider.tsx 2025-05-04 12:01:51 +02:00
lostb1t
b55ed6349c Update DownloadProvider.tsx 2025-05-04 11:56:47 +02:00
Ryan
0c34add45a fix: update search functionality to set text in search bar on press (#669) 2025-05-04 11:48:53 +02:00
lostb1t
1c1345a3b7 feat: move to custom download handler with background download support (#675) 2025-05-04 11:46:34 +02:00
Chris
9f706a348e chore: Update README.md - Sessions View (#673) 2025-05-03 17:29:51 +02:00
sarendsen
f4750e781d refactor: getstreamurl 2025-05-02 19:02:35 +02:00
lance chant
0b574cc047 fix: dolby vision on supported devices, specifically profile 5 (#660) 2025-05-01 12:11:29 +02:00
Alec Warren
4a816470d1 feat: improve jellyseer item page buttons (#634) 2025-04-29 18:40:43 +02:00
Ryan
0d43b57f55 fix: improve empty state layout in library view (#665) 2025-04-28 18:11:24 +02:00
sarendsen
31f662a582 style: horizontal width 2025-04-21 12:28:12 +02:00
Alex
23e0ec9774 Remove Alamofire (#656) 2025-04-20 00:25:51 +10:00
Alex
d6ac8569a8 Fix/external subtitle support vlc3 (#655) 2025-04-20 00:25:35 +10:00
Fredrik Burmester
205715ae29 chore 2025-03-25 13:13:46 +01:00
Fredrik Burmester
ffbaaa81a8 Merge branch 'develop' 2025-03-19 11:33:51 +01:00
Chris
7201be6f02 Update README.md (#605) 2025-03-14 14:47:09 +01:00
369 changed files with 24371 additions and 10578 deletions

View File

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

185
.github/CONTRIBUTING.md vendored Normal file
View File

@@ -0,0 +1,185 @@
# Contributing to Streamyfin
Thank you for your interest in contributing to the Streamyfin project. This document outlines the guidelines for effective collaboration across the Streamyfin codebase and aims to ensure a smooth, productive experience for all contributors.
---
## Table of Contents
- [AI Assistance Disclosure](#ai-assistance-disclosure)
- [Reporting Issues](#reporting-issues)
- [Reporting Security Vulnerabilities](#reporting-security-vulnerabilities)
- [Requesting Features & Enhancements](#requesting-features--enhancements)
- [Developing the Mobile App](#developing-the-mobile-app)
- [Codebase Overview](#codebase-overview)
- [Setting Up Your Development Environment](#setting-up-your-development-environment)
- [Making Changes](#making-changes)
- [Pull Request Guidelines](#pull-request-guidelines)
- [Release Process](#release-process)
- [Getting Help and Community](#getting-help-and-community)
---
## AI Assistance Disclosure
> [!IMPORTANT]
> If any AI tool was used while contributing to Streamyfin, it must be disclosed in the pull request.
State in your PR whether AI assistance was used and to what extent (for example, *docs only* or *code generation*).
If AI-generated text was used in PR discussions or responses, disclose that as well.
Minor autocomplete or keyword suggestions do not require disclosure.
### Examples
> This PR was written primarily by Claude Code.
> I used Cursor to explore parts of the codebase, but the implementation is fully manual.
Failing to disclose AI usage wastes maintainers time and complicates review efforts.
AI-assisted contributions are welcome, but contributors remain fully responsible for the code they submit.
Always disclose AI involvement to maintain transparency and respect for maintainers time.
## Reporting Issues
Streamyfin uses GitHub issues to track bugs and improvements. Before opening a new issue:
- Search existing issues for duplicates.
- Provide clear, reproducible steps to demonstrate bugs.
- Include device info, OS version, Streamyfin version, and any relevant logs.
- Apply the `bug` label to the issue for easier triage; no title prefix needed.
If you're unsure about how to report an issue or need help, reach out to the community via our chat links.
### Reporting Security Vulnerabilities
Please do not file public GitHub issues for security vulnerabilities.
Report security concerns via GitHub Security Advisories (Repository → Security → Report a vulnerability). Provide steps to reproduce, affected versions, and mitigation ideas if available. Well acknowledge receipt and coordinate a fix before public disclosure.
If Security Advisories are unavailable for you, contact the maintainers via the email listed in SECURITY.md.---
## Requesting Features & Enhancements
Please submit feature and enhancement requests as GitHub issues labeled `enhancement`.
When creating a new feature request:
- Check if the idea or similar request exists.
- Use reactions like 👍 to support existing requests.
- Clearly describe the use case and potential benefits.
- Include screenshots when relevant.
---
## Developing Streamyfin
### Codebase Overview
Streamyfin is built primarily using Expo and React Native to support both iOS and Android platforms within a single repository. The app communicates directly with Jellyfin backend servers for media streaming.
### Setting Up Your Development Environment
1. Fork the Streamyfin repository on GitHub. If prompted with “Copy the main branch only,” uncheck it so all branches are copied.
2. Clone your fork:
```
git clone git@github.com:yourusername/streamyfin.git
# or
git clone https://github.com/yourusername/streamyfin.git
cd streamyfin
```
3. Initialize submodules and install dependencies:
```
bun run submodule-reload
bun install
```
4. Start the development server locally (with Expo):
```
bun ios / bun android
```
> Optionally, to run directly on a device or emulator:
>
> ```
> # For iOS (requires macOS and Xcode):
> bun run ios
> # For Android (requires Android Studio or Android Debug Bridge (ADB) tool, plus an emulator or physical device):
> bun run android
> ```
5. Use the Expo app on your mobile device or emulator to run and debug Streamyfin.
### Making Changes
1. Stay up to date by syncing with upstream:
```bash
# Add the upstream remote only once (skip if already added)
git remote add upstream https://github.com/streamyfin/streamyfin.git
# Fetch latest changes from upstream
git fetch upstream
# Rebase your current branch onto the upstream default branch (replace 'develop' if you are working from another upstream branch)
git rebase upstream/develop
```
2. Create a descriptive feature or bugfix branch:
```
git checkout -b feat/feature-name
```
3. Commit changes with clear, concise messages using imperative mood.
4. Push changes to your fork:
```
git push --set-upstream origin feat/feature-name
```
---
## Pull Request Guidelines
When opening a PR:
- Title should clearly summarize the change.
- Reference any related issue(s) using keywords like `closes #123`.
- Follow our [Conventional Commits](https://www.conventionalcommits.org/) style, e.g., `feat: add new playback controls`.
- Provide a detailed description in the PR body, explaining what, why, and any impacts.
- Include screenshots or recordings if UI changes are involved.
- Ensure CI checks are green (lint, type-check, build).
- Confirm that the branch is **up to date with `main`** before submission.
- Mention if AI-generated code or content was used (see [AI Assistance Disclosure](#ai-assistance-disclosure)).
- Do not include secrets, tokens, or production credentials. Redact sensitive data in logs and screenshots.
- Keep PRs focused; avoid bundling unrelated changes together.
PRs require review and approval by maintainers before merging.---
## Release Process
- Streamyfin follows semantic versioning (`MAJOR.MINOR.PATCH`).
- Releases are made periodically after testing and QA cycles.
- Tag each release and publish a GitHub Release with a changelog.
- Consider automating versioning and changelogs (e.g., Changesets or semantic-release).
- Release announcements are posted on our repository and community channels.
- Contributions accepted through PRs will be included in upcoming releases according to readiness.
---
## Getting Help and Community
- Join our community chat channels on [Discord](https://discord.streamyfin.app) for questions and support.
- Use GitHub discussions or open issues to get assistance or report problems.
---
Thank you for contributing to make Streamyfin better for everyone!

View File

@@ -1,63 +0,0 @@
name: Bug report
description: Create a report to help us improve
title: "[Bug]: "
labels:
- ["❌ bug"]
projects:
- ["streamyfin/3"]
body:
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Reproduction steps
description: "How do you trigger this bug? Please walk us through it step by step."
placeholder: |
1.
2.
3.
...
validations:
required: true
- type: textarea
id: device
attributes:
label: Which device and operating system are you using?
description: e.g. iPhone 15, iOS 18.1.1
validations:
required: true
- type: dropdown
id: version
attributes:
label: Version
description: What version of Streamyfin are you running?
options:
- 0.28.0
- 0.27.0
- 0.26.1
- 0.26.0
- 0.25.0
- 0.24.0
- 0.23.0
- 0.22.0
- 0.21.0
- older
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: If applicable, please add screenshots to help explain your problem.
You can drag and drop images here or paste them directly into the comment box.

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 🤔 Questions and Help
url: https://discord.streamyfin.app
about: Support questions? Please use our Streamyfin Discord community for help.
- name: 🛡️ Security vulnerability report
url: https://github.com/streamyfin/streamyfin/security/policy
about: Please report security vulnerabilities privately via our Security Policy for responsible disclosure.

View File

@@ -1,15 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: '✨ enhancement'
assignees: ''
projects:
- streamyfin/3
---
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -0,0 +1,87 @@
name: "🚀 Feature Request"
description: Suggest an idea for this project
title: "[REQUEST]: "
labels: ["✨ enhancement"]
projects:
- "streamyfin/3"
body:
- type: markdown
id: introduction
attributes:
value: |
Thanks for taking the time to fill out this feature request!
Please keep in mind that Streamyfin is a [free and open-source](https://github.com/streamyfin/streamyfin) project, made up entirely and exclusively of **volunteers** who donate their free time to the project.
- type: checkboxes
id: before-posting
attributes:
label: "This feature request respects the following points:"
description: All conditions are **required**. Failure to comply with any of these conditions may cause your feature request to be closed without comment.
options:
- label: This is a **feature request**, not a question or a configuration issue; Please visit our community channels first to troubleshoot with volunteers, before creating a report. The links can be found in our [Discord](https://discord.streamyfin.app).
required: true
- label: This issue is **not** already reported on [GitHub](https://github.com/streamyfin/streamyfin/issues?q=is%3Aissue+is%3Aopen+label%3A"✨%20enhancement") _(I've searched it)_.
required: true
- label: I'm using an up-to-date version of Streamyfin. We generally do not support older versions. If possible, please update to the latest version before opening an issue.
required: true
- label: I agree to follow Streamyfin's [Contribution Guidelines](https://github.com/streamyfin/streamyfin/blob/develop/.github/CONTRIBUTING.md).
required: true
- label: This report addresses only a single feature request; If you have multiple feature requests, kindly create separate reports for each one.
required: true
- type: markdown
id: preliminary-information
attributes:
value: |
### General preliminary information
Please keep the following in mind when creating this issue:
1. Fill in as much of the template as possible.
2. Provide as much detail as possible. Do not assume other people to know what is going on.
3. Keep everything readable and structured. Nobody enjoys reading poorly written reports that are difficult to understand.
4. Keep an eye on your report as long as it is open, your involvement might be requested at a later moment.
5. Keep the title short and descriptive. The title is not the place to write down a full description of the issue.
6. When choosing to omit information in a field, write 'n/a' to explicitly indicate the deliberate absence of data. Avoid leaving the field blank or empty.
- type: textarea
id: feature-description
attributes:
label: Description of the feature request
description: Please provide a detailed description of the feature request, in a readable and comprehensible way.
placeholder: |
I would like to see a new feature that allows users to [...]
validations:
required: true
- type: textarea
id: related-problems
attributes:
label: Is your feature request related to a problem?
description: A clear and concise description of what the problem is.
placeholder: |
I'm always frustrated when [...]
- type: textarea
id: solution-description
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
placeholder: |
I would like to see [...]
validations:
required: true
- type: textarea
id: alternative-description
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
placeholder: |
I've considered [...]
- type: textarea
id: screenshots
attributes:
label: Relevant screenshots or videos
description: Attach relevant screenshots or videos related to this report (drag-and-drop or paste into the editor).
- type: textarea
id: additional-information
attributes:
label: Additional information
description: Any additional information that might be useful to this feature request.

119
.github/ISSUE_TEMPLATE/issue_report.yml vendored Normal file
View File

@@ -0,0 +1,119 @@
name: "🐛 Bug Report"
description: Create a report to help us improve
title: "[Bug]: "
labels:
- "🐛 bug"
projects:
- "streamyfin/3"
body:
- type: markdown
id: introduction
attributes:
value: |
Thanks for taking the time to fill out this bug report!
Please keep in mind that Streamyfin is a [free and open-source](https://github.com/streamyfin/streamyfin) project maintained entirely by **volunteers** who donate their free time.
- type: checkboxes
id: before-posting
attributes:
label: "This issue respects the following points:"
description: All conditions are **required**. Failure to comply with any of these conditions may cause your issue to be closed without comment.
options:
- label: This is a **bug**, not a question or configuration issue; please consult our community channels before filing a report. [Discord](https://discord.streamyfin.app).
required: true
- label: This issue is **not** already reported on [GitHub](https://github.com/streamyfin/streamyfin/issues?q=is%3Aissue+is%3Aopen+label%3A"🐛%20bug") *(I've searched it)*.
required: true
- label: I'm using an up-to-date version of Streamyfin. We generally do not support older versions. If possible, please update to the latest version before opening an issue.
required: true
- label: I agree to follow Streamyfin's [Contribution rules](https://github.com/streamyfin/streamyfin/blob/develop/.github/CONTRIBUTING.md).
required: true
- label: This report addresses only a single issue; If you encounter multiple issues, please create separate reports for each one.
required: true
- type: textarea
id: what-happened
attributes:
label: What happened?
description: A clear and concise description of what the bug is.
placeholder: Describe what happened in detail.
validations:
required: true
- type: textarea
id: what-expected
attributes:
label: What did you expect to happen?
description: Tell us what you expected to happen instead.
placeholder: Describe the expected behavior clearly.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Reproduction steps
description: "How do you trigger this bug? Please walk us through it step by step."
placeholder: |
1. Open Streamyfin app
2. Navigate to [specific section]
3. Tap on [specific item]
4. See error
validations:
required: true
- type: textarea
id: device
attributes:
label: Which device and operating system are you using?
description: Please provide your device model and OS version
placeholder: e.g. iPhone 15 Pro, iOS 18.1.1 or Samsung Galaxy S24, Android 14
validations:
required: true
- type: dropdown
id: version
attributes:
label: Streamyfin Version
description: What version of Streamyfin are you running?
options:
- 0.30.2
- 0.29.0
- 0.28.0
- 0.27.0
- 0.26.1
- 0.26.0
- 0.25.0
- older
- TestFlight/Development build
validations:
required: true
- type: textarea
id: jellyfin-info
attributes:
label: Jellyfin Server Information
description: Please provide details about your Jellyfin server
placeholder: |
- Jellyfin Server Version: e.g. 10.10.7
- Server OS: e.g. Ubuntu 22.04, Windows 11, Docker
- Connection: e.g. Local network, Remote via domain, VPN
- type: textarea
id: screenshots
attributes:
label: Screenshots or Videos
description: If applicable, please add screenshots or videos to help explain your problem. You can drag and drop images here or paste them directly into the comment box.
- type: textarea
id: logs
attributes:
label: Relevant logs (if available)
description: If you have access to app logs or crash reports, please include them here. **Remember to remove any personal information like server URLs or usernames.**
render: shell
- type: textarea
id: additional-info
attributes:
label: Additional information
description: Any additional context that might help us understand and reproduce the issue.

96
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,96 @@
# Copilot Instructions for Streamyfin
## Project Overview
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,
and provides seamless media streaming with offline capabilities and Chromecast support.
## Main Technologies
- **Runtime**: Bun (JavaScript/TypeScript execution)
- **Framework**: React Native (Expo)
- **Language**: TypeScript (strict mode)
- **State Management**: Jotai (global state) + React Query (server state)
- **API SDK**: Jellyfin SDK (TypeScript)
- **Navigation**: Expo Router (file-based routing)
- **Code Quality**: BiomeJS (formatting/linting)
- **Build Platform**: EAS (Expo Application Services)
- **CI/CD**: GitHub Actions with Bun
## Package Management
**CRITICAL: ALWAYS use `bun` for all package management operations**
- **NEVER use `npm`, `yarn` or `npx` commands**
- Use `bun install` instead of `npm install` or `yarn install`
- Use `bun add <package>` instead of `npm install <package>`
- Use `bun remove <package>` instead of `npm uninstall <package>`
- Use `bun run <script>` instead of `npm run <script>`
- Use `bunx <command>` instead of `npx <command>`
- For Expo: use `bunx create-expo-app` or `bunx @expo/cli`
## Code Structure
- `app/` Main application code (screens, navigation, etc.)
- `components/` Reusable UI components
- `providers/` Context and API providers (e.g., JellyfinProvider.tsx)
- `utils/` Utility functions and Jotai atoms
- `assets/` Images and static assets
- `scripts/` Automation scripts (Node.js, Bash)
- `plugins/` Expo/Metro plugins
## Coding Standards
- Use TypeScript for ALL files (no .js files)
- Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks
- Use Jotai atoms for global state management
- Use React Query for server state and caching
- Follow BiomeJS formatting and linting rules
- Use `const` over `let`, avoid `var` entirely
- Implement proper error boundaries
- Use React.memo() for performance optimization
- Handle both mobile and TV navigation patterns
## API Integration
- Use Jellyfin SDK for all server interactions
- Access authenticated APIs via `apiAtom` and `userAtom` from JellyfinProvider
- Implement proper loading states and error handling
- Use React Query for caching and background updates
- Handle offline scenarios gracefully
## Performance Optimization
- Leverage Bun's superior runtime performance
- Optimize FlatList components with proper props
- Use lazy loading for non-critical components
- Implement proper image caching strategies
- Monitor bundle size and use tree-shaking effectively
## Testing
- Use Bun's built-in test runner when possible
- Test files: `*.test.ts` or `*.test.tsx`
- Run tests with: `bun test`
- Mock external APIs in tests
- Focus on testing business logic and custom hooks
## Commit Messages
Use Conventional Commits (https://www.conventionalcommits.org/):
Exemples:
- `feat(player): add Chromecast support`
- `fix(auth): handle expired JWT tokens`
- `chore(deps): update Jellyfin SDK`
## Special Instructions
- Prioritize cross-platform compatibility (mobile + TV)
- Ensure accessibility for TV remote navigation
- Use existing atoms, hooks, and utilities before creating new ones
- Maintain compatibility with Expo and EAS workflows
- Always verify Bun compatibility when suggesting new dependencies
**Copilot: Please use these instructions to provide context-aware suggestions and code completions for this repository.**

12
.github/crowdin.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
"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"
}
]

91
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,91 @@
<!--
Pull Request Template for Streamyfin
====================================
Use this template to help reviewers understand the purpose of your PR
and to ensure all necessary checks are completed before merging.
-->
# 📦 Pull Request
## 🔖 Summary
<!--
A concise description of the changes introduced by this PR.
Example:
“Add real-time currency conversion widget to dashboard.”
-->
## 🏷️ Ticket / Issue
<!--
Link to the related ticket, issue or user story.
You can also indicate if this PR supersedes a previous one.
Example:
- Closes #123
- Fixes STREAMYFIN-456
- Resolves #789
- Supersedes #120
- Related: #130
-->
## 🛠️ Whats Changed
<!-- Use a Conventional Commit in the PR title, e.g., `feat(auth): add MFA`.
If this PR introduces a breaking change, include a `BREAKING CHANGE:` block in the description.
Spec: https://www.conventionalcommits.org/ -->
- Type: feat | fix | docs | style | refactor | perf | test | chore | build | ci | revert
- Scope (optional): e.g., auth, billing, mobile
- Short summary: what changed and why (12 lines)
-->
## 📋 Details
<!--
Provide more context or background. Explain any non-obvious decisions.
Include screenshots or GIFs for UI changes if applicable.
-->
### ⚠️ Breaking Changes
<!-- List any breaking API/contract changes and migration guidance. If none, write “None”. -->
### 🔐 Security & Privacy Impact
<!-- Data touched, new permissions/scopes, PII, secrets, threat considerations. If none, write “None”. -->
### ⚡ Performance Impact
<!-- Hot paths, memory/CPU/latency implications, benchmarks if available. -->
### 🖼️ Screenshots / GIFs (if UI)
<!-- Before/After, dark mode, responsive states. -->
## ✅ Checklist
<!--
Review and check off items as you complete them.
-->
- [ ] Ive read the [contribution guidelines](CONTRIBUTING.md)
- [ ] Code follows project style and passes lint/format (`npm|pnpm|yarn|bun` scripts)
- [ ] Type checks pass (tsc/biome/etc.)
- [ ] Docs updated (README/ADR/usage/API)
- [ ] No secrets/credentials included; env vars documented
- [ ] Release notes/CHANGELOG entry added (if applicable)
- [ ] Verified locally that changes behave as expected
## 🔍 Testing Instructions
<!--
Describe how reviewers can test your changes.
Example:
1. `git fetch origin pull/<PR_ID>/head:branchname && git checkout branchname`
2. Install deps: `npm|pnpm|yarn|bun install`
3. Start service/app: `npm|pnpm|yarn|bun run [target]` (e.g., `npm run ios` or `bun run android:tv`)
4. Run tests: `npm|pnpm|yarn|bun test`
5. Verification steps:
- [ ] Expected UI/endpoint behavior
- [ ] Logs show no errors
- [ ] Edge cases covered (list)
-->
## ⚙️ Deployment Notes
<!--
Describe any deployment considerations such as config, environment vars, or native builds.
-->
## 📝 Additional Notes
<!--
Any other information or references related to this PR.
-->

46
.github/renovate.json vendored Normal file
View File

@@ -0,0 +1,46 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"description": "Renovate configuration for Streamyfin - Expo React Native Jellyfin client",
"extends": [
"config:best-practices",
":dependencyDashboard",
":enableVulnerabilityAlertsWithLabel(security)",
":semanticCommits",
":timezone(Etc/UTC)",
"group:testNonMajor",
"group:monorepos",
"helpers:pinGitHubActionDigests",
"customManagers:biomeVersions",
":automergeBranch",
":automergeRequireAllStatusChecks"
],
"addLabels": ["dependencies"],
"rebaseWhen": "conflicted",
"ignorePaths": ["**/node_modules/**"],
"ignoreUnstable": true,
"minimumReleaseAge": "3 days",
"schedule": ["before 6am on Sunday"],
"branchPrefix": "renovate/",
"commitMessagePrefix": "chore(deps):",
"osvVulnerabilityAlerts": true,
"configMigration": true,
"separateMinorPatch": true,
"lockFileMaintenance": {
"vulnerabilityAlerts": {
"enabled": true,
"addLabels": ["security", "vulnerability"],
"assigneesFromCodeOwners": true,
"commitMessageSuffix": " [SECURITY]"
},
"packageRules": [
{
"description": "Group minor and patch GitHub Action updates into a single PR",
"matchManagers": ["github-actions"],
"groupName": "CI dependencies",
"groupSlug": "ci-deps",
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
"automerge": true
}
]
}
}

478
.github/workflows/artifact-comment.yml vendored Normal file
View File

@@ -0,0 +1,478 @@
name: 📝 Artifact Comment on PR
concurrency:
group: artifact-comment-${{ github.event.workflow_run.head_sha || github.sha }}
cancel-in-progress: true
on:
workflow_dispatch: # Allow manual testing
pull_request: # Show in PR checks and provide status updates
types: [opened, synchronize, reopened]
workflow_run: # Triggered when build workflows complete
workflows:
- "🏗️ Build Apps"
types:
- completed
jobs:
comment-artifacts:
if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request')
name: 📦 Post Build Artifacts
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
actions: read
steps:
- name: 🔍 Get PR and Artifacts
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
// Check if we're running from a fork (more precise detection)
const targetRepo = context.repo.owner + '/' + context.repo.repo;
const prHeadRepo = context.payload.pull_request?.head?.repo?.full_name;
const workflowHeadRepo = context.payload.workflow_run?.head_repository?.full_name;
// For debugging
console.log('🔍 Repository detection:');
console.log('- Target repository:', targetRepo);
console.log('- PR head repository:', prHeadRepo || 'N/A');
console.log('- Workflow head repository:', workflowHeadRepo || 'N/A');
console.log('- Event name:', context.eventName);
// Only skip if it's actually a different repository (fork)
const isFromFork = prHeadRepo && prHeadRepo !== targetRepo;
const workflowFromFork = workflowHeadRepo && workflowHeadRepo !== targetRepo;
if (isFromFork || workflowFromFork) {
console.log('🚫 Workflow running from fork - skipping comment creation to avoid permission errors');
console.log('Fork repository:', prHeadRepo || workflowHeadRepo);
console.log('Target repository:', targetRepo);
return;
}
console.log('✅ Same repository - proceeding with comment creation'); // Handle repository_dispatch, pull_request, and manual dispatch events
let pr;
let targetCommitSha;
if (context.eventName === 'workflow_run') {
// Find PR associated with this workflow run commit
console.log('Workflow run event:', context.payload.workflow_run.name);
const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha
});
if (pullRequests.length === 0) {
console.log('No pull request found for commit:', context.payload.workflow_run.head_sha);
return;
}
pr = pullRequests[0];
targetCommitSha = context.payload.workflow_run.head_sha;
} else if (context.eventName === 'pull_request') {
// Direct PR event
pr = context.payload.pull_request;
targetCommitSha = pr.head.sha;
} else if (context.eventName === 'workflow_dispatch') {
// For manual testing, try to find PR for current branch/commit
console.log('Manual workflow dispatch triggered');
// First, try to find PRs associated with current commit
try {
const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.sha
});
if (pullRequests.length > 0) {
pr = pullRequests[0];
targetCommitSha = pr.head.sha;
console.log(`Found PR #${pr.number} for commit ${context.sha.substring(0, 7)}`);
} else {
// Fallback: get latest open PR
const { data: openPRs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
sort: 'updated',
direction: 'desc',
per_page: 1
});
if (openPRs.length > 0) {
pr = openPRs[0];
targetCommitSha = pr.head.sha;
console.log(`Using latest open PR #${pr.number} for manual testing`);
} else {
console.log('No open PRs found for manual testing');
return;
}
}
} catch (error) {
console.log('Error finding PR for manual testing:', error.message);
return;
}
} else {
console.log('Unsupported event type:', context.eventName);
return;
}
console.log(`Processing PR #${pr.number} for commit ${targetCommitSha.substring(0, 7)}`);
// Get all recent workflow runs for this PR to collect artifacts from multiple builds
const { data: workflowRuns } = await github.rest.actions.listWorkflowRunsForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
head_sha: targetCommitSha,
per_page: 30
});
// Filter for build workflows only, include active runs even if marked as cancelled
const buildRuns = workflowRuns.workflow_runs
.filter(run =>
(run.name.includes('Build Apps') ||
run.name.includes('Android APK Build') ||
run.name.includes('iOS IPA Build'))
)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
console.log(`Found ${buildRuns.length} non-cancelled build workflow runs for this commit`);
// Log current status of each build for debugging
buildRuns.forEach(run => {
console.log(`- ${run.name}: ${run.status} (${run.conclusion || 'no conclusion yet'}) - Created: ${run.created_at}`);
});
// Collect artifacts and statuses from builds - prioritize active runs over completed ones
let allArtifacts = [];
let buildStatuses = {};
// Get the most relevant run for each workflow type (prioritize active over cancelled)
const findBestRun = (nameFilter) => {
const matchingRuns = buildRuns.filter(run => run.name.includes(nameFilter));
// First try to find an in-progress run
const inProgressRun = matchingRuns.find(run => run.status === 'in_progress');
if (inProgressRun) return inProgressRun;
// Then try to find a queued run
const queuedRun = matchingRuns.find(run => run.status === 'queued');
if (queuedRun) return queuedRun;
// Check if the workflow is completed but has non-cancelled jobs
const completedRuns = matchingRuns.filter(run => run.status === 'completed');
for (const run of completedRuns) {
// We'll check individual jobs later to see if they're actually running
if (run.conclusion !== 'cancelled') {
return run;
}
}
// Finally fall back to most recent run (even if cancelled at workflow level)
return matchingRuns[0]; // Already sorted by most recent first
};
const latestAppsRun = findBestRun('Build Apps');
const latestAndroidRun = findBestRun('Android APK Build');
const latestIOSRun = findBestRun('iOS IPA Build');
// For the consolidated workflow, get individual job statuses
if (latestAppsRun) {
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
try {
// Get all jobs for this workflow run
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: latestAppsRun.id
});
console.log(`Found ${jobs.jobs.length} jobs in workflow run`);
jobs.jobs.forEach(job => {
console.log(`- Job: ${job.name} | Status: ${job.status} | Conclusion: ${job.conclusion || 'none'}`);
});
// Check if we have any actually running jobs (not cancelled)
const activeJobs = jobs.jobs.filter(job =>
job.status === 'in_progress' ||
job.status === 'queued' ||
(job.status === 'completed' && job.conclusion !== 'cancelled')
);
console.log(`Found ${activeJobs.length} active (non-cancelled) jobs out of ${jobs.jobs.length} total jobs`);
// If no jobs are actually running, skip this workflow
if (activeJobs.length === 0 && latestAppsRun.conclusion === 'cancelled') {
console.log('All jobs are cancelled, skipping this workflow run');
return; // Exit early
}
// Map job names to our build targets
const jobMappings = {
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
'iOS Phone': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone']
};
// Create individual status for each job
for (const [platform, jobNames] of Object.entries(jobMappings)) {
const job = jobs.jobs.find(j =>
jobNames.some(name => j.name.includes(name) || j.name === name)
);
if (job) {
buildStatuses[platform] = {
name: job.name,
status: job.status,
conclusion: job.conclusion,
url: job.html_url,
runId: latestAppsRun.id,
created_at: job.started_at || latestAppsRun.created_at
};
console.log(`Mapped ${platform} to job: ${job.name} (${job.status}/${job.conclusion || 'none'})`);
} else {
console.log(`No job found for ${platform}, using workflow status as fallback`);
buildStatuses[platform] = {
name: latestAppsRun.name,
status: latestAppsRun.status,
conclusion: latestAppsRun.conclusion,
url: latestAppsRun.html_url,
runId: latestAppsRun.id,
created_at: latestAppsRun.created_at
};
}
}
} catch (error) {
console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message);
// Fallback to workflow-level status
buildStatuses['Android Phone'] = buildStatuses['Android TV'] = buildStatuses['iOS Phone'] = {
name: latestAppsRun.name,
status: latestAppsRun.status,
conclusion: latestAppsRun.conclusion,
url: latestAppsRun.html_url,
runId: latestAppsRun.id,
created_at: latestAppsRun.created_at
};
}
// Collect artifacts if any job has completed successfully
if (latestAppsRun.status === 'completed' ||
Object.values(buildStatuses).some(status => status.conclusion === 'success')) {
try {
const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: latestAppsRun.id
});
allArtifacts.push(...artifacts.artifacts);
} catch (error) {
console.log(`Failed to get apps artifacts for run ${latestAppsRun.id}:`, error.message);
}
}
} else {
// Fallback to separate workflows (for backward compatibility)
if (latestAndroidRun) {
buildStatuses['Android'] = {
name: latestAndroidRun.name,
status: latestAndroidRun.status,
conclusion: latestAndroidRun.conclusion,
url: latestAndroidRun.html_url,
runId: latestAndroidRun.id,
created_at: latestAndroidRun.created_at
};
if (latestAndroidRun.conclusion === 'success') {
try {
const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: latestAndroidRun.id
});
allArtifacts.push(...artifacts.artifacts);
} catch (error) {
console.log(`Failed to get Android artifacts for run ${latestAndroidRun.id}:`, error.message);
}
}
}
if (latestIOSRun) {
buildStatuses['iOS'] = {
name: latestIOSRun.name,
status: latestIOSRun.status,
conclusion: latestIOSRun.conclusion,
url: latestIOSRun.html_url,
runId: latestIOSRun.id,
created_at: latestIOSRun.created_at
};
if (latestIOSRun.conclusion === 'success') {
try {
const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: latestIOSRun.id
});
allArtifacts.push(...artifacts.artifacts);
} catch (error) {
console.log(`Failed to get iOS artifacts for run ${latestIOSRun.id}:`, error.message);
}
}
}
}
console.log(`Collected ${allArtifacts.length} total artifacts from all builds`);
// Debug: Show which workflow we're using and its status
if (latestAppsRun) {
console.log(`Using consolidated workflow: ${latestAppsRun.name} (${latestAppsRun.status}/${latestAppsRun.conclusion})`);
} else {
console.log(`Using separate workflows - Android: ${latestAndroidRun?.name || 'none'}, iOS: ${latestIOSRun?.name || 'none'}`);
}
// Debug: List all artifacts found
allArtifacts.forEach(artifact => {
console.log(`- Artifact: ${artifact.name} (from run ${artifact.workflow_run.id})`);
});
// Build comment body with progressive status for individual builds
let commentBody = `## 🔧 Build Status for PR #${pr.number}\n\n`;
commentBody += `🔗 **Commit**: [\`${targetCommitSha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${targetCommitSha})\n\n`; // Progressive build status and downloads table
commentBody += `### 📦 Build Artifacts\n\n`;
commentBody += `| Platform | Device | Status | Download |\n`;
commentBody += `|----------|--------|--------|---------|\n`;
// Process each expected build target individually
const buildTargets = [
{ name: 'Android Phone', platform: '🤖', device: '📱', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
{ name: 'Android TV', platform: '🤖', device: '📺', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
{ name: 'iOS Phone', platform: '🍎', device: '📱', statusKey: 'iOS Phone', artifactPattern: /ios.*phone/i },
{ name: 'iOS TV', platform: '🍎', device: '📺', statusKey: 'iOS TV', artifactPattern: /ios.*tv/i }
];
for (const target of buildTargets) {
// Find matching job status directly
const matchingStatus = buildStatuses[target.statusKey];
// Find matching artifact
const matchingArtifact = allArtifacts.find(artifact =>
target.artifactPattern.test(artifact.name)
);
let status = '⏳ Pending';
let downloadLink = '*Waiting for build...*';
// Special case for iOS TV - show as disabled
if (target.name === 'iOS TV') {
status = '💤 Disabled';
downloadLink = '*Disabled for now*';
} else if (matchingStatus) {
if (matchingStatus.conclusion === 'success' && matchingArtifact) {
status = '✅ Complete';
const directLink = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/artifacts/${matchingArtifact.id}`;
const fileType = target.name.includes('Android') ? 'APK' : 'IPA';
downloadLink = `[📥 Download ${fileType}](${directLink})`;
} else if (matchingStatus.conclusion === 'failure') {
status = `❌ [Failed](${matchingStatus.url})`;
downloadLink = '*Build failed*';
} else if (matchingStatus.conclusion === 'cancelled') {
status = `⚪ [Cancelled](${matchingStatus.url})`;
downloadLink = '*Build cancelled*';
} else if (matchingStatus.status === 'in_progress') {
status = `🔄 [Building...](${matchingStatus.url})`;
downloadLink = '*Build in progress...*';
} else if (matchingStatus.status === 'queued') {
status = `⏳ [Queued](${matchingStatus.url})`;
downloadLink = '*Waiting to start...*';
} else if (matchingStatus.status === 'completed' && !matchingStatus.conclusion) {
// Workflow completed but conclusion not yet available (rare edge case)
status = `🔄 [Finishing...](${matchingStatus.url})`;
downloadLink = '*Finalizing build...*';
} else if (matchingStatus.status === 'completed' && matchingStatus.conclusion === 'success' && !matchingArtifact) {
// Build succeeded but artifacts not yet available
status = `⏳ [Processing artifacts...](${matchingStatus.url})`;
downloadLink = '*Preparing download...*';
} else {
// Fallback for any unexpected states
status = `❓ [${matchingStatus.status}/${matchingStatus.conclusion || 'pending'}](${matchingStatus.url})`;
downloadLink = `*Status: ${matchingStatus.status}, Conclusion: ${matchingStatus.conclusion || 'pending'}*`;
}
}
commentBody += `| ${target.platform} ${target.name.split(' ')[0]} | ${target.device} ${target.name.split(' ')[1]} | ${status} | ${downloadLink} |\n`;
}
commentBody += `\n`;
// Show installation instructions if we have any artifacts
if (allArtifacts.length > 0) {
commentBody += `### 🔧 Installation Instructions\n\n`;
commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\n`;
commentBody += `- **iOS IPA**: Install using [AltStore](https://altstore.io/), [Sideloadly](https://sideloadly.io/), or Xcode\n\n`;
commentBody += `> ⚠️ **Note**: Artifacts expire in 7 days from build date\n\n`;
} else {
commentBody += `⏳ **Builds are starting up...** This comment will update automatically as each build completes.\n\n`;
}
commentBody += `<sub>*Auto-generated by [GitHub Actions](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})*</sub>`;
commentBody += `\n<!-- streamyfin-artifact-comment -->`;
// Try to find existing bot comment to update (with permission check)
try {
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number
});
const botComment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('<!-- streamyfin-artifact-comment -->')
);
if (botComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
console.log(`✅ Updated comment ${botComment.id} on PR #${pr.number}`);
} else {
// Create new comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: commentBody
});
console.log(`✅ Created new comment on PR #${pr.number}`);
}
} catch (error) {
if (error.status === 403) {
console.log('🚫 Permission denied - likely running from a fork. Skipping comment creation.');
console.log('Error details:', error.message);
// Log the build status instead of commenting
console.log('📊 Build Status Summary:');
for (const target of buildTargets) {
const matchingStatus = buildStatuses[target.statusKey];
if (matchingStatus) {
console.log(`- ${target.name}: ${matchingStatus.status}/${matchingStatus.conclusion || 'none'}`);
}
}
} else {
// Re-throw other errors
throw error;
}
}

280
.github/workflows/build-apps.yml vendored Normal file
View File

@@ -0,0 +1,280 @@
name: 🏗️ Build Apps
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
workflow_dispatch:
pull_request:
branches: [develop, master]
push:
branches: [develop, master]
jobs:
build-android-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: ubuntu-24.04
name: 🤖 Build Android APK (Phone)
permissions:
contents: read
steps:
- name: 📥 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
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@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-develop
${{ runner.os }}-bun-develop
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 💾 Cache Gradle global
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: 🛠️ Generate project files
run: bun run prebuild
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-android-gradle-develop
- name: 🚀 Build APK
env:
EXPO_TV: 0
run: bun run build:android:local
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
path: |
android/app/build/outputs/apk/release/*.apk
retention-days: 7
build-android-tv:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: ubuntu-24.04
name: 🤖 Build Android APK (TV)
permissions:
contents: read
steps:
- name: 📥 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
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@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-develop
${{ runner.os }}-bun-develop
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 💾 Cache Gradle global
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: 🛠️ Generate project files
run: bun run prebuild:tv
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-android-gradle-develop
- name: 🚀 Build APK
env:
EXPO_TV: 1
run: bun run build:android:local
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
path: |
android/app/build/outputs/apk/release/*.apk
retention-days: 7
build-ios-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
runs-on: macos-15
name: 🍎 Build iOS IPA (Phone)
permissions:
contents: read
steps:
- name: 📥 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
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@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
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 EAS
uses: expo/expo-github-action@main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: ⚙️ Ensure iOS SDKs installed
run: xcodebuild -downloadPlatform iOS
- name: 🚀 Build iOS app
env:
EXPO_TV: 0
run: eas build -p ios --local --non-interactive
- 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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
path: build-*.ipa
retention-days: 7
# Disabled for now - uncomment when ready to build iOS TV
# build-ios-tv:
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
# runs-on: macos-15
# name: 🍎 Build iOS IPA (TV)
# permissions:
# contents: read
#
# steps:
# - name: 📥 Checkout code
# uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
# 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@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
# with:
# bun-version: latest
#
# - name: 💾 Cache Bun dependencies
# uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
# 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:tv
#
# - name: 🏗️ Setup EAS
# uses: expo/expo-github-action@main
# with:
# eas-version: latest
# token: ${{ secrets.EXPO_TOKEN }}
# eas-cache: true
#
# - name: ⚙️ Ensure tvOS SDKs installed
# run: xcodebuild -downloadPlatform tvOS
#
# - name: 🚀 Build iOS app
# env:
# EXPO_TV: 1
# run: eas build -p ios --local --non-interactive
#
# - 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
# with:
# name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
# path: build-*.ipa
# retention-days: 7

View File

@@ -1,49 +0,0 @@
name: Automatic Build and Deploy
on:
workflow_dispatch:
push:
branches:
- main
jobs:
build:
runs-on: macos-15
name: Build IOS
steps:
- uses: actions/checkout@v2
name: Check out repository
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: |
bun i && bun run submodule-reload
npx expo prebuild
- uses: sparkfabrik/ios-build-action@v2.3.0
with:
upload-to-testflight: false
increment-build-number: false
build-pods: true
pods-path: "ios/Podfile"
configuration: Release
# Change later to app-store if wanted
export-method: appstore
#export-method: ad-hoc
workspace-path: "ios/Streamyfin.xcodeproj/project.xcworkspace/"
project-path: "ios/Streamyfin.xcodeproj"
scheme: Streamyfin
apple-key-id: ${{ secrets.APPLE_KEY_ID }}
apple-key-issuer-id: ${{ secrets.APPLE_KEY_ISSUER_ID }}
apple-key-content: ${{ secrets.APPLE_KEY_CONTENT }}
team-id: ${{ secrets.TEAM_ID }}
team-name: ${{ secrets.TEAM_NAME }}
#match-password: ${{ secrets.MATCH_PASSWORD }}
#match-git-url: ${{ secrets.MATCH_GIT_URL }}
#match-git-basic-authorization: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
#match-build-type: "appstore"
#browserstack-upload: true
#browserstack-username: ${{ secrets.BROWSERSTACK_USERNAME }}
#browserstack-access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
#fastlane-env: stage
ios-app-id: com.stetsed.teststreamyfin
output-path: build-${{ github.sha }}.ipa

46
.github/workflows/check-lockfile.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: 🔒 Lockfile Consistency Check
on:
pull_request:
branches: [develop, master]
push:
branches: [develop, master]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check-lockfile:
name: 🔍 Check bun.lock and package.json consistency
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: 📥 Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
- name: 🛡️ Verify lockfile consistency
run: |
set -euxo pipefail
echo "➡️ Checking for discrepancies between bun.lock and package.json..."
bun install --frozen-lockfile --dry-run --ignore-scripts
echo "✅ Lockfile is consistent with package.json!"

43
.github/workflows/ci-codeql.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: 🛡️ CodeQL Analysis
on:
push:
branches: [master, develop]
pull_request:
branches: [master, develop]
schedule:
- cron: '24 2 * * *'
jobs:
analyze:
name: 🔎 Analyze with CodeQL
runs-on: ubuntu-24.04
permissions:
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript-typescript', 'actions' ]
steps:
- name: 📥 Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
fetch-depth: 0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0

24
.github/workflows/conflict.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: 🏷🔀Merge Conflict Labeler
on:
push:
branches: [develop]
pull_request_target:
branches: [develop]
types: [synchronize]
jobs:
label:
name: 🏷️ Labeling Merge Conflicts
runs-on: ubuntu-24.04
if: ${{ github.repository == 'streamyfin/streamyfin' }}
permissions:
contents: read
pull-requests: write
steps:
- name: 🚩 Apply merge conflict label
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
with:
dirtyLabel: '⚔️ merge-conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
repoToken: '${{ secrets.GITHUB_TOKEN }}'

50
.github/workflows/crowdin.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: 🌐 Translation Sync
on:
push:
branches: [develop]
paths:
- "translations/**"
- "crowdin.yml"
- "i18n.ts"
- ".github/workflows/crowdin.yml"
# Run weekly to pull new translations
schedule:
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
sync-translations:
runs-on: ubuntu-latest
steps:
- name: 📥 Checkout Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0
with:
upload_sources: true
upload_translations: true
download_translations: true
localization_branch_name: I10n_crowdin_translations
create_pull_request: true
pull_request_title: "feat: New Crowdin Translations"
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
pull_request_base_branch_name: "develop"
pull_request_labels: "🌐 translation"
# Quality control options
skip_untranslated_strings: true
export_only_approved: false
# Commit customization
commit_message: "feat(i18n): update translations from Crowdin"
env:
GITHUB_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@@ -1,41 +0,0 @@
name: "Lint PR"
on:
pull_request_target:
types:
- opened
- edited
- synchronize
- reopened
permissions:
pull-requests: write
jobs:
main:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@v2
if: always() && (steps.lint_pr_title.outputs.error_message != null)
with:
header: pr-title-lint-error
message: |
Hey there and thank you for opening this pull request! 👋🏼
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
Details:
```
${{ steps.lint_pr_title.outputs.error_message }}
```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@v2
with:
header: pr-title-lint-error
delete: true

View File

@@ -1,28 +0,0 @@
name: Lint
on:
pull_request:
branches: [ develop, master ]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20.x'
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Run linting checks
run: bun run check

123
.github/workflows/linting.yml vendored Normal file
View File

@@ -0,0 +1,123 @@
name: 🚦 Security & Quality Gate
on:
pull_request:
types: [opened, edited, synchronize, reopened]
branches: [develop, master]
workflow_dispatch:
push:
branches: [develop]
permissions:
contents: read
jobs:
validate_pr_title:
name: "📝 Validate PR Title"
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
permissions:
pull-requests: write
contents: read
steps:
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
if: always() && (steps.lint_pr_title.outputs.error_message != null)
with:
header: pr-title-lint-error
message: |
Hey there and thank you for opening this pull request! 👋🏼
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
**Error details:**
```
${{ steps.lint_pr_title.outputs.error_message }}
```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
with:
header: pr-title-lint-error
delete: true
dependency-review:
name: 🔍 Vulnerable Dependencies
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Checkout Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- name: Dependency Review
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
with:
fail-on-severity: high
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
expo-doctor:
name: 🚑 Expo Doctor Check
if: false
runs-on: ubuntu-24.04
steps:
- name: 🛒 Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: latest
- name: 📦 Install dependencies (bun)
run: bun install --frozen-lockfile
- name: 🚑 Run Expo Doctor
run: bun expo-doctor
code_quality:
name: "🔍 Lint & Test (${{ matrix.command }})"
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
command:
- "lint"
- "check"
- "format"
- "typecheck"
steps:
- name: "📥 Checkout PR code"
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
fetch-depth: 0
- name: "🟢 Setup Node.js"
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: '24.x'
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: latest
- name: "📦 Install dependencies"
run: bun install --frozen-lockfile
- name: "🚨 Run ${{ matrix.command }}"
run: bun run ${{ matrix.command }}

View File

@@ -1,39 +0,0 @@
name: Handle Stale Issues
on:
schedule:
- cron: "30 1 * * *" # Runs at 1:30 UTC every day
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:
# Issue specific settings
days-before-issue-stale: 90
days-before-issue-close: 7
stale-issue-label: "stale"
stale-issue-message: |
This issue has been automatically marked as stale because it has had no activity in the last 30 days.
If this issue is still relevant, please leave a comment to keep it open.
Otherwise, it will be closed in 7 days if no further activity occurs.
Thank you for your contributions!
close-issue-message: |
This issue has been automatically closed because it has been inactive for 7 days since being marked as stale.
If you believe this issue is still relevant, please feel free to reopen it and add a comment explaining the current status.
# Pull request settings (disabled)
days-before-pr-stale: -1
days-before-pr-close: -1
# Other settings
repo-token: ${{ secrets.GITHUB_TOKEN }}
operations-per-run: 100
exempt-issue-labels: "Roadmap v1,help needed,enhancement"

View File

@@ -1,18 +0,0 @@
name: Discord Pull Request Notification
on:
pull_request:
types: [opened, reopened]
jobs:
notify:
runs-on: ubuntu-latest
steps:
- uses: joelwmale/webhook-action@master
with:
url: ${{ secrets.DISCORD_WEBHOOK_URL }}
body: |
{
"content": "New Pull Request: ${{ github.event.pull_request.title }}\nBy: ${{ github.event.pull_request.user.login }}\n\n${{ github.event.pull_request.html_url }}",
"avatar_url": "https://avatars.githubusercontent.com/u/193271640"
}

47
.github/workflows/notification.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: 🛎️ Discord Notification
permissions: {}
on:
pull_request:
types: [opened, reopened]
branches: [develop]
workflow_run:
workflows: ["*"]
types: [completed]
branches: [develop]
jobs:
notify:
runs-on: ubuntu-24.04
if: github.event_name == 'pull_request'
steps:
- name: 🛎️ Notify Discord
uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # 0.4.0
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640
with:
args: |
📢 New Pull Request in **${{ github.repository }}**
**Title:** ${{ github.event.pull_request.title }}
**By:** ${{ github.event.pull_request.user.login }}
**Branch:** ${{ github.event.pull_request.head.ref }}
🔗 ${{ github.event.pull_request.html_url }}
notify-on-failure:
runs-on: ubuntu-24.04
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'failure'
steps:
- name: 🚨 Notify Discord on Failure
uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # 0.4.0
env:
DISCORD_WEBHOOK: ${{ secrets.WEBHOOK_FAILED_JOB_URL }}
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640
with:
args: |
🚨 **Workflow Failed** in **${{ github.repository }}**
**Workflow:** ${{ github.event.workflow_run.name }}
**Branch:** ${{ github.event.workflow_run.head_branch }}
**Triggered by:** ${{ github.event.workflow_run.triggering_actor.login }}
**Commit:** ${{ github.event.workflow_run.head_commit.message }}
🔗 ${{ github.event.workflow_run.html_url }}

49
.github/workflows/stale.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
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

67
.github/workflows/update-issue-form.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: 🐛 Update Bug Report Template
on:
release:
types: [published] # Run on every published release on any branch
concurrency:
group: update-issue-form-${{ github.event.release.tag_name || github.run_id }}
cancel-in-progress: true
jobs:
update-bug-report:
permissions:
contents: write
pull-requests: write
issues: write
runs-on: ubuntu-24.04
steps:
- name: 📥 Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: "🟢 Setup Node.js"
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: '24.x'
cache: 'npm'
- name: 🔍 Extract minor version from app.json
id: minor
uses: actions/github-script@main
with:
result-encoding: string
script: |
const fs = require('fs-extra');
const semver = require('semver');
const content = fs.readJsonSync('./app.json');
const version = content.expo.version;
const minorVersion = semver.minor(version);
return minorVersion.toString();
- name: 📝 Update bug report version
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
with:
semver: '^0.${{ steps.minor.outputs.result }}.0'
dry_run: no-push
- name: ⚙️ Update bug report node version dropdown
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
with:
dropdown: _node_version
package: node
semver: '>=24.0.0'
dry_run: no-push
- name: 📬 Commit and create pull request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8
with:
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
branch: ci-update-bug-report
base: develop
delete-branch: true
labels: ⚙️ ci, 🤖 github-actions
title: 'chore(): Update bug report template to match release version'
body: |
Automated update to `.github/ISSUE_TEMPLATE/bug_report.yml`
Triggered by workflow run [${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})

77
.gitignore vendored
View File

@@ -1,31 +1,16 @@
# Dependencies and Package Managers
node_modules/ node_modules/
.expo/ bun.lock
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
modules/vlc-player/android/build
modules/vlc-player/android/.gradle
bun.lockb bun.lockb
# macOS
.DS_Store
expo-env.d.ts
Streamyfin.app
build-*
*.mp4
build-*
Streamyfin.app
package-lock.json package-lock.json
# Expo and React Native Build Artifacts
.expo/
dist/
web-build/
.tsbuildinfo
# Platform-specific Build Directories
/ios /ios
/android /android
/iostv /iostv
@@ -33,18 +18,50 @@ package-lock.json
/androidmobile /androidmobile
/androidtv /androidtv
# Module-specific Builds
modules/vlc-player/android/build
modules/player/android modules/player/android
modules/hls-downloader/android/build
pc-api-7079014811501811218-719-3b9f15aeccf8.json # Generated Applications
credentials.json Streamyfin.app
*.apk *.apk
*.ipa *.ipa
.continuerc.json *.aab
# Certificates and Keys
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Debug and Temporary Files
npm-debug.*
*.orig.*
*.mp4
# OS-specific Files
# macOS
.DS_Store
# IDE and Editor Files
.vscode/ .vscode/
.idea/ .idea/
.ruby-lsp .ruby-lsp
modules/hls-downloader/android/build .cursor/
streamyfin-4fec1-firebase-adminsdk.json .claude/
# Environment and Configuration
expo-env.d.ts
.continuerc.json
.env .env
.env.local .env.local
# Secrets and Credentials
pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json
streamyfin-4fec1-firebase-adminsdk.json
# Version and Backup Files
/version-backup-*

24
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,24 @@
{
// ==========================================
// Streamyfin - Recommended VS Code Extensions
// ==========================================
// Essential extensions for working with Streamyfin
// See .github/copilot-instructions.md for coding standards
"recommendations": [
// Code Quality & Formatting
"biomejs.biome", // Fast formatter and linter for JavaScript/TypeScript - replaces ESLint + Prettier
// React Native & Expo
"expo.vscode-expo-tools", // Official Expo extension - provides commands, debugging, and config IntelliSense
"msjsdiag.vscode-react-native", // React Native debugging and IntelliSense - essential for RN development
// Developer Experience
"bradlc.vscode-tailwindcss", // Tailwind CSS IntelliSense - autocomplete for NativeWind classes
"yoavbls.pretty-ts-errors", // Makes TypeScript error messages human-readable with formatting and highlights
"usernamehw.errorlens", // Shows errors and warnings inline in the editor - faster debugging
// Bun Support
"oven.bun-vscode" // Official Bun extension - provides debugging and language support for Bun runtime
]
}

176
.vscode/settings.json vendored
View File

@@ -1,24 +1,178 @@
{ {
// ==========================================
// FORMATTING & LINTING
// ==========================================
// Biome as default formatter
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.formatOnType": false,
// Language-specific formatters
"[javascript]": { "[javascript]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[typescriptreact]": { "[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"prettier.printWidth": 120,
"[swift]": {
"editor.defaultFormatter": "sswg.swift-lang"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome",
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[javascriptreact]": { "[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true "editor.formatOnSave": true
} },
"[json]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[swift]": {
"editor.insertSpaces": true,
"editor.tabSize": 2
},
// ==========================================
// TYPESCRIPT & JAVASCRIPT
// ==========================================
// TypeScript performance optimizations
"typescript.preferences.includePackageJsonAutoImports": "auto",
"typescript.suggest.autoImports": true,
"typescript.updateImportsOnFileMove.enabled": "always",
"typescript.preferences.preferTypeOnlyAutoImports": true,
"typescript.preferences.importModuleSpecifier": "relative",
"typescript.preferences.includeCompletionsForImportStatements": true,
"typescript.preferences.includeCompletionsWithSnippetText": true,
// JavaScript settings
"javascript.preferences.importModuleSpecifier": "relative",
"javascript.suggest.autoImports": true,
"javascript.updateImportsOnFileMove.enabled": "always",
// ==========================================
// REACT NATIVE & EXPO
// ==========================================
// File associations for React Native
"files.associations": {
"*.expo.ts": "typescript",
"*.expo.tsx": "typescriptreact",
"*.expo.js": "javascript",
"*.expo.jsx": "javascriptreact",
"metro.config.js": "javascript",
"babel.config.js": "javascript",
"app.config.js": "javascript",
"eas.json": "jsonc"
},
// React Native specific settings
"emmet.includeLanguages": {
"typescriptreact": "html",
"javascriptreact": "html"
},
"emmet.triggerExpansionOnTab": true,
// Exclude build directories from search
"search.exclude": {
"**/node_modules": true
},
// ==========================================
// EDITOR PERFORMANCE & UX
// ==========================================
// Performance optimizations
"editor.largeFileOptimizations": true,
"files.watcherExclude": {
"**/.git/objects/**": true,
"**/.git/subtree-cache/**": true,
"**/node_modules/**": true,
"**/.expo/**": true,
"**/ios/**": true,
"**/android/**": true,
"**/build/**": true,
"**/dist/**": true
},
// Better editor behavior
"editor.suggestSelection": "first",
"editor.quickSuggestions": {
"strings": true,
"comments": true,
"other": true
},
"editor.snippetSuggestions": "top",
"editor.tabCompletion": "on",
"editor.wordBasedSuggestions": "off",
// ==========================================
// TERMINAL & DEVELOPMENT
// ==========================================
// Terminal settings for Bun (Windows-specific)
"terminal.integrated.profiles.windows": {
"Command Prompt": {
"path": "C:\\Windows\\System32\\cmd.exe",
"env": {
"PATH": "${env:PATH};./node_modules/.bin"
}
}
},
// ==========================================
// WORKSPACE & NAVIGATION
// ==========================================
// Better workspace navigation
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.expand": false,
"explorer.fileNesting.patterns": {
"*.ts": "${capture}.js",
"*.tsx": "${capture}.js",
"*.js": "${capture}.js,${capture}.js.map,${capture}.min.js,${capture}.d.ts",
"*.jsx": "${capture}.js",
"package.json": "package-lock.json,yarn.lock,bun.lock,bun.lockb,.yarnrc,.yarnrc.yml",
"tsconfig.json": "tsconfig.*.json",
".env": ".env.*",
"app.json": "app.config.js,eas.json,expo-env.d.ts",
"README.md": "LICENSE.txt,SECURITY.md,CODE_OF_CONDUCT.md,CONTRIBUTING.md"
},
// Better breadcrumbs and navigation
"breadcrumbs.enabled": true,
"outline.showVariables": true,
"outline.showConstants": true,
// ==========================================
// GIT & VERSION CONTROL
// ==========================================
// Git integration
"git.autofetch": true,
"git.enableSmartCommit": true,
"git.confirmSync": false,
"git.ignoreLimitWarning": true,
// ==========================================
// CODE QUALITY & ERRORS
// ==========================================
// Better error detection
"typescript.validate.enable": true,
"javascript.validate.enable": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
},
// Problem matcher for better error display
"typescript.tsc.autoDetect": "on"
} }

348
README.md
View File

@@ -1,100 +1,242 @@
# 📺 Streamyfin
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a> <a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
<div style="display: flex; flex-direction: row; gap: 8px"> <p align="center">
<img width=150 src="./assets/images/screenshots/screenshot1.png" /> <img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
<img width=150 src="./assets/images/screenshots/screenshot3.png" /> </p>
<img width=150 src="./assets/images/screenshots/screenshot2.png" />
<img width=159 src="./assets/images/jellyseerr.PNG"/> **Streamyfin is a user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
</div>
---
<p align="center">
<img src="./assets/images/screenshots/screenshot1.png" width="20%">
&nbsp;
<img src="./assets/images/screenshots/screenshot3.png" width="20%">
&nbsp;
<img src="./assets/images/screenshots/screenshot2.png" width="20%">
&nbsp;
<img src="./assets/images/jellyseerr.PNG" width="21%">
</p>
## 🌟 Features ## 🌟 Features
- 🚀 **Skip Intro / Credits Support** - 🚀 **Skip Intro / Credits Support**: Lets you quickly skip intros and credits during playback
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking. - 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking
- 🔊 **Background audio**: Stream music in the background, even when locking the phone. - 📥 **Download media**: Save your media locally and watch it offline
- 📥 **Download media** (Experimental): Save your media locally and watch it offline. - ⚙️ **Settings management**: Manage app configurations for all users through our plugin
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device. - 🤖 **Seerr (formerly Jellyseerr) integration**: Request media directly in the app
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin. - 👁️ **Sessions view:** View all active sessions currently streaming on your server
- 🤖 **Jellyseerr integration**: Request media directly in the app. - 📡 **Chromecast**: Cast your media to any Chromecast-enabled device
## 🧪 Experimental Features ## 🧪 Experimental Features
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These are still in development, and we appreciate your patience and feedback as we work to improve them. Streamyfin 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.
### Downloading ### 📥 Downloading
Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode. Downloading works by using FFmpeg to convert an HLS stream into a video file on 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.
### Chromecast ### 🧩 Streamyfin Plugin
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features. 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:
### Streamyfin Plugin - Automatic Seerr login with no user input required
- Set your preferred default languages
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that hold all settings for the client Streamyfin. This allows you to syncronize settings accross all your users, like: - Configure download method and search provider
- Personalize your home screen
- Auto log in to Jellyseerr without the user having to do anythin - And much more
- Choose the default languages
- Set download method and search provider
- Customize homescreen
- And more...
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin) [Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
### Jellysearch ### 📡 Chromecast
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀 Chromecast support is currently under development. Video casting is already available, and we're actively working on adding subtitle support and additional features.
### 🔍 Jellysearch
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients. > A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
## Roadmap for V1 ## 🛣️ Roadmap
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open for feedback and suggestions, so please let us know if you have any ideas or feature requests. Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
## Get it now ## 📥 Download Streamyfin
<div style="display: flex; gap: 5px;"> <div style="display: flex; gap: 5px;">
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a> <a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" 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>
</div> </div>
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android. ### 🧪 Beta Testing
### Beta testing To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This grants you immediate access to the ⁠🧪-beta-releases channel on Discord and lets me know youve subscribed. This is where I share APKs and IPAs. It does not provide automatic TestFlight access, so please send me a DM (Cagemaster) with the email you use for Apple so we can add you manually.
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the ⁠🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you. **Note**: Anyone actively contributing to Streamyfins source code will receive automatic access to beta releases.
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
## 🚀 Getting Started ## 🚀 Getting Started
### Prerequisites ### ⚙️ Prerequisites
- Ensure you have an active Jellyfin server. - Your device is on the same network as the Jellyfin server (for local connections)
- Make sure your device is connected to the same network as your Jellyfin server. - Your Jellyfin server is up and running with remote access enabled if you plan to connect from outside your local network
- Your server version is up to date (older versions may cause compatibility issues)
- You have a valid Jellyfin user account with access to the media libraries you want to view
- If using features such as **downloads** or **Seerr integration**, confirm the required plugins are installed and configured on your Jellyfin server
## 🙌 Contributing ## 🙌 Contributing
We welcome any help to make Streamyfin better. If you'd like to contribute, please fork the repository and submit a pull request. For major changes, it's best to open an issue first to discuss your ideas. We welcome contributions that improve Streamyfin. Start by forking the repository and submitting a pull request. For major changes or new features, please open an issue first to discuss your ideas and ensure alignment with the project.
### Development info ## 🌍 Translations
[![Crowdin Translation Status](https://badges.crowdin.net/streamyfin/localized.svg)](https://crowdin.com/project/streamyfin)
Streamyfin is available in multiple languages, and were always looking for contributors to help make the app accessible worldwide.
You can contribute translations directly on our [Crowdin project page](https://crowdin.com/project/streamyfin).
### 👨‍💻 Development Info
1. Use node `>20` 1. Use node `>20`
2. Install dependencies `bun i && bun run submodule-reload` 2. Install dependencies `bun i && bun run submodule-reload`
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. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/) 4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
4. run `npm run prebuild` 4. run `npm run prebuild`
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app. 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
For the TV version suffix the npm commands with `:tv`. For the TV version suffix the npm commands with `:tv`.
`npm run prebuild:tv` `npm run prebuild:tv`
`npm run ios:tv or npm run android:tv` `npm run ios:tv or npm run android:tv`
## 👋 Get in Touch with Us
Need assistance or have any questions?
- **Discord:** [Join our server](https://discord.gg/BuGG9ZNhaE)
- **GitHub Issues:** [Report bugs or request features](https://github.com/streamyfin/streamyfin/issues)
- **Email:** [developer@streamyfin.app](mailto:developer@streamyfin.app)
## ❓ FAQ
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
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
## 📝 Credits
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built using Expo, React Native, and other open-source libraries.
## 🎖️ Core Developers
Thanks to the following contributors for their significant contributions:
<div align="left">
<table>
<tr>
<td align="center">
<a href="https://github.com/Alexk2309">
<img src="https://github.com/Alexk2309.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@Alexk2309</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/herrrta">
<img src="https://github.com/herrrta.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@herrrta</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/lostb1t">
<img src="https://github.com/lostb1t.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@lostb1t</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Simon-Eklundh">
<img src="https://github.com/Simon-Eklundh.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@Simon-Eklundh</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/topiga">
<img src="https://github.com/topiga.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@topiga</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/lancechant">
<img src="https://github.com/lancechant.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@lancechant</b></sub>
</a>
</td>
</tr>
<tr>
<td align="center">
<a href="https://github.com/simoncaron">
<img src="https://github.com/simoncaron.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@simoncaron</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/jakequade">
<img src="https://github.com/jakequade.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@jakequade</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Ryan0204">
<img src="https://github.com/Ryan0204.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@Ryan0204</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/retardgerman">
<img src="https://github.com/retardgerman.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@retardgerman</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/whoopsi-daisy">
<img src="https://github.com/whoopsi-daisy.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@whoopsi-daisy</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Gauvino">
<img src="https://github.com/Gauvino.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@Gauvino</b></sub>
</a>
</td>
</tr>
</table>
</div>
## ✨ Acknowledgements
We would like to thank the Jellyfin team for their excellent software and support on Discord.
Special thanks to the official Jellyfin clients, which have served as an inspiration for Streamyfin.
We also thank all other developers who have contributed to Streamyfin, your efforts are greatly appreciated.
A special mention to the following people and projects for their contributions:
- [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
- [Seerr](https://github.com/seerr-team/seerr) for enabling API integration with their project
## ⭐ Star History
[![Star History Chart](https://api.star-history.com/svg?repos=streamyfin/streamyfin&type=Date)](https://star-history.com/#streamyfin/streamyfin&Date)
## 📄 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).
@@ -107,112 +249,10 @@ Key points of the MPL-2.0:
- You must disclose your source code for any modifications to the covered files - You must disclose your source code for any modifications to the covered files
- Larger works may combine MPL code with code under other licenses - Larger works may combine MPL code with code under other licenses
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license - MPL-licensed components must remain under the MPL, but the larger work can be under a different license
- For the full text of the license, please see the LICENSE file in this repository. - For the full text of the license, please see the LICENSE file in this repository
## 🌐 Connect with Us ## ⚠️ 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.
Join our Discord: [https://discord.gg/aJvAYeycyY](https://discord.gg/aJvAYeycyY) ## 🤝 Sponsorship
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)
If you have questions or need support, feel free to reach out:
- GitHub Issues: Report bugs or request features here.
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
## 📝 Credits
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
## ✨ Acknowledgements
We would like to thank the Jellyfin team for their great software and awesome support on discord.
Special shoutout to the JF official clients for being an inspiration to ours.
### Core Developers
Thanks to the following contributors for their significant contributions:
<table>
<tr
style="
display: flex;
justify-content: space-around;
align-items: center;
flex-wrap: wrap;
"
>
<td align="center">
<a href="https://github.com/Alexk2309">
<img src="https://github.com/Alexk2309.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Alexk2309</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/herrrta">
<img src="https://github.com/herrrta.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@herrrta</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/lostb1t">
<img src="https://github.com/lostb1t.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@lostb1t</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Simon-Eklundh">
<img src="https://github.com/Simon-Eklundh.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Simon-Eklundh</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/topiga">
<img src="https://github.com/topiga.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@topiga</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/simoncaron">
<img src="https://github.com/simoncaron.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@simoncaron</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/jakequade">
<img src="https://github.com/jakequade.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@jakequade</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Ryan0204">
<img src="https://github.com/Ryan0204.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Ryan0204</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/retardgerman">
<img src="https://github.com/retardgerman.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@retardgerman</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/whoopsi-daisy">
<img src="https://github.com/whoopsi-daisy.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@whoopsi-daisy</b></sub>
</a>
</td>
</tr>
</table>
And all other developers who have contributed to Streamyfin, thank you for your contributions.
I'd also like to thank the following people and projects for their contributions to Streamyfin:
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
- The Jellyfin devs for always being helpful in the Discord.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=streamyfin/streamyfin&type=Date)](https://star-history.com/#streamyfin/streamyfin&Date)

40
SECURITY.md Normal file
View File

@@ -0,0 +1,40 @@
# Security Policy
## Supported Versions of Streamyfin Mobile App
Only the most recent stable release of the Streamyfin mobile app is guaranteed to include the latest security patches. **Running older app versions may leave you vulnerable to security risks**. Always update your app from the official App Store or Google Play Store as soon as updates are available. If you must run an older version, avoid using sensitive features (e.g., account management, payment methods) until you can upgrade.
This policy applies only to the current stable app release. Security flaws in previous app versions that are no longer present in the latest release **will not** be back-ported or fixed.
## Supported Versions of Other Streamyfin Components (Server, Plugins)
Most Streamyfin backend services and plugins are supported only in their latest release. Consult each projects README or release notes for any exceptions.
## Vulnerability Triage
Before reporting an issue, please consider:
- Administrator-level risks: Certain administrative or configuration endpoints in the backend may inherently carry elevated privileges. Vulnerabilities that **require administrator or root access** are classified as low priority. Report those via normal GitHub Issues.
- Known vulnerabilities: We maintain a public list of known issues on our Security Advisories page at https://github.com/Streamyfin/Streamyfin/security/advisories. If your issue is already listed there, please do not re-report it.
- Local-only issues: Vulnerabilities exploitable only with physical device access, manual file modification, or local debugging (e.g., modifying app files, rooting/jailbreaking) are considered low- to medium-priority.
- Infrastructure reports: To report issues in our website, servers, CI/CD, or other infrastructure, tag your report subject with `[Streamyfin Infrastructure]`. Our infrastructure team follows standard patch policies for public vulnerabilities, so avoid duplicating widely known issues.
## Reporting a Vulnerability
After confirming your issue is new and relevant, send an email to **developer@streamyfin.app** with the following:
1. Subject line: `[Streamyfin Security] <short summary>`
2. Overview (public-safe): Describe what component is affected (mobile app, backend API, plugin) and the high-level impact. We may reuse this text for a GitHub Security Advisory.
3. Details: Provide reproduction steps, code or API snippets, proof-of-concept, and any suggested remediation. Detail exactly how to trigger the issue.
4. Your GitHub username: So we can invite you to the GitHub Security Advisory (GHSA) for coordination and credit.
Once received, we will review the report, file a GHSA if warranted, and include you and the relevant teams in the remediation process.
## Post-Disclosure Process
Streamyfin is a volunteer-driven project. **We appreciate patience and do not enforce strict disclosure deadlines**, especially for complex issues. You may send polite follow-ups if theres no response after a reasonable interval.
- Patch releases: For critical vulnerabilities, we generally issue a point release promptly unless a major release is imminent; in that case, we defer the fix.
- Advisory publication: After releasing a patched app version, we wait at least seven days (1 week) before publishing the GHSA to allow most users to upgrade. We request that any third-party disclosures (blog posts, advisories) occur **after** our GHSA publication.
- CVE assignment: We will request CVEs via the GitHub Security interface and include them in the published advisory.

View File

@@ -1,9 +1,14 @@
module.exports = ({ config }) => { module.exports = ({ config }) => {
if (process.env.EXPO_TV !== "1") { if (process.env.EXPO_TV !== "1") {
config.plugins.push("expo-background-task");
config.plugins.push([ config.plugins.push([
"react-native-google-cast", "react-native-google-cast",
{ useDefaultExpandedMediaControls: true }, { useDefaultExpandedMediaControls: true },
]); ]);
// Add the background downloader plugin only for non-TV builds
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
} }
return { return {
android: { android: {

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.28.0", "version": "0.39.0",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
@@ -27,14 +27,21 @@
"usesNonExemptEncryption": false "usesNonExemptEncryption": false
}, },
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "com.fredrikburmester.streamyfin" "bundleIdentifier": "com.fredrikburmester.streamyfin",
"icon": {
"dark": "./assets/images/icon-ios-plain.png",
"light": "./assets/images/icon-ios-light.png",
"tinted": "./assets/images/icon-ios-tinted.png"
},
"appleTeamId": "MWD5K362T8"
}, },
"android": { "android": {
"jsEngine": "hermes", "jsEngine": "hermes",
"versionCode": 54, "versionCode": 71,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive_icon.png", "foregroundImage": "./assets/images/icon-android-plain.png",
"backgroundColor": "#464646" "monochromeImage": "./assets/images/icon-android-themed.png",
"backgroundColor": "#2E2E2E"
}, },
"package": "com.fredrikburmester.streamyfin", "package": "com.fredrikburmester.streamyfin",
"permissions": [ "permissions": [
@@ -42,13 +49,13 @@
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK", "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.WRITE_SETTINGS" "android.permission.WRITE_SETTINGS"
], ],
"blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"],
"googleServicesFile": "./google-services.json" "googleServicesFile": "./google-services.json"
}, },
"plugins": [ "plugins": [
"@react-native-tvos/config-tv", "@react-native-tvos/config-tv",
"expo-router", "expo-router",
"expo-font", "expo-font",
"@config-plugins/ffmpeg-kit-react-native",
[ [
"react-native-video", "react-native-video",
{ {
@@ -108,7 +115,6 @@
} }
} }
], ],
["react-native-bottom-tabs"],
["./plugins/withChangeNativeAndroidTextToWhite.js"], ["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidManifest.js"], ["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"], ["./plugins/withTrustLocalCerts.js"],
@@ -116,8 +122,8 @@
[ [
"expo-splash-screen", "expo-splash-screen",
{ {
"backgroundColor": "#2e2e2e", "backgroundColor": "#010101",
"image": "./assets/images/StreamyFinFinal.png", "image": "./assets/images/icon-ios-plain.png",
"imageWidth": 100 "imageWidth": 100
} }
], ],
@@ -127,7 +133,9 @@
"icon": "./assets/images/notification.png", "icon": "./assets/images/notification.png",
"color": "#9333EA" "color": "#9333EA"
} }
] ],
"./plugins/with-runtime-framework-headers.js",
"react-native-bottom-tabs"
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true

View File

@@ -12,7 +12,7 @@ export default function CustomMenuLayout() {
headerShown: true, headerShown: true,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: t("tabs.custom_links"), headerTitle: t("tabs.custom_links"),
headerBlurEffect: "prominent", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
}} }}

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,17 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Feather, Ionicons } from "@expo/vector-icons"; import { Feather, Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router"; import { Stack, useRouter } from "expo-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast"); const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
import { useAtom } from "jotai";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions"; import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { userAtom } from "@/providers/JellyfinProvider"; import { userAtom } from "@/providers/JellyfinProvider";
import { useAtom } from "jotai";
export default function IndexLayout() { export default function IndexLayout() {
const router = useRouter(); const _router = useRouter();
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -18,20 +20,17 @@ export default function IndexLayout() {
<Stack.Screen <Stack.Screen
name='index' name='index'
options={{ options={{
headerShown: true, headerShown: !Platform.isTV,
headerLargeTitle: true,
headerTitle: t("tabs.home"), headerTitle: t("tabs.home"),
headerBlurEffect: "prominent", headerBlurEffect: "none",
headerLargeStyle: {
backgroundColor: "black",
},
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerRight: () => ( headerRight: () => (
<View className='flex flex-row items-center space-x-2'> <View className='flex flex-row items-center px-2'>
{!Platform.isTV && ( {!Platform.isTV && (
<> <>
<Chromecast.Chromecast /> <Chromecast.Chromecast background='transparent' />
{user?.Policy?.IsAdministrator && <SessionsButton />} {user?.Policy?.IsAdministrator && <SessionsButton />}
<SettingsButton /> <SettingsButton />
</> </>
@@ -64,12 +63,6 @@ export default function IndexLayout() {
title: t("home.settings.settings_title"), title: t("home.settings.settings_title"),
}} }}
/> />
<Stack.Screen
name='settings/optimized-server/page'
options={{
title: "",
}}
/>
<Stack.Screen <Stack.Screen
name='settings/marlin-search/page' name='settings/marlin-search/page'
options={{ options={{
@@ -142,14 +135,13 @@ const SessionsButton = () => {
onPress={() => { onPress={() => {
router.push("/(auth)/sessions"); router.push("/(auth)/sessions");
}} }}
className='mr-4'
> >
<View className='mr-4'> <Ionicons
<Ionicons name='play-circle'
name='play-circle' color={sessions.length === 0 ? "white" : "#9333ea"}
color={sessions.length === 0 ? "white" : "#9333ea"} size={28}
size={25} />
/>
</View>
</TouchableOpacity> </TouchableOpacity>
); );
}; };

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,22 @@
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import {
HardwareAccelerationType,
type SessionInfoDto,
} from "@jellyfin/sdk/lib/generated-client";
import {
GeneralCommandType,
PlaystateCommand,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native";
import { Badge } from "@/components/Badge"; import { Badge } from "@/components/Badge";
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import Poster from "@/components/posters/Poster"; import Poster from "@/components/posters/Poster";
import { useInterval } from "@/hooks/useInterval"; import { useInterval } from "@/hooks/useInterval";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions"; import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
@@ -8,22 +24,6 @@ import { apiAtom } from "@/providers/JellyfinProvider";
import { formatBitrate } from "@/utils/bitrate"; import { formatBitrate } from "@/utils/bitrate";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { formatTimeString } from "@/utils/time"; import { formatTimeString } from "@/utils/time";
import {
AntDesign,
Entypo,
Ionicons,
MaterialCommunityIcons,
} from "@expo/vector-icons";
import {
HardwareAccelerationType,
type SessionInfoDto,
} from "@jellyfin/sdk/lib/generated-client";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
export default function page() { export default function page() {
const { sessions, isLoading } = useSessions({} as useSessionsProps); const { sessions, isLoading } = useSessions({} as useSessionsProps);
@@ -99,17 +99,92 @@ const SessionCard = ({ session }: SessionCardProps) => {
} }
}, [session]); }, [session]);
const { data: ipInfo } = useQuery({ const { data: ipInfo } = useQuery<{
cityName?: string;
countryCode?: string;
}>({
queryKey: ["ipinfo", session.RemoteEndPoint], queryKey: ["ipinfo", session.RemoteEndPoint],
cacheTime: Number.POSITIVE_INFINITY, staleTime: Number.POSITIVE_INFINITY,
queryFn: async () => { queryFn: async () => {
const resp = await api.axiosInstance.get( const resp = await api!.axiosInstance.get(
`https://freeipapi.com/api/json/${session.RemoteEndPoint}`, `https://freeipapi.com/api/json/${session.RemoteEndPoint}`,
); );
return resp.data; return resp.data;
}, },
enabled: !!api,
}); });
// Handle session controls
const [isControlLoading, setIsControlLoading] = useState<
Record<string, boolean>
>({});
const handleSystemCommand = async (command: GeneralCommandType) => {
if (!api || !session.Id) return false;
setIsControlLoading({ ...isControlLoading, [command]: true });
try {
getSessionApi(api).sendSystemCommand({
sessionId: session.Id,
command,
});
return true;
} catch (error) {
console.error(`Error sending ${command} command:`, error);
return false;
} finally {
setIsControlLoading({ ...isControlLoading, [command]: false });
}
};
const handlePlaystateCommand = async (command: PlaystateCommand) => {
if (!api || !session.Id) return false;
setIsControlLoading({ ...isControlLoading, [command]: true });
try {
getSessionApi(api).sendPlaystateCommand({
sessionId: session.Id,
command,
});
return true;
} catch (error) {
console.error(`Error sending playstate ${command} command:`, error);
return false;
} finally {
setIsControlLoading({ ...isControlLoading, [command]: false });
}
};
const handlePlayPause = async () => {
console.log("handlePlayPause");
await handlePlaystateCommand(PlaystateCommand.PlayPause);
};
const handleStop = async () => {
await handlePlaystateCommand(PlaystateCommand.Stop);
};
const handlePrevious = async () => {
await handlePlaystateCommand(PlaystateCommand.PreviousTrack);
};
const handleNext = async () => {
await handlePlaystateCommand(PlaystateCommand.NextTrack);
};
const handleToggleMute = async () => {
await handleSystemCommand(GeneralCommandType.ToggleMute);
};
const handleVolumeUp = async () => {
await handleSystemCommand(GeneralCommandType.VolumeUp);
};
const handleVolumeDown = async () => {
await handleSystemCommand(GeneralCommandType.VolumeDown);
};
useInterval(tick, 1000); useInterval(tick, 1000);
return ( return (
@@ -181,6 +256,107 @@ const SessionCard = ({ session }: SessionCardProps) => {
}} }}
/> />
</View> </View>
{/* Session controls */}
<View className='flex flex-row mt-2 space-x-4 justify-center'>
<TouchableOpacity
onPress={handlePrevious}
disabled={isControlLoading[PlaystateCommand.PreviousTrack]}
style={{
opacity: isControlLoading[PlaystateCommand.PreviousTrack]
? 0.5
: 1,
}}
>
<MaterialCommunityIcons
name='skip-previous'
size={24}
color='white'
/>
</TouchableOpacity>
<TouchableOpacity
onPress={handlePlayPause}
disabled={isControlLoading[PlaystateCommand.PlayPause]}
style={{
opacity: isControlLoading[PlaystateCommand.PlayPause]
? 0.5
: 1,
}}
>
{session.PlayState?.IsPaused ? (
<Ionicons name='play' size={24} color='white' />
) : (
<Ionicons name='pause' size={24} color='white' />
)}
</TouchableOpacity>
<TouchableOpacity
onPress={handleStop}
disabled={isControlLoading[PlaystateCommand.Stop]}
style={{
opacity: isControlLoading[PlaystateCommand.Stop] ? 0.5 : 1,
}}
>
<Ionicons name='stop' size={24} color='white' />
</TouchableOpacity>
<TouchableOpacity
onPress={handleNext}
disabled={isControlLoading[PlaystateCommand.NextTrack]}
style={{
opacity: isControlLoading[PlaystateCommand.NextTrack]
? 0.5
: 1,
}}
>
<MaterialCommunityIcons
name='skip-next'
size={24}
color='white'
/>
</TouchableOpacity>
<TouchableOpacity
onPress={handleVolumeDown}
disabled={isControlLoading[GeneralCommandType.VolumeDown]}
style={{
opacity: isControlLoading[GeneralCommandType.VolumeDown]
? 0.5
: 1,
}}
>
<Ionicons name='volume-low' size={24} color='white' />
</TouchableOpacity>
<TouchableOpacity
onPress={handleToggleMute}
disabled={isControlLoading[GeneralCommandType.ToggleMute]}
style={{
opacity: isControlLoading[GeneralCommandType.ToggleMute]
? 0.5
: 1,
}}
>
<Ionicons
name='volume-mute'
size={24}
color={session.PlayState?.IsMuted ? "red" : "white"}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={handleVolumeUp}
disabled={isControlLoading[GeneralCommandType.VolumeUp]}
style={{
opacity: isControlLoading[GeneralCommandType.VolumeUp]
? 0.5
: 1,
}}
>
<Ionicons name='volume-high' size={24} color='white' />
</TouchableOpacity>
</View>
</View> </View>
</View> </View>
</View> </View>
@@ -262,8 +438,6 @@ const TranscodingStreamView = ({
isTranscoding, isTranscoding,
properties, properties,
transcodeProperties, transcodeProperties,
value,
transcodeValue,
}: TranscodingStreamViewProps) => { }: TranscodingStreamViewProps) => {
return ( return (
<View className='flex flex-col pt-2 first:pt-0'> <View className='flex flex-col pt-2 first:pt-0'>
@@ -276,26 +450,25 @@ const TranscodingStreamView = ({
</Text> </Text>
</View> </View>
{isTranscoding && transcodeProperties ? ( {isTranscoding && transcodeProperties ? (
<> <View className='flex flex-row'>
<View className='flex flex-row'> <Text className='-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4'>
<Text className='-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4'> <MaterialCommunityIcons
<MaterialCommunityIcons name='arrow-right-bottom'
name='arrow-right-bottom' size={14}
size={14} color='white'
color='white' />
/> </Text>
</Text> <Text className='flex-1 text-sm mt-1'>
<Text className='flex-1 text-sm mt-1'> <TranscodingBadges properties={transcodeProperties} />
<TranscodingBadges properties={transcodeProperties} /> </Text>
</Text> </View>
</View>
</>
) : null} ) : null}
</View> </View>
); );
}; };
const TranscodingView = ({ session }: SessionCardProps) => { const TranscodingView = ({ session }: SessionCardProps) => {
const { t } = useTranslation();
const videoStream = useMemo(() => { const videoStream = useMemo(() => {
return session.NowPlayingItem?.MediaStreams?.filter( return session.NowPlayingItem?.MediaStreams?.filter(
(s) => s.Type === "Video", (s) => s.Type === "Video",
@@ -329,7 +502,7 @@ const TranscodingView = ({ session }: SessionCardProps) => {
return ( return (
<View className='flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2'> <View className='flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2'>
<TranscodingStreamView <TranscodingStreamView
title='Video' title={t("common.video")}
properties={{ properties={{
resolution: videoStreamTitle(), resolution: videoStreamTitle(),
bitrate: videoStream?.BitRate, bitrate: videoStream?.BitRate,
@@ -346,7 +519,7 @@ const TranscodingView = ({ session }: SessionCardProps) => {
/> />
<TranscodingStreamView <TranscodingStreamView
title='Audio' title={t("common.audio")}
properties={{ properties={{
language: audioStream?.Language, language: audioStream?.Language,
bitrate: audioStream?.BitRate, bitrate: audioStream?.BitRate,
@@ -364,7 +537,7 @@ const TranscodingView = ({ session }: SessionCardProps) => {
{subtitleStream && ( {subtitleStream && (
<TranscodingStreamView <TranscodingStreamView
title='Subtitle' title={t("common.subtitle")}
isTranscoding={false} isTranscoding={false}
properties={{ properties={{
language: subtitleStream?.Language, language: subtitleStream?.Language,

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,7 @@
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { useSettings } from "@/utils/atoms/settings";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
import React, { useEffect, useMemo, useState } from "react";
import { import {
Linking, Linking,
Switch, Switch,
@@ -16,13 +10,18 @@ import {
View, View,
} from "react-native"; } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { useSettings } from "@/utils/atoms/settings";
export default function page() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation(); const { t } = useTranslation();
const [settings, updateSettings, pluginSettings] = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || ""); const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");

View File

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

View File

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

View File

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

View File

@@ -1,24 +1,23 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { uniqBy } from "lodash";
import { useMemo } from "react";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow"; import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster"; import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr"; import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import { import {
type MovieResult, type MovieResult,
Results,
type TvResult, type TvResult,
} from "@/utils/jellyseerr/server/models/Search"; } from "@/utils/jellyseerr/server/models/Search";
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider"; import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { uniqBy } from "lodash";
import React, { useMemo } from "react";
export default function page() { export default function page() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { companyId, name, image, type } = local as unknown as { const { companyId, image, type } = local as unknown as {
companyId: string; companyId: string;
name: string; name: string;
image: string; image: string;
@@ -54,7 +53,10 @@ export default function page() {
uniqBy( uniqBy(
data?.pages data?.pages
?.filter((p) => p?.results.length) ?.filter((p) => p?.results.length)
.flatMap((p) => p?.results ?? []), .flatMap(
(p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
),
"id", "id",
) ?? [], ) ?? [],
[data], [data],
@@ -99,9 +101,7 @@ export default function page() {
}} }}
/> />
} }
renderItem={(item, index) => ( renderItem={(item, _index) => <JellyseerrPoster item={item} />}
<JellyseerrPoster item={item as MovieResult | TvResult} />
)}
/> />
); );
} }

View File

@@ -1,25 +1,17 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { uniqBy } from "lodash";
import { useMemo } from "react";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard"; import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster"; import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import Poster from "@/components/posters/Poster";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr"; import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import {
type MovieResult,
Results,
type TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { useInfiniteQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams, useSegments } from "expo-router";
import { uniqBy } from "lodash";
import React, { useMemo } from "react";
import { TouchableOpacity } from "react-native";
export default function page() { export default function page() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { genreId, name, type } = local as unknown as { const { genreId, name, type } = local as unknown as {
genreId: string; genreId: string;
@@ -55,7 +47,10 @@ export default function page() {
uniqBy( uniqBy(
data?.pages data?.pages
?.filter((p) => p?.results.length) ?.filter((p) => p?.results.length)
.flatMap((p) => p?.results ?? []), .flatMap(
(p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
),
"id", "id",
) ?? [], ) ?? [],
[data], [data],
@@ -66,7 +61,7 @@ export default function page() {
jellyseerrApi jellyseerrApi
? flatData.map((r) => ? flatData.map((r) =>
jellyseerrApi.imageProxy( jellyseerrApi.imageProxy(
(r as TvResult | MovieResult).backdropPath, r.backdropPath,
"w1920_and_h800_multi_faces", "w1920_and_h800_multi_faces",
), ),
) )
@@ -96,9 +91,7 @@ export default function page() {
{name} {name}
</Text> </Text>
} }
renderItem={(item, index) => ( renderItem={(item, _index) => <JellyseerrPoster item={item} />}
<JellyseerrPoster item={item as MovieResult | TvResult} />
)}
/> />
); );
} }

View File

@@ -1,11 +1,27 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
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 { OverviewText } from "@/components/OverviewText"; import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
import { JellyserrRatings } from "@/components/Ratings"; import { JellyserrRatings } from "@/components/Ratings";
import { Text } from "@/components/common/Text";
import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons"; import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions"; import { ItemActions } from "@/components/series/SeriesActions";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
@@ -20,23 +36,9 @@ import type {
TvResult, TvResult,
} from "@/utils/jellyseerr/server/models/Search"; } from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import RequestModal from "@/components/jellyseerr/RequestModal"; import RequestModal from "@/components/jellyseerr/RequestModal";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants"; import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
@@ -46,6 +48,7 @@ const Page: React.FC = () => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } = const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } =
params as unknown as { params as unknown as {
@@ -136,7 +139,15 @@ const Page: React.FC = () => {
} }
requestMedia(mediaTitle, body, refetch); requestMedia(mediaTitle, body, refetch);
}, [details, result, requestMedia, hasAdvancedRequestPermission]); }, [
details,
result,
requestMedia,
hasAdvancedRequestPermission,
mediaTitle,
refetch,
mediaType,
]);
const isAnime = useMemo( const isAnime = useMemo(
() => () =>
@@ -218,11 +229,7 @@ const Page: React.FC = () => {
| TvDetails | TvDetails
} }
/> />
<Text <Text selectable className='font-bold text-2xl mb-1'>
uiTextView
selectable
className='font-bold text-2xl mb-1'
>
{mediaTitle} {mediaTitle}
</Text> </Text>
<Text className='opacity-50'>{releaseYear}</Text> <Text className='opacity-50'>{releaseYear}</Text>
@@ -236,30 +243,71 @@ const Page: React.FC = () => {
}} }}
/> />
</View> </View>
<View className='mb-4'> <View>
<GenreTags genres={details?.genres?.map((g) => g.name) || []} /> <GenreTags genres={details?.genres?.map((g) => g.name) || []} />
</View> </View>
{isLoading || isFetching ? ( {isLoading || isFetching ? (
<Button loading={true} disabled={true} color='purple' /> <Button
loading={true}
disabled={true}
color='purple'
className='mt-4'
/>
) : canRequest ? ( ) : canRequest ? (
<Button color='purple' onPress={request}> <Button color='purple' onPress={request} className='mt-4'>
{t("jellyseerr.request_button")} {t("jellyseerr.request_button")}
</Button> </Button>
) : ( ) : (
<Button details?.mediaInfo?.jellyfinMediaId && (
className='bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100' <View className='flex flex-row space-x-2 mt-4'>
color='transparent' {!Platform.isTV && (
onPress={() => bottomSheetModalRef?.current?.present()} <Button
iconLeft={ className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
<Ionicons name='warning-outline' size={24} color='white' /> color='transparent'
} onPress={() => bottomSheetModalRef?.current?.present()}
style={{ iconLeft={
borderWidth: 1, <Ionicons
borderStyle: "solid", name='warning-outline'
}} size={20}
> color='white'
{t("jellyseerr.report_issue_button")} />
</Button> }
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>
{t("jellyseerr.report_issue_button")}
</Text>
</Button>
)}
<Button
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
onPress={() => {
router.push({
pathname:
mediaType === MediaType.MOVIE
? "/(auth)/(tabs)/(search)/items/page"
: "/(auth)/(tabs)/(search)/series/[id]",
params:
mediaType === MediaType.MOVIE
? { id: details?.mediaInfo.jellyfinMediaId }
: { id: details?.mediaInfo.jellyfinMediaId },
});
}}
iconLeft={
<Ionicons name='play-outline' size={20} color='white' />
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>{t("common.play")}</Text>
</Button>
</View>
)
)} )}
<OverviewText text={result.overview} className='mt-4' /> <OverviewText text={result.overview} className='mt-4' />
</View> </View>
@@ -295,92 +343,95 @@ const Page: React.FC = () => {
}} }}
onDismiss={() => _setRequestBody(undefined)} onDismiss={() => _setRequestBody(undefined)}
/> />
<BottomSheetModal {!Platform.isTV && (
ref={bottomSheetModalRef} // This is till it's fixed because the menu isn't selectable on TV
enableDynamicSizing <BottomSheetModal
handleIndicatorStyle={{ ref={bottomSheetModalRef}
backgroundColor: "white", enableDynamicSizing
}} handleIndicatorStyle={{
backgroundStyle={{ backgroundColor: "white",
backgroundColor: "#171717", }}
}} backgroundStyle={{
backdropComponent={renderBackdrop} backgroundColor: "#171717",
> }}
<BottomSheetView> backdropComponent={renderBackdrop}
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'> >
<View> <BottomSheetView>
<Text className='font-bold text-2xl text-neutral-100'> <View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
{t("jellyseerr.whats_wrong")} <View>
</Text> <Text className='font-bold text-2xl text-neutral-100'>
</View> {t("jellyseerr.whats_wrong")}
<View className='flex flex-col space-y-2 items-start'> </Text>
<View className='flex flex-col'> </View>
<DropdownMenu.Root> <View className='flex flex-col space-y-2 items-start'>
<DropdownMenu.Trigger> <View className='flex flex-col'>
<View className='flex flex-col'> <DropdownMenu.Root>
<Text className='opacity-50 mb-1 text-xs'> <DropdownMenu.Trigger>
{t("jellyseerr.issue_type")} <View className='flex flex-col'>
</Text> <Text className='opacity-50 mb-1 text-xs'>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'> {t("jellyseerr.issue_type")}
<Text style={{}} className='' numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
</Text> </Text>
</TouchableOpacity> <TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
</View> <Text style={{}} className='' numberOfLines={1}>
</DropdownMenu.Trigger> {issueType
<DropdownMenu.Content ? IssueTypeName[issueType]
loop={false} : t("jellyseerr.select_an_issue")}
side='bottom' </Text>
align='center' </TouchableOpacity>
alignOffset={0} </View>
avoidCollisions={true} </DropdownMenu.Trigger>
collisionPadding={0} <DropdownMenu.Content
sideOffset={0} loop={false}
> side='bottom'
<DropdownMenu.Label> align='center'
{t("jellyseerr.types")} alignOffset={0}
</DropdownMenu.Label> avoidCollisions={true}
{Object.entries(IssueTypeName) collisionPadding={0}
.reverse() sideOffset={0}
.map(([key, value], idx) => ( >
<DropdownMenu.Item <DropdownMenu.Label>
key={value} {t("jellyseerr.types")}
onSelect={() => </DropdownMenu.Label>
setIssueType(key as unknown as IssueType) {Object.entries(IssueTypeName)
} .reverse()
> .map(([key, value], _idx) => (
<DropdownMenu.ItemTitle> <DropdownMenu.Item
{value} key={value}
</DropdownMenu.ItemTitle> onSelect={() =>
</DropdownMenu.Item> setIssueType(key as unknown as IssueType)
))} }
</DropdownMenu.Content> >
</DropdownMenu.Root> <DropdownMenu.ItemTitle>
</View> {value}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'> <View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
<BottomSheetTextInput <BottomSheetTextInput
multiline multiline
maxLength={254} maxLength={254}
style={{ color: "white" }} style={{ color: "white" }}
clearButtonMode='always' clearButtonMode='always'
placeholder={t("jellyseerr.describe_the_issue")} placeholder={t("jellyseerr.describe_the_issue")}
placeholderTextColor='#9CA3AF' placeholderTextColor='#9CA3AF'
// Issue with multiline + Textinput inside a portal // Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668 // https://github.com/callstack/react-native-paper/issues/1668
defaultValue={issueMessage} defaultValue={issueMessage}
onChangeText={setIssueMessage} onChangeText={setIssueMessage}
/> />
</View>
</View> </View>
<Button className='mt-auto' onPress={submitIssue} color='purple'>
{t("jellyseerr.submit_button")}
</Button>
</View> </View>
<Button className='mt-auto' onPress={submitIssue} color='purple'> </BottomSheetView>
{t("jellyseerr.submit_button")} </BottomSheetModal>
</Button> )}
</View>
</BottomSheetView>
</BottomSheetModal>
</View> </View>
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,3 @@
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import type { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -19,24 +7,36 @@ import { useAtom } from "jotai";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorizontalScroll";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
const page: React.FC = () => { const page: React.FC = () => {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { actorId } = local as { actorId: string }; const { personId } = local as { personId: string };
const { t } = useTranslation(); const { t } = useTranslation();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { data: item, isLoading: l1 } = useQuery({ const { data: item, isLoading: l1 } = useQuery({
queryKey: ["item", actorId], queryKey: ["item", personId],
queryFn: async () => queryFn: async () =>
await getUserItemData({ await getUserItemData({
api, api,
userId: user?.Id, userId: user?.Id,
itemId: actorId, itemId: personId,
}), }),
enabled: !!actorId && !!api, enabled: !!personId && !!api,
staleTime: 60, staleTime: 60,
}); });
@@ -50,7 +50,7 @@ const page: React.FC = () => {
const response = await getItemsApi(api).getItems({ const response = await getItemsApi(api).getItems({
userId: user.Id, userId: user.Id,
personIds: [actorId], personIds: [personId],
startIndex: pageParam, startIndex: pageParam,
limit: 16, limit: 16,
sortOrder: ["Descending", "Descending", "Ascending"], sortOrder: ["Descending", "Descending", "Ascending"],
@@ -68,7 +68,7 @@ const page: React.FC = () => {
return response.data; return response.data;
}, },
[api, user?.Id, actorId], [api, user?.Id, personId],
); );
const backdropUrl = useMemo( const backdropUrl = useMemo(
@@ -131,7 +131,7 @@ const page: React.FC = () => {
</TouchableItemRouter> </TouchableItemRouter>
)} )}
queryFn={fetchItems} queryFn={fetchItems}
queryKey={["actor", "movies", actorId]} queryKey={["actor", "movies", personId]}
/> />
<View className='h-12' /> <View className='h-12' />
</View> </View>

View File

@@ -1,13 +1,3 @@
import { AddToFavorites } from "@/components/AddToFavorites";
import { DownloadItems } from "@/components/DownloadItem";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { NextUp } from "@/components/series/NextUp";
import { SeasonPicker } from "@/components/series/SeasonPicker";
import { SeriesHeader } from "@/components/series/SeriesHeader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -18,6 +8,16 @@ import type React from "react";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { AddToFavorites } from "@/components/AddToFavorites";
import { DownloadItems } from "@/components/DownloadItem";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { NextUp } from "@/components/series/NextUp";
import { SeasonPicker } from "@/components/series/SeasonPicker";
import { SeriesHeader } from "@/components/series/SeriesHeader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
const page: React.FC = () => { const page: React.FC = () => {
const navigation = useNavigation(); const navigation = useNavigation();
@@ -69,10 +69,18 @@ const page: React.FC = () => {
seriesId: item?.Id!, seriesId: item?.Id!,
userId: user?.Id!, userId: user?.Id!,
enableUserData: true, enableUserData: true,
fields: ["MediaSources", "MediaStreams", "Overview"], // Note: Including trick play is necessary to enable trick play downloads
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
}); });
return res?.data.Items || []; return res?.data.Items || [];
}, },
select: (data) =>
// This needs to be sorted by parent index number and then index number, that way we can download the episodes in the correct order.
[...(data || [])].sort(
(a, b) =>
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
),
staleTime: 60, staleTime: 60,
enabled: !!api && !!user?.Id && !!item?.Id, enabled: !!api && !!user?.Id && !!item?.Id,
}); });
@@ -133,10 +141,10 @@ const page: React.FC = () => {
style={{ style={{
height: 130, height: 130,
width: "100%", width: "100%",
resizeMode: "contain",
}} }}
contentFit='contain'
/> />
) : null ) : undefined
} }
> >
<View className='flex flex-col pt-4'> <View className='flex flex-col pt-4'>

View File

@@ -1,34 +1,3 @@
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react";
import { FlatList, View, useWindowDimensions } from "react-native";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemPoster } from "@/components/posters/ItemPoster";
import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
SortByOption,
SortOrderOption,
genreFilterAtom,
getSortByPreference,
getSortOrderPreference,
sortByAtom,
sortByPreferenceAtom,
sortOptions,
sortOrderAtom,
sortOrderOptions,
sortOrderPreferenceAtom,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import type { import type {
BaseItemDto, BaseItemDto,
BaseItemDtoQueryResult, BaseItemDtoQueryResult,
@@ -40,8 +9,38 @@ import {
getUserLibraryApi, getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FlatList, useWindowDimensions, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
genreFilterAtom,
getSortByPreference,
getSortOrderPreference,
SortByOption,
SortOrderOption,
sortByAtom,
sortByPreferenceAtom,
sortOptions,
sortOrderAtom,
sortOrderOptions,
sortOrderPreferenceAtom,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
const Page = () => { const Page = () => {
const searchParams = useLocalSearchParams(); const searchParams = useLocalSearchParams();
@@ -78,7 +77,13 @@ const Page = () => {
} else { } else {
_setSortBy([SortByOption.SortName]); _setSortBy([SortByOption.SortName]);
} }
}, []); }, [
libraryId,
sortOrderPreference,
sortByPreference,
_setSortOrder,
_setSortBy,
]);
const setSortBy = useCallback( const setSortBy = useCallback(
(sortBy: SortByOption[]) => { (sortBy: SortByOption[]) => {
@@ -88,7 +93,7 @@ const Page = () => {
} }
_setSortBy(sortBy); _setSortBy(sortBy);
}, },
[libraryId, sortByPreference], [libraryId, sortByPreference, setSortByPreference, _setSortBy],
); );
const setSortOrder = useCallback( const setSortOrder = useCallback(
@@ -102,7 +107,7 @@ const Page = () => {
} }
_setSortOrder(sortOrder); _setSortOrder(sortOrder);
}, },
[libraryId, sortOrderPreference], [libraryId, sortOrderPreference, setOderByPreference, _setSortOrder],
); );
const nrOfCols = useMemo(() => { const nrOfCols = useMemo(() => {
@@ -169,7 +174,7 @@ const Page = () => {
fields: ["PrimaryImageAspectRatio", "SortName"], fields: ["PrimaryImageAspectRatio", "SortName"],
genres: selectedGenres, genres: selectedGenres,
tags: selectedTags, tags: selectedTags,
years: selectedYears.map((year) => Number.parseInt(year)), years: selectedYears.map((year) => Number.parseInt(year, 10)),
includeItemTypes: itemType ? [itemType] : undefined, includeItemTypes: itemType ? [itemType] : undefined,
}); });
@@ -433,15 +438,6 @@ const Page = () => {
</View> </View>
); );
if (flatData.length === 0)
return (
<View className='h-full w-full flex justify-center items-center'>
<Text className='text-lg text-neutral-500'>
{t("library.no_items_found")}
</Text>
</View>
);
return ( return (
<FlashList <FlashList
key={orientation} key={orientation}

View File

@@ -1,222 +1,85 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { Platform } from "react-native"; import { useState } from "react";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity } from "react-native";
import { LibraryOptionsSheet } from "@/components/settings/LibraryOptionsSheet";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useSettings } from "@/utils/atoms/settings";
export default function IndexLayout() { export default function IndexLayout() {
const [settings, updateSettings, pluginSettings] = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
const [optionsSheetOpen, setOptionsSheetOpen] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
if (!settings?.libraryOptions) return null; if (!settings?.libraryOptions) return null;
return ( return (
<Stack> <>
<Stack.Screen <Stack>
name='index' <Stack.Screen
options={{ name='index'
headerShown: true, options={{
headerLargeTitle: true, headerShown: !Platform.isTV,
headerTitle: t("tabs.library"), headerTitle: t("tabs.library"),
headerBlurEffect: "prominent", headerBlurEffect: "none",
headerLargeStyle: { headerTransparent: Platform.OS === "ios",
backgroundColor: "black", headerShadowVisible: false,
}, headerRight: () =>
headerTransparent: Platform.OS === "ios", !pluginSettings?.libraryOptions?.locked &&
headerShadowVisible: false, !Platform.isTV && (
headerRight: () => <TouchableOpacity
!pluginSettings?.libraryOptions?.locked && onPress={() => setOptionsSheetOpen(true)}
!Platform.isTV && ( className='flex flex-row items-center justify-center w-9 h-9'
<DropdownMenu.Root> >
<DropdownMenu.Trigger>
<Ionicons <Ionicons
name='ellipsis-horizontal-outline' name='ellipsis-horizontal-outline'
size={24} size={24}
color='white' color='white'
/> />
</DropdownMenu.Trigger> </TouchableOpacity>
<DropdownMenu.Content ),
align={"end"} }}
alignOffset={-10} />
avoidCollisions={false} <Stack.Screen
collisionPadding={0} name='[libraryId]'
loop={false} options={{
side={"bottom"} title: "",
sideOffset={10} headerShown: !Platform.isTV,
> headerBlurEffect: "none",
<DropdownMenu.Label> headerTransparent: Platform.OS === "ios",
{t("library.options.display")} headerShadowVisible: false,
</DropdownMenu.Label> }}
<DropdownMenu.Group key='display-group'> />
<DropdownMenu.Sub> {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<DropdownMenu.SubTrigger key='image-style-trigger'> <Stack.Screen key={name} name={name} options={options} />
{t("library.options.display")} ))}
</DropdownMenu.SubTrigger> <Stack.Screen
<DropdownMenu.SubContent name='collections/[collectionId]'
alignOffset={-10} options={{
avoidCollisions={true} title: "",
collisionPadding={0} headerShown: !Platform.isTV,
loop={true} headerBlurEffect: "none",
sideOffset={10} headerTransparent: Platform.OS === "ios",
> headerShadowVisible: false,
<DropdownMenu.CheckboxItem }}
key='display-option-1' />
value={settings.libraryOptions.display === "row"} </Stack>
onValueChange={() => <LibraryOptionsSheet
updateSettings({ open={optionsSheetOpen}
libraryOptions: { setOpen={setOptionsSheetOpen}
...settings.libraryOptions, settings={settings.libraryOptions}
display: "row", updateSettings={(options) =>
}, updateSettings({
}) libraryOptions: {
} ...settings.libraryOptions,
> ...options,
<DropdownMenu.ItemIndicator /> },
<DropdownMenu.ItemTitle key='display-title-1'> })
{t("library.options.row")} }
</DropdownMenu.ItemTitle> disabled={pluginSettings?.libraryOptions?.locked}
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key='display-option-2'
value={settings.libraryOptions.display === "list"}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "list",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='display-title-2'>
{t("library.options.list")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key='image-style-trigger'>
{t("library.options.image_style")}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
<DropdownMenu.CheckboxItem
key='poster-option'
value={
settings.libraryOptions.imageStyle === "poster"
}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "poster",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='poster-title'>
{t("library.options.poster")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key='cover-option'
value={settings.libraryOptions.imageStyle === "cover"}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "cover",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='cover-title'>
{t("library.options.cover")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Group>
<DropdownMenu.Group key='show-titles-group'>
<DropdownMenu.CheckboxItem
disabled={settings.libraryOptions.imageStyle === "poster"}
key='show-titles-option'
value={settings.libraryOptions.showTitles}
onValueChange={(newValue: string) => {
if (settings.libraryOptions.imageStyle === "poster")
return;
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showTitles: newValue === "on",
},
});
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='show-titles-title'>
{t("library.options.show_titles")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key='show-stats-option'
value={settings.libraryOptions.showStats}
onValueChange={(newValue: string) => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showStats: newValue === "on",
},
});
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='show-stats-title'>
{t("library.options.show_stats")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.Group>
<DropdownMenu.Separator />
</DropdownMenu.Content>
</DropdownMenu.Root>
),
}}
/> />
<Stack.Screen </>
name='[libraryId]'
options={{
title: "",
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name='collections/[collectionId]'
options={{
title: "",
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
</Stack>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,29 +1,8 @@
import { BITRATES } from "@/components/BitrateSelector";
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useHaptic } from "@/hooks/useHaptic";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules";
import type {
PipStartedPayload,
PlaybackStatePayload,
ProgressUpdatePayload,
VlcPlayerViewRef,
} from "@/modules/VlcPlayer.types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log";
import native from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
import { import {
type BaseItemDto, type BaseItemDto,
type MediaSourceInfo, type MediaSourceInfo,
PlaybackOrder, PlaybackOrder,
type PlaybackProgressInfo, PlaybackProgressInfo,
PlaybackStartInfo, PlaybackStartInfo,
RepeatMode, RepeatMode,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
@@ -32,22 +11,41 @@ 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 { useGlobalSearchParams, useNavigation } from "expo-router"; import { router, useGlobalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import React, { import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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, View } from "react-native";
import { useSharedValue } from "react-native-reanimated"; import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const downloadProvider = !Platform.isTV import { BITRATES } from "@/components/BitrateSelector";
? require("@/providers/DownloadProvider") import { Text } from "@/components/common/Text";
: null; import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls";
import {
OUTLINE_THICKNESS,
OutlineThickness,
VLC_COLORS,
VLCColor,
} from "@/constants/SubtitleConstants";
import { useHaptic } from "@/hooks/useHaptic";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules";
import type {
PlaybackStatePayload,
ProgressUpdatePayload,
VlcPlayerViewRef,
} from "@/modules/VlcPlayer.types";
import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log";
import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
export default function page() { export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null); const videoRef = useRef<VlcPlayerViewRef>(null);
@@ -58,19 +56,26 @@ export default function page() {
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false); const [aspectRatio, setAspectRatio] = useState<
"default" | "16:9" | "4:3" | "1:1" | "21:9"
>("default");
const [scaleFactor, setScaleFactor] = useState<
1.0 | 1.1 | 1.2 | 1.3 | 1.4 | 1.5 | 1.6 | 1.7 | 1.8 | 1.9 | 2.0
>(1.0);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [isBuffering, setIsBuffering] = useState(true); const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false); const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [isPipStarted, setIsPipStarted] = useState(false);
const progress = useSharedValue(0); const progress = useSharedValue(0);
const isSeeking = useSharedValue(false); const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0); const cacheProgress = useSharedValue(0);
let getDownloadedItem = null; const VolumeManager = Platform.isTV
if (!Platform.isTV) { ? null
getDownloadedItem = downloadProvider.useDownload(); : require("react-native-volume-manager");
}
const downloadUtils = useDownload();
const downloadedFiles = downloadUtils.getDownloadedItems();
const revalidateProgressCache = useInvalidatePlaybackProgressCache(); const revalidateProgressCache = useInvalidatePlaybackProgressCache();
@@ -88,6 +93,7 @@ export default function page() {
mediaSourceId, mediaSourceId,
bitrateValue: bitrateValueStr, bitrateValue: bitrateValueStr,
offline: offlineStr, offline: offlineStr,
playbackPosition: playbackPositionFromUrl,
} = useGlobalSearchParams<{ } = useGlobalSearchParams<{
itemId: string; itemId: string;
audioIndex: string; audioIndex: string;
@@ -95,10 +101,13 @@ export default function page() {
mediaSourceId: string; mediaSourceId: string;
bitrateValue: string; bitrateValue: string;
offline: string; offline: string;
/** Playback position in ticks. */
playbackPosition?: string;
}>(); }>();
const [settings] = useSettings(); const { settings } = useSettings();
const insets = useSafeAreaInsets();
const offline = offlineStr === "true"; const offline = offlineStr === "true";
const playbackManager = usePlaybackManager();
const audioIndex = audioIndexStr const audioIndex = audioIndexStr
? Number.parseInt(audioIndexStr, 10) ? Number.parseInt(audioIndexStr, 10)
@@ -111,19 +120,33 @@ export default function page() {
: BITRATES[0].value; : BITRATES[0].value;
const [item, setItem] = useState<BaseItemDto | null>(null); const [item, setItem] = useState<BaseItemDto | null>(null);
const [downloadedItem, setDownloadedItem] = useState<DownloadedItem | null>(
null,
);
const [itemStatus, setItemStatus] = useState({ const [itemStatus, setItemStatus] = useState({
isLoading: true, isLoading: true,
isError: false, isError: false,
}); });
/** Gets the initial playback position from the URL. */
const getInitialPlaybackTicks = useCallback((): number => {
if (playbackPositionFromUrl) {
return Number.parseInt(playbackPositionFromUrl, 10);
}
return item?.UserData?.PlaybackPositionTicks ?? 0;
}, [playbackPositionFromUrl]);
useEffect(() => { useEffect(() => {
const fetchItemData = async () => { const fetchItemData = async () => {
setItemStatus({ isLoading: true, isError: false }); setItemStatus({ isLoading: true, isError: false });
try { try {
let fetchedItem: BaseItemDto | null = null; let fetchedItem: BaseItemDto | null = null;
if (offline && !Platform.isTV) { if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId); const data = downloadUtils.getDownloadedItemById(itemId);
if (data) fetchedItem = data.item as BaseItemDto; if (data) {
fetchedItem = data.item as BaseItemDto;
setDownloadedItem(data);
}
} else { } else {
const res = await getUserLibraryApi(api!).getItem({ const res = await getUserLibraryApi(api!).getItem({
itemId, itemId,
@@ -132,11 +155,10 @@ export default function page() {
fetchedItem = res.data; fetchedItem = res.data;
} }
setItem(fetchedItem); setItem(fetchedItem);
setItemStatus({ isLoading: false, isError: false });
} catch (error) { } catch (error) {
console.error("Failed to fetch item:", error); console.error("Failed to fetch item:", error);
setItemStatus({ isLoading: false, isError: true }); setItemStatus({ isLoading: false, isError: true });
} finally {
setItemStatus({ isLoading: false, isError: false });
} }
}; };
@@ -159,26 +181,50 @@ export default function page() {
useEffect(() => { useEffect(() => {
const fetchStreamData = async () => { const fetchStreamData = async () => {
setStreamStatus({ isLoading: true, isError: false });
try { try {
// Don't attempt to fetch stream data if item is not available
if (!item?.Id) {
console.log("Item not loaded yet, skipping stream data fetch");
setStreamStatus({ isLoading: false, isError: false });
return;
}
let result: Stream | null = null; let result: Stream | null = null;
if (offline && !Platform.isTV) { if (offline && downloadedItem && downloadedItem.mediaSource) {
const data = await getDownloadedItem.getDownloadedItem(itemId); const url = downloadedItem.videoFilePath;
if (!data?.mediaSource) return;
const url = await getDownloadedFileUrl(data.item.Id!);
if (item) { if (item) {
result = { mediaSource: data.mediaSource, sessionId: "", url }; result = {
mediaSource: downloadedItem.mediaSource,
sessionId: "",
url: url,
};
} }
} else { } else {
// Validate required parameters before calling getStreamUrl
if (!api) {
console.warn("API not available for streaming");
setStreamStatus({ isLoading: false, isError: true });
return;
}
if (!user?.Id) {
console.warn("User not authenticated for streaming");
setStreamStatus({ isLoading: false, isError: true });
return;
}
const native = generateDeviceProfile();
const transcoding = generateDeviceProfile({ transcode: true });
const res = await getStreamUrl({ const res = await getStreamUrl({
api, api,
item, item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!, startTimeTicks: getInitialPlaybackTicks(),
userId: user?.Id, userId: user.Id,
audioStreamIndex: audioIndex, audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue, maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex, subtitleStreamIndex: subtitleIndex,
deviceProfile: native, deviceProfile: bitrateValue ? transcoding : native,
}); });
if (!res) return; if (!res) return;
const { mediaSource, sessionId, url } = res; const { mediaSource, sessionId, url } = res;
@@ -192,34 +238,41 @@ export default function page() {
result = { mediaSource, sessionId, url }; result = { mediaSource, sessionId, url };
} }
setStream(result); setStream(result);
setStreamStatus({ isLoading: false, isError: false });
} catch (error) { } catch (error) {
console.error("Failed to fetch stream:", error); console.error("Failed to fetch stream:", error);
setStreamStatus({ isLoading: false, isError: true }); setStreamStatus({ isLoading: false, isError: true });
} finally {
setStreamStatus({ isLoading: false, isError: false });
} }
}; };
fetchStreamData(); fetchStreamData();
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]); }, [
itemId,
mediaSourceId,
bitrateValue,
api,
item,
user?.Id,
downloadedItem,
]);
useEffect(() => { useEffect(() => {
if (!stream) return; if (!stream || !api) return;
const reportPlaybackStart = async () => { const reportPlaybackStart = async () => {
await getPlaystateApi(api!).reportPlaybackStart({ await getPlaystateApi(api).reportPlaybackStart({
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo, playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
}); });
}; };
reportPlaybackStart(); reportPlaybackStart();
}, [stream]); }, [stream, api]);
const togglePlay = async () => { const togglePlay = async () => {
lightHapticFeedback(); lightHapticFeedback();
setIsPlaying(!isPlaying); setIsPlaying(!isPlaying);
if (isPlaying) { if (isPlaying) {
await videoRef.current?.pause(); await videoRef.current?.pause();
reportPlaybackStopped(); playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo,
);
} else { } else {
videoRef.current?.play(); videoRef.current?.play();
await getPlaystateApi(api!).reportPlaybackStart({ await getPlaystateApi(api!).reportPlaybackStart({
@@ -229,7 +282,6 @@ export default function page() {
}; };
const reportPlaybackStopped = useCallback(async () => { const reportPlaybackStopped = useCallback(async () => {
if (offline) return;
const currentTimeInTicks = msToTicks(progress.get()); const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({ await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!, itemId: item?.Id!,
@@ -237,15 +289,26 @@ export default function page() {
positionTicks: currentTimeInTicks, positionTicks: currentTimeInTicks,
playSessionId: stream?.sessionId!, playSessionId: stream?.sessionId!,
}); });
}, [
revalidateProgressCache(); api,
}, [api, item, mediaSourceId, stream]); item,
mediaSourceId,
stream,
progress,
offline,
revalidateProgressCache,
]);
const stop = useCallback(() => { const stop = useCallback(() => {
// Update URL with final playback position before stopping
router.setParams({
playbackPosition: msToTicks(progress.get()).toString(),
});
reportPlaybackStopped(); reportPlaybackStopped();
setIsPlaybackStopped(true); setIsPlaybackStopped(true);
videoRef.current?.stop(); videoRef.current?.stop();
}, [videoRef, reportPlaybackStopped]); revalidateProgressCache();
}, [videoRef, reportPlaybackStopped, progress]);
useEffect(() => { useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop); const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
@@ -254,7 +317,7 @@ export default function page() {
}; };
}, [navigation, stop]); }, [navigation, stop]);
const currentPlayStateInfo = () => { const currentPlayStateInfo = useCallback(() => {
if (!stream) return; if (!stream) return;
return { return {
itemId: item?.Id!, itemId: item?.Id!,
@@ -265,12 +328,37 @@ export default function page() {
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: false, isMuted: isMuted,
canSeek: true, canSeek: true,
repeatMode: RepeatMode.RepeatNone, repeatMode: RepeatMode.RepeatNone,
playbackOrder: PlaybackOrder.Default, playbackOrder: PlaybackOrder.Default,
}; };
}; }, [
stream,
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
progress,
isPlaying,
isMuted,
]);
const lastUrlUpdateTime = useSharedValue(0);
const wasJustSeeking = useSharedValue(false);
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second
// Track when seeking ends to update URL immediately
useAnimatedReaction(
() => isSeeking.get(),
(currentSeeking, previousSeeking) => {
if (previousSeeking && !currentSeeking) {
// Seeking just ended
wasJustSeeking.value = true;
}
},
[],
);
const onProgress = useCallback( const onProgress = useCallback(
async (data: ProgressUpdatePayload) => { async (data: ProgressUpdatePayload) => {
@@ -283,11 +371,26 @@ export default function page() {
progress.set(currentTime); progress.set(currentTime);
if (offline) return; // Update URL immediately after seeking, or every 30 seconds during normal playback
const now = Date.now();
const shouldUpdateUrl = wasJustSeeking.get();
wasJustSeeking.value = false;
if (!item?.Id || !stream) return; if (
shouldUpdateUrl ||
now - lastUrlUpdateTime.get() > URL_UPDATE_INTERVAL
) {
router.setParams({
playbackPosition: msToTicks(currentTime).toString(),
});
lastUrlUpdateTime.value = now;
}
reportPlaybackProgress(); if (!item?.Id) return;
playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo,
);
}, },
[ [
item?.Id, item?.Id,
@@ -302,40 +405,88 @@ export default function page() {
], ],
); );
const onPipStarted = useCallback((e: PipStartedPayload) => { /** Gets the initial playback position in seconds. */
const { pipStarted } = e.nativeEvent; const startPosition = useMemo(() => {
setIsPipStarted(pipStarted); return ticksToSeconds(getInitialPlaybackTicks());
}, [getInitialPlaybackTicks]);
const volumeUpCb = useCallback(async () => {
if (Platform.isTV) return;
try {
const { volume: currentVolume } = await VolumeManager.getVolume();
const newVolume = Math.min(currentVolume + 0.1, 1.0);
await VolumeManager.setVolume(newVolume);
} catch (error) {
console.error("Error adjusting volume:", error);
}
}, []);
const [previousVolume, setPreviousVolume] = useState<number | null>(null);
const toggleMuteCb = useCallback(async () => {
if (Platform.isTV) return;
try {
const { volume: currentVolume } = await VolumeManager.getVolume();
const currentVolumePercent = currentVolume * 100;
if (currentVolumePercent > 0) {
// Currently not muted, so mute
setPreviousVolume(currentVolumePercent);
await VolumeManager.setVolume(0);
setIsMuted(true);
} else {
// Currently muted, so restore previous volume
const volumeToRestore = previousVolume || 50; // Default to 50% if no previous volume
await VolumeManager.setVolume(volumeToRestore / 100);
setPreviousVolume(null);
setIsMuted(false);
}
} catch (error) {
console.error("Error toggling mute:", error);
}
}, [previousVolume]);
const volumeDownCb = useCallback(async () => {
if (Platform.isTV) return;
try {
const { volume: currentVolume } = await VolumeManager.getVolume();
const newVolume = Math.max(currentVolume - 0.1, 0); // Decrease by 10%
console.log(
"Volume Down",
Math.round(currentVolume * 100),
"→",
Math.round(newVolume * 100),
);
await VolumeManager.setVolume(newVolume);
} catch (error) {
console.error("Error adjusting volume:", error);
}
}, []); }, []);
const reportPlaybackProgress = useCallback(async () => { const setVolumeCb = useCallback(async (newVolume: number) => {
if (!api || offline || !stream) return; if (Platform.isTV) return;
await getPlaystateApi(api).reportPlaybackProgress({
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
});
}, [
api,
isPlaying,
offline,
stream,
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
progress,
]);
const startPosition = useMemo(() => { try {
if (offline) return 0; const clampedVolume = Math.max(0, Math.min(newVolume, 100));
return item?.UserData?.PlaybackPositionTicks console.log("Setting volume to", clampedVolume);
? ticksToSeconds(item.UserData.PlaybackPositionTicks) await VolumeManager.setVolume(clampedVolume / 100);
: 0; } catch (error) {
}, [item]); console.error("Error setting volume:", error);
}
}, []);
useWebSocket({ useWebSocket({
isPlaying: isPlaying, isPlaying: isPlaying,
togglePlay: togglePlay, togglePlay: togglePlay,
stopPlayback: stop, stopPlayback: stop,
offline, offline,
toggleMute: toggleMuteCb,
volumeUp: volumeUpCb,
volumeDown: volumeDownCb,
setVolume: setVolumeCb,
}); });
const onPlaybackStateChanged = useCallback( const onPlaybackStateChanged = useCallback(
@@ -343,14 +494,22 @@ export default function page() {
const { state, isBuffering, isPlaying } = e.nativeEvent; const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") { if (state === "Playing") {
setIsPlaying(true); setIsPlaying(true);
reportPlaybackProgress(); if (item?.Id) {
playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo,
);
}
if (!Platform.isTV) await activateKeepAwakeAsync(); if (!Platform.isTV) await activateKeepAwakeAsync();
return; return;
} }
if (state === "Paused") { if (state === "Paused") {
setIsPlaying(false); setIsPlaying(false);
reportPlaybackProgress(); if (item?.Id) {
playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo,
);
}
if (!Platform.isTV) await deactivateKeepAwake(); if (!Platform.isTV) await deactivateKeepAwake();
return; return;
} }
@@ -362,7 +521,7 @@ export default function page() {
setIsBuffering(true); setIsBuffering(true);
} }
}, },
[reportPlaybackProgress], [playbackManager, item?.Id, progress],
); );
const allAudio = const allAudio =
@@ -380,28 +539,58 @@ export default function page() {
.filter((sub: any) => sub.DeliveryMethod === "External") .filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({ .map((sub: any) => ({
name: sub.DisplayTitle, name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl, DeliveryUrl: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl,
})); }));
/** The text based subtitle tracks */
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream); const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
/** The user chosen subtitle track from the server */
const chosenSubtitleTrack = allSubs.find( const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex, (sub) => sub.Index === subtitleIndex,
); );
/** The user chosen audio track from the server */
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex); const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
/** Whether the stream we're playing is not transcoding*/
const notTranscoding = !stream?.mediaSource.TranscodingUrl; const notTranscoding = !stream?.mediaSource.TranscodingUrl;
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`]; /** The initial options to pass to the VLC Player */
const initOptions = [``];
if ( if (
chosenSubtitleTrack && chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream) (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
) { ) {
// If not transcoding, we can the index as normal.
// If transcoding, we need to reverse the text based subtitles, because VLC reverses the HLS subtitles.
const finalIndex = notTranscoding const finalIndex = notTranscoding
? allSubs.indexOf(chosenSubtitleTrack) ? allSubs.indexOf(chosenSubtitleTrack)
: textSubs.indexOf(chosenSubtitleTrack); : [...textSubs].reverse().indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`); initOptions.push(`--sub-track=${finalIndex}`);
}
// 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) { if (notTranscoding && chosenAudioTrack) {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`); initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
} }
@@ -414,7 +603,72 @@ export default function page() {
return () => setIsMounted(false); return () => setIsMounted(false);
}, []); }, []);
if (itemStatus.isLoading || streamStatus.isLoading) { // Memoize video ref functions to prevent unnecessary re-renders
const startPictureInPicture = useCallback(async () => {
return videoRef.current?.startPictureInPicture?.();
}, []);
const play = useCallback(() => {
videoRef.current?.play?.();
}, []);
const pause = useCallback(() => {
videoRef.current?.pause?.();
}, []);
const seek = useCallback((position: number) => {
videoRef.current?.seekTo?.(position);
}, []);
const getAudioTracks = useCallback(async () => {
return videoRef.current?.getAudioTracks?.() || null;
}, []);
const getSubtitleTracks = useCallback(async () => {
return videoRef.current?.getSubtitleTracks?.() || null;
}, []);
const setSubtitleTrack = useCallback((index: number) => {
videoRef.current?.setSubtitleTrack?.(index);
}, []);
const setSubtitleURL = useCallback((url: string, _customName?: string) => {
// Note: VlcPlayer type only expects url parameter
videoRef.current?.setSubtitleURL?.(url);
}, []);
const setAudioTrack = useCallback((index: number) => {
videoRef.current?.setAudioTrack?.(index);
}, []);
const setVideoAspectRatio = useCallback(
async (aspectRatio: string | null) => {
return (
videoRef.current?.setVideoAspectRatio?.(aspectRatio) ||
Promise.resolve()
);
},
[],
);
const setVideoScaleFactor = useCallback(async (scaleFactor: number) => {
return (
videoRef.current?.setVideoScaleFactor?.(scaleFactor) || Promise.resolve()
);
}, []);
console.log("Debug: component render"); // Uncomment to debug re-renders
// Show error UI first, before checking loading/missingdata
if (itemStatus.isError || streamStatus.isError) {
return (
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
<Text className='text-white'>{t("player.error")}</Text>
</View>
);
}
// Then show loader while either side is still fetching or data isnt present
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
// …loader UI…
return ( return (
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'> <View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
<Loader /> <Loader />
@@ -422,7 +676,7 @@ export default function page() {
); );
} }
if (!item || !stream || itemStatus.isError || streamStatus.isError) if (itemStatus.isError || streamStatus.isError)
return ( return (
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'> <View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
<Text className='text-white'>{t("player.error")}</Text> <Text className='text-white'>{t("player.error")}</Text>
@@ -430,7 +684,14 @@ export default function page() {
); );
return ( return (
<View style={{ flex: 1, backgroundColor: "black" }}> <View
style={{
flex: 1,
backgroundColor: "black",
height: "100%",
width: "100%",
}}
>
<View <View
style={{ style={{
display: "flex", display: "flex",
@@ -439,8 +700,6 @@ export default function page() {
position: "relative", position: "relative",
flexDirection: "column", flexDirection: "column",
justifyContent: "center", justifyContent: "center",
paddingLeft: ignoreSafeAreas ? 0 : insets.left,
paddingRight: ignoreSafeAreas ? 0 : insets.right,
}} }}
> >
<VlcPlayerView <VlcPlayerView
@@ -448,7 +707,7 @@ export default function page() {
source={{ source={{
uri: stream?.url || "", uri: stream?.url || "",
autoplay: true, autoplay: true,
isNetwork: true, isNetwork: !offline,
startPosition, startPosition,
externalSubtitles, externalSubtitles,
initOptions, initOptions,
@@ -457,7 +716,6 @@ export default function page() {
onVideoProgress={onProgress} onVideoProgress={onProgress}
progressUpdateInterval={1000} progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged} onVideoStateChange={onPlaybackStateChanged}
onPipStarted={onPipStarted}
onVideoLoadEnd={() => { onVideoLoadEnd={() => {
setIsVideoLoaded(true); setIsVideoLoaded(true);
}} }}
@@ -471,7 +729,7 @@ export default function page() {
}} }}
/> />
</View> </View>
{videoRef.current && !isPipStarted && isMounted === true ? ( {isMounted === true && item && (
<Controls <Controls
mediaSource={stream?.mediaSource} mediaSource={stream?.mediaSource}
item={item} item={item}
@@ -484,23 +742,28 @@ export default function page() {
isBuffering={isBuffering} isBuffering={isBuffering}
showControls={showControls} showControls={showControls}
setShowControls={setShowControls} setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded} isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef?.current?.startPictureInPicture} startPictureInPicture={startPictureInPicture}
play={videoRef.current?.play} play={play}
pause={videoRef.current?.pause} pause={pause}
seek={videoRef.current?.seekTo} seek={seek}
enableTrickplay={true} enableTrickplay={true}
getAudioTracks={videoRef.current?.getAudioTracks} getAudioTracks={getAudioTracks}
getSubtitleTracks={videoRef.current?.getSubtitleTracks} getSubtitleTracks={getSubtitleTracks}
offline={offline} offline={offline}
setSubtitleTrack={videoRef.current.setSubtitleTrack} setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={videoRef.current.setSubtitleURL} setSubtitleURL={setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack} setAudioTrack={setAudioTrack}
setVideoAspectRatio={setVideoAspectRatio}
setVideoScaleFactor={setVideoScaleFactor}
aspectRatio={aspectRatio}
scaleFactor={scaleFactor}
setAspectRatio={setAspectRatio}
setScaleFactor={setScaleFactor}
isVlc isVlc
downloadedFiles={downloadedFiles}
/> />
) : null} )}
</View> </View>
); );
} }

View File

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

View File

@@ -1,13 +1,15 @@
import "@/augmentations"; import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { Platform } from "react-native";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider"; import { DownloadProvider } from "@/providers/DownloadProvider";
import { import {
JellyfinProvider,
apiAtom, apiAtom,
getOrSetDeviceId, getOrSetDeviceId,
getTokenFromStorage, getTokenFromStorage,
JellyfinProvider,
} from "@/providers/JellyfinProvider"; } from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { type Settings, useSettings } from "@/utils/atoms/settings"; import { type Settings, useSettings } from "@/utils/atoms/settings";
@@ -18,41 +20,39 @@ import {
} from "@/utils/background-tasks"; } from "@/utils/background-tasks";
import { import {
LogProvider, LogProvider,
writeDebugLog,
writeErrorLog, writeErrorLog,
writeInfoLog,
writeToLog, writeToLog,
} from "@/utils/log"; } from "@/utils/log";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Platform } from "react-native";
const BackGroundDownloader = !Platform.isTV const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader") ? require("@kesha-antonov/react-native-background-downloader")
: null; : null;
import { DarkTheme, ThemeProvider } from "@react-navigation/native"; import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const BackgroundFetch = !Platform.isTV
? require("expo-background-fetch") import * as BackgroundTask from "expo-background-task";
: null;
import * as Device from "expo-device"; import * as Device from "expo-device";
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
const Notifications = !Platform.isTV ? require("expo-notifications") : null; const Notifications = !Platform.isTV ? require("expo-notifications") : null;
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { Stack, router, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
import { getLocales } from "expo-localization"; import { getLocales } from "expo-localization";
import { router, Stack, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider } from "jotai"; import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider } from "react-i18next";
import { AppState, Appearance } from "react-native"; import { Appearance, AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import "react-native-reanimated"; import "react-native-reanimated";
import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import type { EventSubscription } from "expo-modules-core"; import type { EventSubscription } from "expo-modules-core";
import type { import type {
@@ -62,6 +62,8 @@ import type {
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types"; import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { Toaster } from "sonner-native"; import { Toaster } from "sonner-native";
import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store";
if (!Platform.isTV) { if (!Platform.isTV) {
Notifications.setNotificationHandler({ Notifications.setNotificationHandler({
@@ -82,18 +84,18 @@ 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() {
if (Platform.isTV) return;
useEffect(() => { useEffect(() => {
let isMounted = true; if (Platform.isTV) return;
function redirect(notification: typeof Notifications.Notification) { let isMounted = true;
const url = notification.request.content.data?.url;
if (url) {
router.push(url);
}
}
Notifications.getLastNotificationResponseAsync().then( Notifications.getLastNotificationResponseAsync().then(
(response: { notification: any }) => { (response: { notification: any }) => {
@@ -104,15 +106,8 @@ function useNotificationObserver() {
}, },
); );
const subscription = Notifications.addNotificationResponseReceivedListener(
(response: { notification: any }) => {
redirect(response.notification);
},
);
return () => { return () => {
isMounted = false; isMounted = false;
subscription.remove();
}; };
}, []); }, []);
} }
@@ -131,101 +126,30 @@ if (!Platform.isTV) {
const result = response.data.filter((s) => s.NowPlayingItem); const result = response.data.filter((s) => s.NowPlayingItem);
Notifications.setBadgeCountAsync(result.length); Notifications.setBadgeCountAsync(result.length);
return BackgroundFetch.BackgroundFetchResult.NewData; return BackgroundTask.BackgroundTaskResult.Success;
}); });
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
console.log("TaskManager ~ trigger"); console.log("TaskManager ~ trigger");
const now = Date.now();
const settingsData = storage.getString("settings"); const settingsData = storage.getString("settings");
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData; if (!settingsData) return BackgroundTask.BackgroundTaskResult.Failed;
const settings: Partial<Settings> = JSON.parse(settingsData); const settings: Partial<Settings> = JSON.parse(settingsData);
const url = settings?.optimizedVersionsServerUrl;
if (!settings?.autoDownload || !url) if (!settings?.autoDownload)
return BackgroundFetch.BackgroundFetchResult.NoData; return BackgroundTask.BackgroundTaskResult.Failed;
const token = getTokenFromStorage(); const token = getTokenFromStorage();
const deviceId = getOrSetDeviceId(); const deviceId = getOrSetDeviceId();
const baseDirectory = FileSystem.documentDirectory; const baseDirectory = FileSystem.documentDirectory;
if (!token || !deviceId || !baseDirectory) if (!token || !deviceId || !baseDirectory)
return BackgroundFetch.BackgroundFetchResult.NoData; return BackgroundTask.BackgroundTaskResult.Failed;
const jobs = await getAllJobsByDeviceId({
deviceId,
authHeader: token,
url,
});
console.log("TaskManager ~ Active jobs: ", jobs.length);
for (const job of jobs) {
if (job.status === "completed") {
const downloadUrl = `${url}download/${job.id}`;
const tasks = await BackGroundDownloader.checkForExistingDownloads();
if (tasks.find((task: { id: string }) => task.id === job.id)) {
console.log("TaskManager ~ Download already in progress: ", job.id);
continue;
}
BackGroundDownloader.download({
id: job.id,
url: downloadUrl,
destination: `${baseDirectory}${job.item.Id}.mp4`,
headers: {
Authorization: token,
},
})
.begin(() => {
console.log("TaskManager ~ Download started: ", job.id);
})
.done(() => {
console.log("TaskManager ~ Download completed: ", job.id);
saveDownloadedItemInfo(job.item);
BackGroundDownloader.completeHandler(job.id);
cancelJobById({
authHeader: token,
id: job.id,
url: url,
});
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: "Download completed",
data: {
url: "/downloads",
},
},
trigger: null,
});
})
.error((error: any) => {
console.log("TaskManager ~ Download error: ", job.id, error);
BackGroundDownloader.completeHandler(job.id);
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: "Download failed",
data: {
url: "/downloads",
},
},
trigger: null,
});
});
}
}
console.log(`Auto download started: ${new Date(now).toISOString()}`);
// Be sure to return the successful result type! // Be sure to return the successful result type!
return BackgroundFetch.BackgroundFetchResult.NewData; return BackgroundTask.BackgroundTaskResult.Success;
}); });
} }
@@ -234,22 +158,31 @@ const checkAndRequestPermissions = async () => {
const hasAskedBefore = storage.getString( const hasAskedBefore = storage.getString(
"hasAskedForNotificationPermission", "hasAskedForNotificationPermission",
); );
let granted = false;
if (hasAskedBefore !== "true") { if (hasAskedBefore !== "true") {
const { status } = await Notifications.requestPermissionsAsync(); const { status } = await Notifications.requestPermissionsAsync();
granted = status === "granted";
if (status === "granted") { if (granted) {
writeToLog("INFO", "Notification permissions granted."); writeToLog("INFO", "Notification permissions granted.");
console.log("Notification permissions granted."); console.log("Notification permissions granted.");
} else { } else {
writeToLog("ERROR", "Notification permissions denied."); writeToLog("ERROR", "Notification permissions denied.");
console.log("Notification permissions denied."); console.log("Notification permissions denied.");
} }
storage.set("hasAskedForNotificationPermission", "true"); storage.set("hasAskedForNotificationPermission", "true");
} else { } else {
console.log("Already asked for notification permissions before."); // Already asked before, check current status
const { status } = await Notifications.getPermissionsAsync();
granted = status === "granted";
if (!granted) {
writeToLog(
"ERROR",
"Notification permissions denied (already asked before).",
);
console.log("Notification permissions denied (already asked before).");
}
} }
return granted;
} catch (error) { } catch (error) {
writeToLog( writeToLog(
"ERROR", "ERROR",
@@ -257,6 +190,7 @@ const checkAndRequestPermissions = async () => {
error, error,
); );
console.error("Error checking/requesting notification permissions:", error); console.error("Error checking/requesting notification permissions:", error);
return false;
} }
}; };
@@ -289,7 +223,7 @@ const queryClient = new QueryClient({
}); });
function Layout() { function Layout() {
const [settings] = useSettings(); const { settings } = useSettings();
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const appState = useRef(AppState.currentState); const appState = useRef(AppState.currentState);
@@ -301,52 +235,67 @@ function Layout() {
); );
}, [settings?.preferedLanguage, i18n]); }, [settings?.preferedLanguage, i18n]);
if (!Platform.isTV) { useNotificationObserver();
useNotificationObserver();
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>(); const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>();
const notificationListener = useRef<EventSubscription>(); const notificationListener = useRef<EventSubscription>(null);
const responseListener = useRef<EventSubscription>(); const responseListener = useRef<EventSubscription>(null);
useEffect(() => { useEffect(() => {
if (expoPushToken && api && user) { if (!Platform.isTV && expoPushToken && api && user) {
api api
?.post("/Streamyfin/device", { ?.post("/Streamyfin/device", {
token: expoPushToken.data, token: expoPushToken.data,
deviceId: getOrSetDeviceId(), deviceId: getOrSetDeviceId(),
userId: user.Id, userId: user.Id,
}) })
.then((_) => console.log("Posted expo push token")) .then((_) => console.log("Posted expo push token"))
.catch((_) => .catch((_) =>
writeErrorLog("Failed to push expo push token to plugin"), writeErrorLog("Failed to push expo push token to plugin"),
); );
} else console.log("No token available"); } else console.log("No token available");
}, [api, expoPushToken, user]); }, [api, expoPushToken, user]);
async function registerNotifications() { async function registerNotifications() {
if (Platform.OS === "android") { if (Platform.OS === "android") {
console.log("Setting android notification channel 'default'"); console.log("Setting android notification channel 'default'");
await Notifications?.setNotificationChannelAsync("default", { await Notifications?.setNotificationChannelAsync("default", {
name: "default", name: "default",
}); });
}
await checkAndRequestPermissions(); // Create dedicated channel for download notifications
console.log("Setting android notification channel 'downloads'");
if (!Platform.isTV && user && user.Policy?.IsAdministrator) { await Notifications?.setNotificationChannelAsync("downloads", {
await registerBackgroundFetchAsyncSessions(); name: "Downloads",
} importance: Notifications.AndroidImportance.DEFAULT,
vibrationPattern: [0, 250, 250, 250],
// only create push token for real devices (pointless for emulators) lightColor: "#FF231F7C",
if (Device.isDevice) { });
Notifications?.getExpoPushTokenAsync()
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
.catch((reason: any) => console.log("Failed to get token", reason));
}
} }
useEffect(() => { const granted = await checkAndRequestPermissions();
registerNotifications(); if (!granted) {
console.log(
"Notification permissions not granted, skipping background fetch and push token registration.",
);
return;
}
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
await registerBackgroundFetchAsyncSessions();
}
// only create push token for real devices (pointless for emulators)
if (Device.isDevice) {
Notifications?.getExpoPushTokenAsync()
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
.catch((reason: any) => console.log("Failed to get token", reason));
}
}
useEffect(() => {
if (!Platform.isTV) {
void registerNotifications();
notificationListener.current = notificationListener.current =
Notifications?.addNotificationReceivedListener( Notifications?.addNotificationReceivedListener(
@@ -361,177 +310,165 @@ 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);
writeDebugLog( let url: any;
`Notification ${title} opened`, const type = (data?.type ?? "").toString().toLowerCase();
response.notification.request.content, const itemId = data?.id;
);
if (data && Object.keys(data).length > 0) { switch (type) {
const type = data?.type?.toLower?.(); case "movie":
const itemId = data?.id; url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
break;
switch (type) { case "episode":
case "movie": // `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`); // We just clicked a notification for an individual episode.
break; if (itemId) {
case "episode": url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
// We just clicked a notification for an individual episode.
if (itemId) {
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
}
// summarized season notification for multiple episodes. Bring them to series season // summarized season notification for multiple episodes. Bring them to series season
else { } else {
const seriesId = data.seriesId; const seriesId = data.seriesId;
const seasonIndex = data.seasonIndex; const seasonIndex = data.seasonIndex;
if (seasonIndex) {
if (seasonIndex) { url = `/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`;
router.push( } else {
`/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`, url = `/(auth)/(tabs)/home/series/${seriesId}`;
);
} else {
router.push(`/(auth)/(tabs)/home/series/${seriesId}`);
}
} }
break; }
} break;
}
writeInfoLog(`Notification attempting to redirect to ${url}`);
if (url) {
router.push(url);
} }
}, },
); );
return () => { return () => {
notificationListener.current && notificationListener.current?.remove();
Notifications?.removeNotificationSubscription( responseListener.current?.remove();
notificationListener.current,
);
responseListener.current &&
Notifications?.removeNotificationSubscription(
responseListener.current,
);
}; };
}, []); }
}, [user, api]);
useEffect(() => { useEffect(() => {
if (Platform.isTV) return; if (Platform.isTV) {
if (segments.includes("direct-player" as never)) { return;
return; }
if (segments.includes("direct-player" as never)) {
if (
!settings.followDeviceOrientation &&
settings.defaultVideoOrientation
) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
} }
return;
}
// If the user has auto rotate enabled, unlock the orientation if (settings.followDeviceOrientation === true) {
if (settings.followDeviceOrientation === true) { ScreenOrientation.unlockAsync();
ScreenOrientation.unlockAsync(); } else {
} else { ScreenOrientation.lockAsync(
// If the user has auto rotate disabled, lock the orientation to portrait ScreenOrientation.OrientationLock.PORTRAIT_UP,
ScreenOrientation.lockAsync( );
ScreenOrientation.OrientationLock.PORTRAIT_UP, }
}, [
settings.followDeviceOrientation,
settings.defaultVideoOrientation,
segments,
]);
useEffect(() => {
if (Platform.isTV) {
return;
}
const subscription = AppState.addEventListener("change", (nextAppState) => {
if (
appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
BackGroundDownloader.checkForExistingDownloads().catch(
(error: unknown) => {
writeErrorLog("Failed to resume background downloads", error);
},
); );
} }
}, [settings.followDeviceOrientation, segments]); });
useEffect(() => { BackGroundDownloader.checkForExistingDownloads().catch((error: unknown) => {
const subscription = AppState.addEventListener( writeErrorLog("Failed to resume background downloads", error);
"change", });
(nextAppState) => { return () => {
if ( subscription.remove();
appState.current.match(/inactive|background/) && };
nextAppState === "active" }, []);
) {
BackGroundDownloader.checkForExistingDownloads();
}
},
);
BackGroundDownloader.checkForExistingDownloads();
return () => {
subscription.remove();
};
}, []);
}
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<JobQueueProvider> <JellyfinProvider>
<JellyfinProvider> <PlaySettingsProvider>
<PlaySettingsProvider> <LogProvider>
<LogProvider> <WebSocketProvider>
<WebSocketProvider> <DownloadProvider>
<DownloadProvider> <BottomSheetModalProvider>
<BottomSheetModalProvider> <SystemBars style='light' hidden={false} />
<SystemBars style='light' hidden={false} /> <ThemeProvider value={DarkTheme}>
<ThemeProvider value={DarkTheme}> <Stack initialRouteName='(auth)/(tabs)'>
<Stack initialRouteName='(auth)/(tabs)'> <Stack.Screen
<Stack.Screen name='(auth)/(tabs)'
name='(auth)/(tabs)' options={{
options={{ headerShown: false,
headerShown: false, title: "",
title: "", header: () => null,
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}} }}
closeButton
/> />
</ThemeProvider> <Stack.Screen
</BottomSheetModalProvider> name='(auth)/player'
</DownloadProvider> options={{
</WebSocketProvider> headerShown: false,
</LogProvider> title: "",
</PlaySettingsProvider> header: () => null,
</JellyfinProvider> }}
</JobQueueProvider> />
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</JellyfinProvider>
</QueryClientProvider> </QueryClientProvider>
); );
} }
function saveDownloadedItemInfo(item: BaseItemDto) {
try {
const downloadedItems = storage.getString("downloadedItems");
const items: BaseItemDto[] = downloadedItems
? JSON.parse(downloadedItems)
: [];
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
if (existingItemIndex !== -1) {
items[existingItemIndex] = item;
} else {
items.push(item);
}
storage.set("downloadedItems", JSON.stringify(items));
} catch (error) {
writeToLog("ERROR", "Failed to save downloaded item information:", error);
console.error("Failed to save downloaded item information:", error);
}
}

View File

@@ -1,29 +1,29 @@
import { Button } from "@/components/Button";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
Alert, Alert,
Keyboard,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
SafeAreaView, SafeAreaView,
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { Keyboard } from "react-native";
import { t } from "i18next";
import { z } from "zod"; import { z } from "zod";
import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
const CredentialsSchema = z.object({ const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")), username: z.string().min(1, t("login.username_required")),
}); });
@@ -133,27 +133,52 @@ const Login: React.FC = () => {
*/ */
const checkUrl = useCallback(async (url: string) => { const checkUrl = useCallback(async (url: string) => {
setLoadingServerCheck(true); setLoadingServerCheck(true);
const baseUrl = url.replace(/^https?:\/\//i, "");
const protocols = ["https", "http"];
try { try {
const response = await fetch(`${url}/System/Info/Public`, { return checkHttp(baseUrl, protocols);
mode: "cors", } catch (e) {
}); if (e instanceof Error && e.message === "Server too old") {
throw e;
if (response.ok) {
const data = (await response.json()) as PublicSystemInfo;
setServerName(data.ServerName || "");
return url;
} }
return undefined;
} catch {
return undefined; return undefined;
} finally { } finally {
setLoadingServerCheck(false); setLoadingServerCheck(false);
} }
}, []); }, []);
async function checkHttp(baseUrl: string, protocols: string[]) {
for (const protocol of protocols) {
try {
const response = await fetch(
`${protocol}://${baseUrl}/System/Info/Public`,
{
mode: "cors",
},
);
if (response.ok) {
const data = (await response.json()) as PublicSystemInfo;
const serverVersion = data.Version?.split(".");
if (serverVersion && +serverVersion[0] <= 10) {
if (+serverVersion[1] < 10) {
Alert.alert(
t("login.too_old_server_text"),
t("login.too_old_server_description"),
);
throw new Error("Server too old");
}
}
setServerName(data.ServerName || "");
return `${protocol}://${baseUrl}`;
}
} catch (e) {
if (e instanceof Error && e.message === "Server too old") {
throw e;
}
}
}
return undefined;
}
/** /**
* Handles the connection attempt to a Jellyfin server. * Handles the connection attempt to a Jellyfin server.
* *
@@ -172,17 +197,17 @@ const Login: React.FC = () => {
*/ */
const handleConnect = useCallback(async (url: string) => { const handleConnect = useCallback(async (url: string) => {
url = url.trim().replace(/\/$/, ""); url = url.trim().replace(/\/$/, "");
const result = await checkUrl(url); try {
const result = await checkUrl(url);
if (result === undefined) { if (result === undefined) {
Alert.alert( Alert.alert(
t("login.connection_failed"), t("login.connection_failed"),
t("login.could_not_connect_to_server"), t("login.could_not_connect_to_server"),
); );
return; return;
} }
await setServer({ address: result });
await setServer({ address: url }); } catch {}
}, []); }, []);
const handleQuickConnect = async () => { const handleQuickConnect = async () => {
@@ -199,7 +224,7 @@ const Login: React.FC = () => {
], ],
); );
} }
} catch (error) { } catch (_error) {
Alert.alert( Alert.alert(
t("login.error_title"), t("login.error_title"),
t("login.failed_to_initiate_quick_connect"), t("login.failed_to_initiate_quick_connect"),
@@ -207,139 +232,269 @@ const Login: React.FC = () => {
} }
}; };
return ( return Platform.isTV ? (
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}> // TV layout
<SafeAreaView className='flex-1 bg-black'>
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"} behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
> >
{api?.basePath ? ( {api?.basePath ? (
<> // ------------ Username/Password view ------------
<View className='flex flex-col h-full relative items-center justify-center'> <View className='flex-1 items-center justify-center'>
<View className='px-4 -mt-20 w-full'> {/* Safe centered column with max width so TV doesnt stretch too far */}
<View className='flex flex-col space-y-2'> <View className='w-[92%] max-w-[900px] px-2 -mt-12'>
<Text className='text-2xl font-bold -mb-2'> <Text className='text-3xl font-bold text-white mb-1'>
{serverName ? ( {serverName ? (
<> <>
{`${t("login.login_to_title")} `} {`${t("login.login_to_title")} `}
<Text className='text-purple-600'>{serverName}</Text> <Text className='text-purple-500'>{serverName}</Text>
</> </>
) : ( ) : (
t("login.login_title") t("login.login_title")
)} )}
</Text> </Text>
<Text className='text-xs text-neutral-400'> <Text className='text-xs text-neutral-400 mb-6'>
{api.basePath} {api.basePath}
</Text> </Text>
<Input
placeholder={t("login.username_placeholder")}
onChangeText={(text) =>
setCredentials({ ...credentials, username: text })
}
value={credentials.username}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
// Changed from username to oneTimeCode because it is a known issue in RN
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
textContentType='oneTimeCode'
clearButtonMode='while-editing'
maxLength={500}
/>
<Input {/* Username */}
placeholder={t("login.password_placeholder")} <Input
onChangeText={(text) => placeholder={t("login.username_placeholder")}
setCredentials({ ...credentials, password: text }) onChangeText={(text: string) =>
} setCredentials({ ...credentials, username: text })
value={credentials.password} }
secureTextEntry value={credentials.username}
keyboardType='default' keyboardType='default'
returnKeyType='done' returnKeyType='done'
autoCapitalize='none' autoCapitalize='none'
textContentType='password' textContentType='oneTimeCode'
clearButtonMode='while-editing' clearButtonMode='while-editing'
maxLength={500} maxLength={500}
/> extraClassName='mb-4'
<View className='flex flex-row items-center justify-between'> />
<Button
onPress={handleLogin} {/* Password */}
loading={loading} <Input
className='flex-1 mr-2' placeholder={t("login.password_placeholder")}
> onChangeText={(text: string) =>
{t("login.login_button")} setCredentials({ ...credentials, password: text })
</Button> }
<TouchableOpacity value={credentials.password}
onPress={handleQuickConnect} secureTextEntry
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center' keyboardType='default'
> returnKeyType='done'
<MaterialCommunityIcons autoCapitalize='none'
name='cellphone-lock' textContentType='password'
size={24} clearButtonMode='while-editing'
color='white' maxLength={500}
/> extraClassName='mb-4'
</TouchableOpacity> />
</View>
</View> <View className='mt-4'>
<Button onPress={handleLogin}>{t("login.login_button")}</Button>
</View>
<View className='mt-3'>
<Button
onPress={handleQuickConnect}
className='bg-neutral-800 border border-neutral-700'
>
{t("login.quick_connect")}
</Button>
</View>
</View>
</View>
) : (
// ------------ Server connect view ------------
<View className='flex-1 items-center justify-center'>
<View className='w-[92%] max-w-[900px] -mt-2'>
<View className='items-center mb-1'>
<Image
source={require("@/assets/images/icon-ios-plain.png")}
style={{ width: 110, height: 110 }}
contentFit='contain'
/>
</View> </View>
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' /> <Text className='text-white text-4xl font-bold text-center'>
</View> Streamyfin
</> </Text>
) : ( <Text className='text-neutral-400 text-base text-left mt-2 mb-1'>
<> {t("server.enter_url_to_jellyfin_server")}
<View className='flex flex-col h-full items-center justify-center w-full'> </Text>
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
<Image {/* Full-width Input with clear focus ring */}
style={{ <Input
width: 100, aria-label='Server URL'
height: 100, placeholder={t("server.server_url_placeholder")}
marginLeft: -23, onChangeText={setServerURL}
marginBottom: -20, value={serverURL}
}} keyboardType='url'
source={require("@/assets/images/StreamyFinFinal.png")} returnKeyType='done'
/> autoCapitalize='none'
<Text className='text-3xl font-bold'>Streamyfin</Text> textContentType='URL'
<Text className='text-neutral-500'> maxLength={500}
{t("server.enter_url_to_jellyfin_server")} />
</Text>
<Input {/* Full-width primary button */}
aria-label='Server URL' <View className='mt-4'>
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
maxLength={500}
/>
<Button <Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => { onPress={async () => {
await handleConnect(serverURL); await handleConnect(serverURL);
}} }}
className='w-full grow'
> >
{t("server.connect_button")} {t("server.connect_button")}
</Button> </Button>
</View>
{/* Lists stay full width but inside max width container */}
<View className='mt-2'>
<JellyfinServerDiscovery <JellyfinServerDiscovery
onServerSelect={async (server) => { onServerSelect={async (server: any) => {
setServerURL(server.address); setServerURL(server.address);
if (server.serverName) { if (server.serverName) setServerName(server.serverName);
setServerName(server.serverName);
}
await handleConnect(server.address); await handleConnect(server.address);
}} }}
/> />
<PreviousServersList <PreviousServersList
onServerSelect={async (s) => { onServerSelect={async (s: any) => {
await handleConnect(s.address); await handleConnect(s.address);
}} }}
/> />
</View> </View>
</View> </View>
</> </View>
)}
</KeyboardAvoidingView>
</SafeAreaView>
) : (
// Mobile layout
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
{api?.basePath ? (
<View className='flex flex-col h-full relative items-center justify-center'>
<View className='px-4 -mt-20 w-full'>
<View className='flex flex-col space-y-2'>
<Text className='text-2xl font-bold -mb-2'>
{serverName ? (
<>
{`${t("login.login_to_title")} `}
<Text className='text-purple-600'>{serverName}</Text>
</>
) : (
t("login.login_title")
)}
</Text>
<Text className='text-xs text-neutral-400'>{api.basePath}</Text>
<Input
placeholder={t("login.username_placeholder")}
onChangeText={(text) =>
setCredentials({ ...credentials, username: text })
}
value={credentials.username}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
// Changed from username to oneTimeCode because it is a known issue in RN
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
textContentType='oneTimeCode'
clearButtonMode='while-editing'
maxLength={500}
/>
<Input
placeholder={t("login.password_placeholder")}
onChangeText={(text) =>
setCredentials({ ...credentials, password: text })
}
value={credentials.password}
secureTextEntry
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
clearButtonMode='while-editing'
maxLength={500}
/>
<View className='flex flex-row items-center justify-between'>
<Button
onPress={handleLogin}
loading={loading}
className='flex-1 mr-2'
>
{t("login.login_button")}
</Button>
<TouchableOpacity
onPress={handleQuickConnect}
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
>
<MaterialCommunityIcons
name='cellphone-lock'
size={24}
color='white'
/>
</TouchableOpacity>
</View>
</View>
</View>
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
</View>
) : (
<View className='flex flex-col h-full items-center justify-center w-full'>
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
<Image
style={{
width: 100,
height: 100,
marginLeft: -23,
marginBottom: -20,
}}
source={require("@/assets/images/icon-ios-plain.png")}
/>
<Text className='text-3xl font-bold'>Streamyfin</Text>
<Text className='text-neutral-500'>
{t("server.enter_url_to_jellyfin_server")}
</Text>
<Input
aria-label='Server URL'
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
maxLength={500}
/>
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => {
await handleConnect(serverURL);
}}
className='w-full grow'
>
{t("server.connect_button")}
</Button>
<JellyfinServerDiscovery
onServerSelect={async (server) => {
setServerURL(server.address);
if (server.serverName) {
setServerName(server.serverName);
}
await handleConnect(server.address);
}}
/>
<PreviousServersList
onServerSelect={async (s) => {
await handleConnect(s.address);
}}
/>
</View>
</View>
)} )}
</KeyboardAvoidingView> </KeyboardAvoidingView>
</SafeAreaView> </SafeAreaView>

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -115,4 +115,4 @@
<path id="path259-2-6-4-6-7-0-1-0-5-9-4-7-1-5-7-6-2" class="cls-11" d="M46.97,39.46c5.94,0,10.75,4.81,10.75,10.75s-4.81,10.75-10.75,10.75-10.75-4.81-10.75-10.75c0-1.1.16-2.16.47-3.17.84,1.87,2.72,3.17,4.9,3.17,2.97,0,5.37-2.41,5.37-5.37,0-2.18-1.3-4.06-3.17-4.9,1-.31,2.06-.47,3.17-.47h.01Z"/> <path id="path259-2-6-4-6-7-0-1-0-5-9-4-7-1-5-7-6-2" class="cls-11" d="M46.97,39.46c5.94,0,10.75,4.81,10.75,10.75s-4.81,10.75-10.75,10.75-10.75-4.81-10.75-10.75c0-1.1.16-2.16.47-3.17.84,1.87,2.72,3.17,4.9,3.17,2.97,0,5.37-2.41,5.37-5.37,0-2.18-1.3-4.06-3.17-4.9,1-.31,2.06-.47,3.17-.47h.01Z"/>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 75 KiB

View File

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

View File

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

View File

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

2037
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
import { RoundButton } from "@/components/RoundButton";
import { useFavorite } from "@/hooks/useFavorite";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { FC } from "react"; import type { FC } from "react";
import { View, type ViewProps } from "react-native"; import { Platform, View, type ViewProps } from "react-native";
import { RoundButton } from "@/components/RoundButton";
import { useFavorite } from "@/hooks/useFavorite";
interface Props extends ViewProps { interface Props extends ViewProps {
item: BaseItemDto; item: BaseItemDto;
@@ -11,6 +11,18 @@ interface Props extends ViewProps {
export const AddToFavorites: FC<Props> = ({ item, ...props }) => { export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
const { isFavorite, toggleFavorite } = useFavorite(item); const { isFavorite, toggleFavorite } = useFavorite(item);
if (Platform.OS === "ios") {
return (
<View {...props}>
<RoundButton
size='large'
icon={isFavorite ? "heart" : "heart-outline"}
onPress={toggleFavorite}
/>
</View>
);
}
return ( return (
<View {...props}> <View {...props}>
<RoundButton <RoundButton

View File

@@ -0,0 +1,776 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getTvShowsApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Dimensions, Pressable, TouchableOpacity, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
Easing,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { ItemImage } from "./common/ItemImage";
import { getItemNavigation } from "./common/TouchableItemRouter";
import type { SelectedOptions } from "./ItemContent";
import { PlayButton } from "./PlayButton";
import { PlayedStatus } from "./PlayedStatus";
interface AppleTVCarouselProps {
initialIndex?: number;
onItemChange?: (index: number) => void;
}
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
// Layout Constants
const CAROUSEL_HEIGHT = screenHeight / 1.45;
const GRADIENT_HEIGHT_TOP = 150;
const GRADIENT_HEIGHT_BOTTOM = 150;
const LOGO_HEIGHT = 80;
// Position Constants
const LOGO_BOTTOM_POSITION = 210;
const GENRES_BOTTOM_POSITION = 170;
const CONTROLS_BOTTOM_POSITION = 100;
const DOTS_BOTTOM_POSITION = 60;
// Size Constants
const DOT_HEIGHT = 6;
const DOT_ACTIVE_WIDTH = 20;
const DOT_INACTIVE_WIDTH = 12;
const PLAY_BUTTON_SKELETON_HEIGHT = 50;
const PLAYED_STATUS_SKELETON_SIZE = 40;
const TEXT_SKELETON_HEIGHT = 20;
const TEXT_SKELETON_WIDTH = 250;
const _EMPTY_STATE_ICON_SIZE = 64;
// Spacing Constants
const HORIZONTAL_PADDING = 40;
const DOT_PADDING = 2;
const DOT_GAP = 4;
const CONTROLS_GAP = 20;
const _TEXT_MARGIN_TOP = 16;
// Border Radius Constants
const DOT_BORDER_RADIUS = 3;
const LOGO_SKELETON_BORDER_RADIUS = 8;
const TEXT_SKELETON_BORDER_RADIUS = 4;
const PLAY_BUTTON_BORDER_RADIUS = 25;
const PLAYED_STATUS_BORDER_RADIUS = 20;
// Animation Constants
const DOT_ANIMATION_DURATION = 300;
const CAROUSEL_TRANSITION_DURATION = 250;
const PAN_ACTIVE_OFFSET = 10;
const TRANSLATION_THRESHOLD = 0.2;
const VELOCITY_THRESHOLD = 400;
// Text Constants
const GENRES_FONT_SIZE = 16;
const _EMPTY_STATE_FONT_SIZE = 18;
const TEXT_SHADOW_RADIUS = 2;
const MAX_GENRES_COUNT = 2;
const MAX_BUTTON_WIDTH = 300;
// Opacity Constants
const OVERLAY_OPACITY = 0.4;
const DOT_INACTIVE_OPACITY = 0.6;
const TEXT_OPACITY = 0.9;
// Color Constants
const SKELETON_BACKGROUND_COLOR = "#1a1a1a";
const SKELETON_ELEMENT_COLOR = "#333";
const SKELETON_ACTIVE_DOT_COLOR = "#666";
const _EMPTY_STATE_COLOR = "#666";
const TEXT_SHADOW_COLOR = "rgba(0, 0, 0, 0.8)";
const LOGO_WIDTH_PERCENTAGE = "80%";
const DotIndicator = ({
index,
currentIndex,
onPress,
}: {
index: number;
currentIndex: number;
onPress: (index: number) => void;
}) => {
const isActive = index === currentIndex;
const animatedStyle = useAnimatedStyle(() => ({
width: withTiming(isActive ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH, {
duration: DOT_ANIMATION_DURATION,
easing: Easing.out(Easing.quad),
}),
opacity: withTiming(isActive ? 1 : DOT_INACTIVE_OPACITY, {
duration: DOT_ANIMATION_DURATION,
easing: Easing.out(Easing.quad),
}),
}));
return (
<Pressable
onPress={() => onPress(index)}
style={{
padding: DOT_PADDING, // Increase touch area
}}
>
<Animated.View
style={[
{
height: DOT_HEIGHT,
backgroundColor: isActive ? "white" : "rgba(255, 255, 255, 0.4)",
borderRadius: DOT_BORDER_RADIUS,
},
animatedStyle,
]}
/>
</Pressable>
);
};
export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
initialIndex = 0,
onItemChange,
}) => {
const { settings } = useSettings();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { isConnected, serverConnected } = useNetworkStatus();
const router = useRouter();
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const translateX = useSharedValue(-currentIndex * screenWidth);
const isQueryEnabled =
!!api && !!user?.Id && isConnected && serverConnected === true;
const { data: continueWatchingData, isLoading: continueWatchingLoading } =
useQuery({
queryKey: ["appleTVCarousel", "continueWatching", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Genres"],
limit: 2,
});
return response.data.Items || [];
},
enabled: isQueryEnabled,
staleTime: 60 * 1000,
});
const { data: nextUpData, isLoading: nextUpLoading } = useQuery({
queryKey: ["appleTVCarousel", "nextUp", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getTvShowsApi(api).getNextUp({
userId: user.Id,
fields: ["MediaSourceCount", "Genres"],
limit: 2,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: false,
});
return response.data.Items || [];
},
enabled: isQueryEnabled,
staleTime: 60 * 1000,
});
const { data: recentlyAddedData, isLoading: recentlyAddedLoading } = useQuery(
{
queryKey: ["appleTVCarousel", "recentlyAdded", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getUserLibraryApi(api).getLatestMedia({
userId: user.Id,
limit: 2,
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
});
return response.data || [];
},
enabled: isQueryEnabled,
staleTime: 60 * 1000,
},
);
const items = useMemo(() => {
const continueItems = continueWatchingData ?? [];
const nextItems = nextUpData ?? [];
const recentItems = recentlyAddedData ?? [];
return [
...continueItems.slice(0, 2),
...nextItems.slice(0, 2),
...recentItems.slice(0, 2),
];
}, [continueWatchingData, nextUpData, recentlyAddedData]);
const isLoading =
continueWatchingLoading || nextUpLoading || recentlyAddedLoading;
const hasItems = items.length > 0;
// Only get play settings if we have valid items
const currentItem = hasItems ? items[currentIndex] : null;
// Extract colors for the current item only (for performance)
const currentItemColors = useImageColorsReturn({ item: currentItem });
// Create a fallback empty item for useDefaultPlaySettings when no item is available
const itemForPlaySettings = currentItem || { MediaSources: [] };
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(itemForPlaySettings as BaseItemDto, settings);
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined
>(undefined);
useEffect(() => {
// Only set options if we have valid current item
if (currentItem) {
setSelectedOptions({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
});
} else {
setSelectedOptions(undefined);
}
}, [
defaultAudioIndex,
defaultBitrate,
defaultSubtitleIndex,
defaultMediaSource,
currentIndex,
currentItem,
]);
useEffect(() => {
if (!hasItems) {
setCurrentIndex(initialIndex);
translateX.value = -initialIndex * screenWidth;
return;
}
setCurrentIndex((prev) => {
const newIndex = Math.min(prev, items.length - 1);
translateX.value = -newIndex * screenWidth;
return newIndex;
});
}, [hasItems, items, initialIndex, translateX]);
useEffect(() => {
if (hasItems) {
onItemChange?.(currentIndex);
}
}, [hasItems, currentIndex, onItemChange]);
const goToIndex = useCallback(
(index: number) => {
if (!hasItems || index < 0 || index >= items.length) return;
translateX.value = withTiming(-index * screenWidth, {
duration: CAROUSEL_TRANSITION_DURATION, // Slightly longer for smoother feel
easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), // iOS-like smooth deceleration curve
});
setCurrentIndex(index);
onItemChange?.(index);
},
[hasItems, items, onItemChange, translateX],
);
const navigateToItem = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, "(home)");
router.push(navigation as any);
},
[router],
);
const panGesture = Gesture.Pan()
.activeOffsetX([-PAN_ACTIVE_OFFSET, PAN_ACTIVE_OFFSET])
.onUpdate((event) => {
translateX.value = -currentIndex * screenWidth + event.translationX;
})
.onEnd((event) => {
const velocity = event.velocityX;
const translation = event.translationX;
let newIndex = currentIndex;
// Improved thresholds for more responsive navigation
if (
Math.abs(translation) > screenWidth * TRANSLATION_THRESHOLD ||
Math.abs(velocity) > VELOCITY_THRESHOLD
) {
if (translation > 0 && currentIndex > 0) {
newIndex = currentIndex - 1;
} else if (
translation < 0 &&
items &&
currentIndex < items.length - 1
) {
newIndex = currentIndex + 1;
}
}
runOnJS(goToIndex)(newIndex);
});
const containerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [{ translateX: translateX.value }],
};
});
const renderDots = () => {
if (!hasItems || items.length <= 1) return null;
return (
<View
style={{
position: "absolute",
bottom: DOTS_BOTTOM_POSITION,
left: 0,
right: 0,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: DOT_GAP,
}}
>
{items.map((_, index) => (
<DotIndicator
key={index}
index={index}
currentIndex={currentIndex}
onPress={goToIndex}
/>
))}
</View>
);
};
const renderSkeletonLoader = () => {
return (
<View
style={{
width: screenWidth,
height: CAROUSEL_HEIGHT,
backgroundColor: "#000",
}}
>
{/* Background Skeleton */}
<View
style={{
width: "100%",
height: "100%",
backgroundColor: SKELETON_BACKGROUND_COLOR,
position: "absolute",
}}
/>
{/* Dark Overlay Skeleton */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
}}
/>
{/* Gradient Fade to Black Top Skeleton */}
<LinearGradient
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.8)", "transparent"]}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
height: GRADIENT_HEIGHT_TOP,
}}
/>
{/* Gradient Fade to Black Bottom Skeleton */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: GRADIENT_HEIGHT_BOTTOM,
}}
/>
{/* Logo Skeleton */}
<View
style={{
position: "absolute",
bottom: LOGO_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<View
style={{
height: LOGO_HEIGHT,
width: LOGO_WIDTH_PERCENTAGE,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: LOGO_SKELETON_BORDER_RADIUS,
}}
/>
</View>
{/* Type and Genres Skeleton */}
<View
style={{
position: "absolute",
bottom: GENRES_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<View
style={{
height: TEXT_SKELETON_HEIGHT,
width: TEXT_SKELETON_WIDTH,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
}}
/>
</View>
{/* Controls Skeleton */}
<View
style={{
position: "absolute",
bottom: CONTROLS_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: CONTROLS_GAP,
}}
>
{/* Play Button Skeleton */}
<View
style={{
height: PLAY_BUTTON_SKELETON_HEIGHT,
flex: 1,
maxWidth: MAX_BUTTON_WIDTH,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: PLAY_BUTTON_BORDER_RADIUS,
}}
/>
{/* Played Status Skeleton */}
<View
style={{
width: PLAYED_STATUS_SKELETON_SIZE,
height: PLAYED_STATUS_SKELETON_SIZE,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: PLAYED_STATUS_BORDER_RADIUS,
}}
/>
</View>
{/* Dots Skeleton */}
<View
style={{
position: "absolute",
bottom: DOTS_BOTTOM_POSITION,
left: 0,
right: 0,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: DOT_GAP,
}}
>
{[1, 2, 3].map((_, index) => (
<View
key={index}
style={{
width: index === 0 ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH,
height: DOT_HEIGHT,
backgroundColor:
index === 0
? SKELETON_ACTIVE_DOT_COLOR
: SKELETON_ELEMENT_COLOR,
borderRadius: DOT_BORDER_RADIUS,
}}
/>
))}
</View>
</View>
);
};
const renderItem = (item: BaseItemDto, _index: number) => {
const itemLogoUrl = api ? getLogoImageUrlById({ api, item }) : null;
return (
<View
key={item.Id}
style={{
width: screenWidth,
height: CAROUSEL_HEIGHT,
position: "relative",
}}
>
{/* Background Backdrop */}
<ItemImage
item={item}
variant='Backdrop'
style={{
width: "100%",
height: "100%",
position: "absolute",
}}
/>
{/* Dark Overlay */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
}}
/>
{/* Gradient Fade to Black at Top */}
<LinearGradient
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.2)", "transparent"]}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
height: GRADIENT_HEIGHT_TOP,
}}
/>
{/* Gradient Fade to Black at Bottom */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: GRADIENT_HEIGHT_BOTTOM,
}}
/>
{/* Logo Section */}
{itemLogoUrl && (
<TouchableOpacity
onPress={() => navigateToItem(item)}
style={{
position: "absolute",
bottom: LOGO_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<Image
source={{
uri: itemLogoUrl,
}}
style={{
height: LOGO_HEIGHT,
width: LOGO_WIDTH_PERCENTAGE,
}}
contentFit='contain'
/>
</TouchableOpacity>
)}
{/* Type and Genres Section */}
<View
style={{
position: "absolute",
bottom: GENRES_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<TouchableOpacity onPress={() => navigateToItem(item)}>
<Animated.Text
style={{
color: `rgba(255, 255, 255, ${TEXT_OPACITY})`,
fontSize: GENRES_FONT_SIZE,
fontWeight: "500",
textAlign: "center",
textShadowColor: TEXT_SHADOW_COLOR,
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: TEXT_SHADOW_RADIUS,
}}
>
{(() => {
let typeLabel = "";
if (item.Type === "Episode") {
// For episodes, show season and episode number
const season = item.ParentIndexNumber;
const episode = item.IndexNumber;
if (season && episode) {
typeLabel = `S${season} • E${episode}`;
} else {
typeLabel = "Episode";
}
} else {
typeLabel =
item.Type === "Series"
? "TV Show"
: item.Type === "Movie"
? "Movie"
: item.Type || "";
}
const genres =
item.Genres && item.Genres.length > 0
? item.Genres.slice(0, MAX_GENRES_COUNT).join(" • ")
: "";
if (typeLabel && genres) {
return `${typeLabel}${genres}`;
} else if (typeLabel) {
return typeLabel;
} else if (genres) {
return genres;
} else {
return "";
}
})()}
</Animated.Text>
</TouchableOpacity>
</View>
{/* Controls Section */}
<View
style={{
position: "absolute",
bottom: CONTROLS_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
}}
>
<View
style={{
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: CONTROLS_GAP,
}}
>
{/* Play Button */}
<View style={{ flex: 1, maxWidth: MAX_BUTTON_WIDTH }}>
{selectedOptions && (
<PlayButton
item={item}
selectedOptions={selectedOptions}
colors={currentItemColors}
/>
)}
</View>
{/* Mark as Played */}
<PlayedStatus items={[item]} size='large' />
</View>
</View>
</View>
);
};
// Handle loading state
if (isLoading) {
return (
<View
style={{
height: CAROUSEL_HEIGHT,
backgroundColor: "#000",
overflow: "hidden",
}}
>
{renderSkeletonLoader()}
</View>
);
}
// Handle empty items
if (!hasItems) {
return null;
}
return (
<View
style={{
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
backgroundColor: "#000",
overflow: "hidden",
}}
>
<GestureDetector gesture={panGesture}>
<Animated.View
style={[
{
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
flexDirection: "row",
width: screenWidth * items.length,
},
containerAnimatedStyle,
]}
>
{items.map((item, index) => renderItem(item, index))}
</Animated.View>
</GestureDetector>
{/* Animated Dots Indicator */}
{renderDots()}
</View>
);
};

View File

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

122
components/BitRateSheet.tsx Normal file
View File

@@ -0,0 +1,122 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
import { FilterSheet } from "./filters/FilterSheet";
export type Bitrate = {
key: string;
value: number | undefined;
};
export const BITRATES: Bitrate[] = [
{
key: "Max",
value: undefined,
},
{
key: "8 Mb/s",
value: 8000000,
height: 1080,
},
{
key: "4 Mb/s",
value: 4000000,
height: 1080,
},
{
key: "2 Mb/s",
value: 2000000,
},
{
key: "1 Mb/s",
value: 1000000,
},
{
key: "500 Kb/s",
value: 500000,
},
{
key: "250 Kb/s",
value: 250000,
},
].sort(
(a, b) =>
(b.value || Number.POSITIVE_INFINITY) -
(a.value || Number.POSITIVE_INFINITY),
);
interface Props extends React.ComponentProps<typeof View> {
onChange: (value: Bitrate) => void;
selected?: Bitrate | null;
inverted?: boolean | null;
}
export const BitrateSheet: React.FC<Props> = ({
onChange,
selected,
inverted,
...props
}) => {
const isTv = Platform.isTV;
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const sorted = useMemo(() => {
if (inverted)
return BITRATES.slice().sort(
(a, b) =>
(a.value || Number.POSITIVE_INFINITY) -
(b.value || Number.POSITIVE_INFINITY),
);
return BITRATES.slice().sort(
(a, b) =>
(b.value || Number.POSITIVE_INFINITY) -
(a.value || Number.POSITIVE_INFINITY),
);
}, [inverted]);
if (isTv) return null;
return (
<View
className='flex shrink'
style={{
minWidth: 60,
maxWidth: 200,
}}
>
<View className='flex flex-col' {...props}>
<Text className='opacity-50 mb-1 text-xs'>
{t("item_card.quality")}
</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
onPress={() => setOpen(true)}
>
<Text numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key}
</Text>
</TouchableOpacity>
</View>
<FilterSheet
open={open}
setOpen={setOpen}
title={t("item_card.quality")}
data={sorted}
values={selected ? [selected] : []}
multiple={false}
searchFilter={(item, query) => {
const label = (item as any).key || "";
return label.toLowerCase().includes(query.toLowerCase());
}}
renderItemLabel={(item) => <Text>{(item as any).key || ""}</Text>}
set={(vals) => {
const chosen = vals[0] as Bitrate | undefined;
if (chosen) onChange(chosen);
}}
/>
</View>
);
};

View File

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

View File

@@ -1,7 +1,21 @@
import { useHaptic } from "@/hooks/useHaptic";
import type React from "react"; import type React from "react";
import { type PropsWithChildren, type ReactNode, useMemo } from "react"; import {
import { Platform, Text, TouchableOpacity, View } from "react-native"; type PropsWithChildren,
type ReactNode,
useMemo,
useRef,
useState,
} from "react";
import {
Animated,
Easing,
Platform,
Pressable,
Text,
TouchableOpacity,
View,
} from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
export interface ButtonProps export interface ButtonProps
@@ -31,10 +45,23 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
justify = "center", justify = "center",
...props ...props
}) => { }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 130,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
const colorClasses = useMemo(() => { const colorClasses = useMemo(() => {
switch (color) { switch (color) {
case "purple": case "purple":
return "bg-purple-600 active:bg-purple-700"; return focused
? "bg-purple-500 border-2 border-white"
: "bg-purple-600 border border-purple-700";
case "red": case "red":
return "bg-red-600"; return "bg-red-600";
case "black": case "black":
@@ -42,11 +69,43 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
case "transparent": case "transparent":
return "bg-transparent"; return "bg-transparent";
} }
}, [color]); }, [color, focused]);
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
return ( return Platform.isTV ? (
<Pressable
className='w-full'
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.08);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
>
<Animated.View
style={{
transform: [{ scale }],
shadowColor: "#a855f7",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.9 : 0,
shadowRadius: focused ? 18 : 0,
elevation: focused ? 12 : 0, // Android glow
}}
>
<View
className={`rounded-2xl py-5 items-center justify-center
${focused ? "bg-purple-500 border-2 border-white" : "bg-purple-600 border border-purple-700"}
${className}`}
>
<Text className='text-white text-xl font-bold'>{children}</Text>
</View>
</Animated.View>
</Pressable>
) : (
<TouchableOpacity <TouchableOpacity
className={` className={`
p-3 rounded-xl items-center justify-center p-3 rounded-xl items-center justify-center

View File

@@ -1,6 +1,6 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import React, { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { Platform, TouchableOpacity, type ViewProps } from "react-native"; import { Platform, TouchableOpacity } from "react-native";
import GoogleCast, { import GoogleCast, {
CastButton, CastButton,
CastContext, CastContext,
@@ -11,12 +11,6 @@ import GoogleCast, {
} from "react-native-google-cast"; } from "react-native-google-cast";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
interface Props extends ViewProps {
width?: number;
height?: number;
background?: "blur" | "transparent";
}
export function Chromecast({ export function Chromecast({
width = 48, width = 48,
height = 48, height = 48,
@@ -44,14 +38,26 @@ export function Chromecast({
// Android requires the cast button to be present for startDiscovery to work // Android requires the cast button to be present for startDiscovery to work
const AndroidCastButton = useCallback( const AndroidCastButton = useCallback(
() => () =>
Platform.OS === "android" ? ( Platform.OS === "android" ? <CastButton tintColor='transparent' /> : null,
<CastButton tintColor='transparent' />
) : (
<></>
),
[Platform.OS], [Platform.OS],
); );
if (Platform.OS === "ios") {
return (
<TouchableOpacity
className='mr-4'
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
{...props}
>
<AndroidCastButton />
<Feather name='cast' size={22} color={"white"} />
</TouchableOpacity>
);
}
if (background === "transparent") if (background === "transparent")
return ( return (
<RoundButton <RoundButton

View File

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

View File

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

View File

@@ -1,12 +1,3 @@
//import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
import download from "@/utils/profiles/download";
import Ionicons from "@expo/vector-icons/Ionicons"; import Ionicons from "@expo/vector-icons/Ionicons";
import { import {
BottomSheetBackdrop, BottomSheetBackdrop,
@@ -18,22 +9,36 @@ import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { type Href, router, useFocusEffect } from "expo-router"; import { type Href, router } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import type React from "react"; import type React from "react";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, Platform, View, type ViewProps } from "react-native"; import { Alert, Platform, Switch, View, type ViewProps } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getDownloadUrl } from "@/utils/jellyfin/media/getDownloadUrl";
import { AudioTrackSelector } from "./AudioTrackSelector"; import { AudioTrackSelector } from "./AudioTrackSelector";
import { type Bitrate, BitrateSelector } from "./BitrateSelector"; import { type Bitrate, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button"; import { Button } from "./Button";
import { Text } from "./common/Text";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector"; import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle"; import ProgressCircle from "./ProgressCircle";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
import { Text } from "./common/Text";
export type SelectedOptions = {
bitrate: Bitrate;
mediaSource: MediaSourceInfo | undefined;
audioIndex: number | undefined;
subtitleIndex: number;
};
interface DownloadProps extends ViewProps { interface DownloadProps extends ViewProps {
items: BaseItemDto[]; items: BaseItemDto[];
@@ -55,33 +60,29 @@ export const DownloadItems: React.FC<DownloadProps> = ({
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [queue, setQueue] = useAtom(queueAtom); const [queue, _setQueue] = useAtom(queueAtom);
const [settings] = useSettings(); const { settings } = useSettings();
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
const { processes, startBackgroundDownload, downloadedFiles } = useDownload(); const { processes, startBackgroundDownload, getDownloadedItems } =
//const { startRemuxing } = useRemuxHlsToMp4(); useDownload();
const downloadedFiles = getDownloadedItems();
const [selectedMediaSource, setSelectedMediaSource] = useState< const [selectedOptions, setSelectedOptions] = useState<
MediaSourceInfo | undefined | null SelectedOptions | undefined
>(undefined); >(undefined);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] = const {
useState<number>(0); defaultAudioIndex,
const [maxBitrate, setMaxBitrate] = useState<Bitrate>( defaultBitrate,
settings?.defaultBitrate ?? { defaultMediaSource,
key: "Max", defaultSubtitleIndex,
value: undefined, } = useDefaultPlaySettings(items[0], settings);
},
);
const userCanDownload = useMemo( const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading, () => user?.Policy?.EnableContentDownloading,
[user], [user],
); );
const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === DownloadMethod.Optimized,
[settings],
);
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -89,7 +90,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
bottomSheetModalRef.current?.present(); bottomSheetModalRef.current?.present();
}, []); }, []);
const handleSheetChanges = useCallback((index: number) => {}, []); const handleSheetChanges = useCallback((index: number) => {
// Ensure modal is fully dismissed when index is -1
if (index === -1) {
// Modal is fully closed
}
}, []);
const closeModal = useCallback(() => { const closeModal = useCallback(() => {
bottomSheetModalRef.current?.dismiss(); bottomSheetModalRef.current?.dismiss();
@@ -103,6 +109,28 @@ export const DownloadItems: React.FC<DownloadProps> = ({
[items, downloadedFiles], [items, downloadedFiles],
); );
// Initialize selectedOptions with default values
useEffect(() => {
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
}, [
defaultAudioIndex,
defaultBitrate,
defaultSubtitleIndex,
defaultMediaSource,
]);
const itemsToDownload = useMemo(() => {
if (downloadUnwatchedOnly) {
return itemsNotDownloaded.filter((item) => !item.UserData?.Played);
}
return itemsNotDownloaded;
}, [itemsNotDownloaded, downloadUnwatchedOnly]);
const allItemsDownloaded = useMemo(() => { const allItemsDownloaded = useMemo(() => {
if (items.length === 0) return false; if (items.length === 0) return false;
return itemsNotDownloaded.length === 0; return itemsNotDownloaded.length === 0;
@@ -145,114 +173,103 @@ export const DownloadItems: React.FC<DownloadProps> = ({
); );
}; };
const acceptDownloadOptions = useCallback(() => {
if (userCanDownload === true) {
if (itemsNotDownloaded.some((i) => !i.Id)) {
throw new Error("No item id");
}
closeModal();
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
else {
queueActions.enqueue(
queue,
setQueue,
...itemsNotDownloaded.map((item) => ({
id: item.Id!,
execute: async () => await initiateDownload(item),
item,
})),
);
}
} else {
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
);
}
}, [
queue,
setQueue,
itemsNotDownloaded,
usingOptimizedServer,
userCanDownload,
maxBitrate,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
]);
const initiateDownload = useCallback( const initiateDownload = useCallback(
async (...items: BaseItemDto[]) => { async (...items: BaseItemDto[]) => {
if ( if (
!api || !api ||
!user?.Id || !user?.Id ||
items.some((p) => !p.Id) || items.some((p) => !p.Id) ||
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id) (itemsNotDownloaded.length === 1 && !selectedOptions?.mediaSource?.Id)
) { ) {
throw new Error( throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item", "DownloadItem ~ initiateDownload: No api or user or item",
); );
} }
let mediaSource = selectedMediaSource; const downloadDetailsPromises = items.map(async (item) => {
let audioIndex: number | undefined = selectedAudioStream; const { mediaSource, audioIndex, subtitleIndex } =
let subtitleIndex: number | undefined = selectedSubtitleStream; itemsNotDownloaded.length > 1
? getDefaultPlaySettings(item, settings!)
: {
mediaSource: selectedOptions?.mediaSource,
audioIndex: selectedOptions?.audioIndex,
subtitleIndex: selectedOptions?.subtitleIndex,
};
for (const item of items) { const downloadDetails = await getDownloadUrl({
if (itemsNotDownloaded.length > 1) {
const defaults = getDefaultPlaySettings(item, settings!);
mediaSource = defaults.mediaSource;
audioIndex = defaults.audioIndex;
subtitleIndex = defaults.subtitleIndex;
// Keep using the selected bitrate for consistency across all downloads
}
const res = await getStreamUrl({
api, api,
item, item,
startTimeTicks: 0, userId: user.Id!,
userId: user?.Id, mediaSource: mediaSource!,
audioStreamIndex: audioIndex, audioStreamIndex: audioIndex ?? -1,
maxStreamingBitrate: maxBitrate.value, subtitleStreamIndex: subtitleIndex ?? -1,
mediaSourceId: mediaSource?.Id, maxBitrate: selectedOptions?.bitrate || defaultBitrate,
subtitleStreamIndex: subtitleIndex, deviceId: api.deviceInfo.id,
deviceProfile: download,
}); });
if (!res) { return {
url: downloadDetails?.url,
item,
mediaSource: downloadDetails?.mediaSource,
};
});
const downloadDetails = await Promise.all(downloadDetailsPromises);
for (const { url, item, mediaSource } of downloadDetails) {
if (!url) {
Alert.alert( Alert.alert(
t("home.downloads.something_went_wrong"), t("home.downloads.something_went_wrong"),
t("home.downloads.could_not_get_stream_url_from_jellyfin"), t("home.downloads.could_not_get_stream_url_from_jellyfin"),
); );
continue; continue;
} }
if (!mediaSource) {
const { mediaSource: source, url } = res; console.error(`Could not get download URL for ${item.Name}`);
toast.error(
if (!url || !source) throw new Error("No url"); t("home.downloads.toasts.could_not_get_download_url_for_item", {
itemName: item.Name,
if (usingOptimizedServer) { }),
saveDownloadItemInfoToDiskTmp(item, source, url); );
await startBackgroundDownload(url, item, source); continue;
} else {
//await startRemuxing(item, url, source);
} }
await startBackgroundDownload(
url,
item,
mediaSource,
selectedOptions?.bitrate || defaultBitrate,
);
} }
}, },
[ [
api, api,
user?.Id, user?.Id,
itemsNotDownloaded, itemsNotDownloaded,
selectedMediaSource, selectedOptions,
selectedAudioStream,
selectedSubtitleStream,
settings, settings,
maxBitrate, defaultBitrate,
usingOptimizedServer,
startBackgroundDownload, startBackgroundDownload,
//startRemuxing,
], ],
); );
const acceptDownloadOptions = useCallback(async () => {
if (userCanDownload === true) {
if (itemsToDownload.some((i) => !i.Id)) {
throw new Error("No item id");
}
// Ensure modal is dismissed before starting download
await closeModal();
// Small delay to ensure modal is fully dismissed
setTimeout(() => {
initiateDownload(...itemsToDownload);
}, 100);
} else {
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
);
}
}, [closeModal, initiateDownload, itemsToDownload, userCanDownload]);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => ( (props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop <BottomSheetBackdrop
@@ -263,19 +280,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
), ),
[], [],
); );
useFocusEffect(
useCallback(() => {
if (!settings) return;
if (itemsNotDownloaded.length !== 1) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(items[0], settings);
setSelectedMediaSource(mediaSource ?? undefined);
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [items, itemsNotDownloaded, settings]),
);
const renderButtonContent = () => { const renderButtonContent = () => {
if (processes.length > 0 && itemsProcesses.length > 0) { if (processes.length > 0 && itemsProcesses.length > 0) {
@@ -332,7 +336,15 @@ export const DownloadItems: React.FC<DownloadProps> = ({
backgroundColor: "#171717", backgroundColor: "#171717",
}} }}
onChange={handleSheetChanges} onChange={handleSheetChanges}
onDismiss={() => {
// Ensure any pending state is cleared when modal is dismissed
}}
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
enablePanDownToClose
enableDismissOnClose
android_keyboardInputMode='adjustResize'
keyboardBehavior='interactive'
keyboardBlurBehavior='restore'
> >
<BottomSheetView> <BottomSheetView>
<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'>
@@ -343,40 +355,78 @@ export const DownloadItems: React.FC<DownloadProps> = ({
<Text className='text-neutral-300'> <Text className='text-neutral-300'>
{subtitle || {subtitle ||
t("item_card.download.download_x_item", { t("item_card.download.download_x_item", {
item_count: itemsNotDownloaded.length, item_count: itemsToDownload.length,
})} })}
</Text> </Text>
</View> </View>
<View className='flex flex-col space-y-2 w-full items-start'> <View className='flex flex-col space-y-2 w-full items-start'>
<BitrateSelector <BitrateSelector
inverted inverted
onChange={setMaxBitrate} onChange={(val) =>
selected={maxBitrate} setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val },
)
}
selected={selectedOptions?.bitrate}
/> />
{itemsNotDownloaded.length > 1 && (
<View className='flex flex-row items-center justify-between w-full py-2'>
<Text>{t("item_card.download.download_unwatched_only")}</Text>
<Switch
onValueChange={setDownloadUnwatchedOnly}
value={downloadUnwatchedOnly}
/>
</View>
)}
{itemsNotDownloaded.length === 1 && ( {itemsNotDownloaded.length === 1 && (
<> <View>
<MediaSourceSelector <MediaSourceSelector
item={items[0]} item={items[0]}
onChange={setSelectedMediaSource} onChange={(val) =>
selected={selectedMediaSource} setSelectedOptions(
(prev) =>
prev && {
...prev,
mediaSource: val,
},
)
}
selected={selectedOptions?.mediaSource}
/> />
{selectedMediaSource && ( {selectedOptions?.mediaSource && (
<View className='flex flex-col space-y-2'> <View className='flex flex-col space-y-2'>
<AudioTrackSelector <AudioTrackSelector
source={selectedMediaSource} source={selectedOptions.mediaSource}
onChange={setSelectedAudioStream} onChange={(val) => {
selected={selectedAudioStream} setSelectedOptions(
(prev) =>
prev && {
...prev,
audioIndex: val,
},
);
}}
selected={selectedOptions.audioIndex}
/> />
<SubtitleTrackSelector <SubtitleTrackSelector
source={selectedMediaSource} source={selectedOptions.mediaSource}
onChange={setSelectedSubtitleStream} onChange={(val) => {
selected={selectedSubtitleStream} setSelectedOptions(
(prev) =>
prev && {
...prev,
subtitleIndex: val,
},
);
}}
selected={selectedOptions.subtitleIndex}
/> />
</View> </View>
)} )}
</> </View>
)} )}
</View> </View>
<Button <Button
className='mt-auto' className='mt-auto'
onPress={acceptDownloadOptions} onPress={acceptDownloadOptions}
@@ -384,13 +434,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
> >
{t("item_card.download.download_button")} {t("item_card.download.download_button")}
</Button> </Button>
<View className='opacity-70 text-center w-full flex items-center'>
<Text className='text-xs'>
{usingOptimizedServer
? t("item_card.download.using_optimized_server")
: t("item_card.download.using_default_method")}
</Text>
</View>
</View> </View>
</BottomSheetView> </BottomSheetView>
</BottomSheetModal> </BottomSheetModal>

View File

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

View File

@@ -1,24 +1,3 @@
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { DownloadSingleItem } from "@/components/DownloadItem";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
import { PlayButton } from "@/components/PlayButton";
import { PlayedStatus } from "@/components/PlayedStatus";
import { SimilarItems } from "@/components/SimilarItems";
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
import { ItemImage } from "@/components/common/ItemImage";
import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors";
import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import type { import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
@@ -27,13 +6,37 @@ import { Image } from "expo-image";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { type Bitrate } from "@/components/BitrateSelector";
import { ItemImage } from "@/components/common/ItemImage";
import { DownloadSingleItem } from "@/components/DownloadItem";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
import { PlayButton } from "@/components/PlayButton";
import { PlayedStatus } from "@/components/PlayedStatus";
import { SimilarItems } from "@/components/SimilarItems";
import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { AddToFavorites } from "./AddToFavorites"; import { AddToFavorites } from "./AddToFavorites";
import { BitrateSheet } from "./BitRateSheet";
import { ItemHeader } from "./ItemHeader"; import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails"; import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { MediaSourceSelector } from "./MediaSourceSelector"; import { MediaSourceSheet } from "./MediaSourceSheet";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
import { TrackSheet } from "./TrackSheet";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null; const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
export type SelectedOptions = { export type SelectedOptions = {
@@ -43,14 +46,22 @@ export type SelectedOptions = {
subtitleIndex: number; subtitleIndex: number;
}; };
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( interface ItemContentProps {
({ item }) => { item: BaseItemDto;
isOffline: boolean;
}
export const ItemContent: React.FC<ItemContentProps> = React.memo(
({ item, isOffline }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [settings] = useSettings(); const { settings } = useSettings();
const { orientation } = useOrientation(); const { orientation } = useOrientation();
const navigation = useNavigation(); const navigation = useNavigation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
useImageColors({ item }); const [user] = useAtom(userAtom);
const { t } = useTranslation();
const itemColors = useImageColorsReturn({ item });
const [loadingLogo, setLoadingLogo] = useState(true); const [loadingLogo, setLoadingLogo] = useState(true);
const [headerHeight, setHeaderHeight] = useState(350); const [headerHeight, setHeaderHeight] = useState(350);
@@ -64,7 +75,16 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
defaultBitrate, defaultBitrate,
defaultMediaSource, defaultMediaSource,
defaultSubtitleIndex, defaultSubtitleIndex,
} = useDefaultPlaySettings(item, settings); } = useDefaultPlaySettings(item!, settings);
const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null),
[api, item],
);
const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
// Needs to automatically change the selected to the default values for default indexes. // Needs to automatically change the selected to the default values for default indexes.
useEffect(() => { useEffect(() => {
@@ -81,45 +101,60 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
defaultMediaSource, defaultMediaSource,
]); ]);
if (!Platform.isTV) { useEffect(() => {
useEffect(() => { if (!Platform.isTV) {
navigation.setOptions({ navigation.setOptions({
headerRight: () => headerRight: () =>
item && ( item &&
<View className='flex flex-row items-center space-x-2'> (Platform.OS === "ios" ? (
<Chromecast.Chromecast <View className='flex flex-row items-center pl-2'>
background='blur' <Chromecast.Chromecast width={22} height={22} />
width={22}
height={22}
/>
{item.Type !== "Program" && ( {item.Type !== "Program" && (
<View className='flex flex-row items-center space-x-2'> <View className='flex flex-row items-center'>
{!Platform.isTV && ( {!Platform.isTV && (
<DownloadSingleItem item={item} size='large' /> <DownloadSingleItem item={item} size='large' />
)} )}
{user?.Policy?.IsAdministrator && (
<PlayInRemoteSessionButton item={item} size='large' />
)}
<PlayedStatus items={[item]} size='large' /> <PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} /> <AddToFavorites item={item} />
</View> </View>
)} )}
</View> </View>
), ) : (
<View className='flex flex-row items-center space-x-2'>
<Chromecast.Chromecast width={22} height={22} />
{item.Type !== "Program" && (
<View className='flex flex-row items-center space-x-2'>
{!Platform.isTV && (
<DownloadSingleItem item={item} size='large' />
)}
{user?.Policy?.IsAdministrator && (
<PlayInRemoteSessionButton item={item} size='large' />
)}
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
</View>
)}
</View>
)),
}); });
}, [item]); }
} }, [item, navigation, user]);
useEffect(() => { useEffect(() => {
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP) if (item) {
setHeaderHeight(230); if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
else if (item.Type === "Movie") setHeaderHeight(500); setHeaderHeight(230);
else setHeaderHeight(350); else if (item.Type === "Movie") setHeaderHeight(500);
}, [item.Type, orientation]); else setHeaderHeight(350);
}
}, [item, orientation]);
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]); if (!item || !selectedOptions) return null;
const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
if (!selectedOptions) return null;
return ( return (
<View <View
@@ -155,20 +190,22 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
style={{ style={{
height: 130, height: 130,
width: "100%", width: "100%",
resizeMode: "contain",
}} }}
contentFit='contain'
onLoad={() => setLoadingLogo(false)} onLoad={() => setLoadingLogo(false)}
onError={() => setLoadingLogo(false)} onError={() => setLoadingLogo(false)}
/> />
) : null ) : (
<View />
)
} }
> >
<View className='flex flex-col bg-transparent shrink'> <View className='flex flex-col bg-transparent shrink'>
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'> <View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
<ItemHeader item={item} className='mb-4' /> <ItemHeader item={item} className='mb-2' />
{item.Type !== "Program" && !Platform.isTV && ( {item.Type !== "Program" && !Platform.isTV && !isOffline && (
<View className='flex flex-row items-center justify-start w-full h-16'> <View className='flex flex-row items-center justify-start w-full h-16'>
<BitrateSelector <BitrateSheet
className='mr-1' className='mr-1'
onChange={(val) => onChange={(val) =>
setSelectedOptions( setSelectedOptions(
@@ -177,7 +214,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
} }
selected={selectedOptions.bitrate} selected={selectedOptions.bitrate}
/> />
<MediaSourceSelector <MediaSourceSheet
className='mr-1' className='mr-1'
item={item} item={item}
onChange={(val) => onChange={(val) =>
@@ -191,8 +228,10 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
} }
selected={selectedOptions.mediaSource} selected={selectedOptions.mediaSource}
/> />
<AudioTrackSelector <TrackSheet
className='mr-1' className='mr-1'
streamType='Audio'
title={t("item_card.audio")}
source={selectedOptions.mediaSource} source={selectedOptions.mediaSource}
onChange={(val) => { onChange={(val) => {
setSelectedOptions( setSelectedOptions(
@@ -205,8 +244,10 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
}} }}
selected={selectedOptions.audioIndex} selected={selectedOptions.audioIndex}
/> />
<SubtitleTrackSelector <TrackSheet
source={selectedOptions.mediaSource} source={selectedOptions.mediaSource}
streamType='Subtitle'
title={t("item_card.subtitles")}
onChange={(val) => onChange={(val) =>
setSelectedOptions( setSelectedOptions(
(prev) => (prev) =>
@@ -225,25 +266,35 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
className='grow' className='grow'
selectedOptions={selectedOptions} selectedOptions={selectedOptions}
item={item} item={item}
isOffline={isOffline}
colors={itemColors}
/> />
</View> </View>
{item.Type === "Episode" && ( {item.Type === "Episode" && (
<SeasonEpisodesCarousel item={item} loading={loading} /> <SeasonEpisodesCarousel
item={item}
loading={loading}
isOffline={isOffline}
/>
)} )}
<ItemTechnicalDetails source={selectedOptions.mediaSource} /> {!isOffline && (
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
)}
<OverviewText text={item.Overview} className='px-4 mb-4' /> <OverviewText text={item.Overview} className='px-4 mb-4' />
{item.Type !== "Program" && ( {item.Type !== "Program" && (
<> <>
{item.Type === "Episode" && ( {item.Type === "Episode" && !isOffline && (
<CurrentSeries item={item} className='mb-4' /> <CurrentSeries item={item} className='mb-4' />
)} )}
<CastAndCrew item={item} className='mb-4' loading={loading} /> {!isOffline && (
<CastAndCrew item={item} className='mb-4' loading={loading} />
)}
{item.People && item.People.length > 0 && ( {item.People && item.People.length > 0 && !isOffline && (
<View className='mb-4'> <View className='mb-4'>
{item.People.slice(0, 3).map((person, idx) => ( {item.People.slice(0, 3).map((person, idx) => (
<MoreMoviesWithActor <MoreMoviesWithActor
@@ -256,7 +307,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</View> </View>
)} )}
<SimilarItems itemId={item.Id} /> {!isOffline && <SimilarItems itemId={item.Id} />}
</> </>
)} )}
</View> </View>

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