Compare commits

..

3 Commits

Author SHA1 Message Date
Fredrik Burmester
90474b2403 fix(android): building and video playing with correct aspect ratio 2026-01-05 11:52:00 +01:00
Fredrik Burmester
2e463ddb0c chore: version 2026-01-05 10:06:34 +01:00
Fredrik Burmester
98fddcda74 fix: item content header button colors 2026-01-05 10:06:29 +01:00
288 changed files with 10429 additions and 34037 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

7
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,238 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "expo-router";
import { TFunction } from "i18next";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { useSettings } from "@/utils/atoms/settings";
/**
* Factory function to create skip options for a specific segment type
* Reduces code duplication across all 5 segment types
*/
const useSkipOptions = (
settingKey:
| "skipIntro"
| "skipOutro"
| "skipRecap"
| "skipCommercial"
| "skipPreview",
settings: ReturnType<typeof useSettings>["settings"] | null,
updateSettings: ReturnType<typeof useSettings>["updateSettings"],
t: TFunction<"translation", undefined>,
) => {
return useMemo(
() => [
{
options: SEGMENT_SKIP_OPTIONS(t).map((option) => ({
type: "radio" as const,
label: option.label,
value: option.value,
selected: option.value === settings?.[settingKey],
onPress: () => updateSettings({ [settingKey]: option.value }),
})),
},
],
[settings?.[settingKey], updateSettings, t, settingKey],
);
};
export default function SegmentSkipPage() {
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
title: t("home.settings.other.segment_skip_settings"),
});
}, [navigation, t]);
const skipIntroOptions = useSkipOptions(
"skipIntro",
settings,
updateSettings,
t,
);
const skipOutroOptions = useSkipOptions(
"skipOutro",
settings,
updateSettings,
t,
);
const skipRecapOptions = useSkipOptions(
"skipRecap",
settings,
updateSettings,
t,
);
const skipCommercialOptions = useSkipOptions(
"skipCommercial",
settings,
updateSettings,
t,
);
const skipPreviewOptions = useSkipOptions(
"skipPreview",
settings,
updateSettings,
t,
);
if (!settings) return null;
return (
<DisabledSetting disabled={false} className='px-4'>
<ListGroup>
<ListItem
title={t("home.settings.other.skip_intro")}
subtitle={t("home.settings.other.skip_intro_description")}
disabled={pluginSettings?.skipIntro?.locked}
>
<PlatformDropdown
groups={skipIntroOptions}
disabled={pluginSettings?.skipIntro?.locked}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(`home.settings.other.segment_skip_${settings.skipIntro}`)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.other.skip_intro")}
/>
</ListItem>
<ListItem
title={t("home.settings.other.skip_outro")}
subtitle={t("home.settings.other.skip_outro_description")}
disabled={pluginSettings?.skipOutro?.locked}
>
<PlatformDropdown
groups={skipOutroOptions}
disabled={pluginSettings?.skipOutro?.locked}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(`home.settings.other.segment_skip_${settings.skipOutro}`)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.other.skip_outro")}
/>
</ListItem>
<ListItem
title={t("home.settings.other.skip_recap")}
subtitle={t("home.settings.other.skip_recap_description")}
disabled={pluginSettings?.skipRecap?.locked}
>
<PlatformDropdown
groups={skipRecapOptions}
disabled={pluginSettings?.skipRecap?.locked}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(`home.settings.other.segment_skip_${settings.skipRecap}`)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.other.skip_recap")}
/>
</ListItem>
<ListItem
title={t("home.settings.other.skip_commercial")}
subtitle={t("home.settings.other.skip_commercial_description")}
disabled={pluginSettings?.skipCommercial?.locked}
>
<PlatformDropdown
groups={skipCommercialOptions}
disabled={pluginSettings?.skipCommercial?.locked}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
`home.settings.other.segment_skip_${settings.skipCommercial}`,
)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.other.skip_commercial")}
/>
</ListItem>
<ListItem
title={t("home.settings.other.skip_preview")}
subtitle={t("home.settings.other.skip_preview_description")}
disabled={pluginSettings?.skipPreview?.locked}
>
<PlatformDropdown
groups={skipPreviewOptions}
disabled={pluginSettings?.skipPreview?.locked}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
`home.settings.other.segment_skip_${settings.skipPreview}`,
)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.other.skip_preview")}
/>
</ListItem>
</ListGroup>
</DisabledSetting>
);
}
const SEGMENT_SKIP_OPTIONS = (
t: TFunction<"translation", undefined>,
): Array<{
label: string;
value: "none" | "ask" | "auto";
}> => [
{
label: t("home.settings.other.segment_skip_auto"),
value: "auto",
},
{
label: t("home.settings.other.segment_skip_ask"),
value: "ask",
},
{
label: t("home.settings.other.segment_skip_none"),
value: "none",
},
];

View File

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

View File

@@ -8,7 +8,7 @@ import {
} from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
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";
@@ -27,7 +27,6 @@ import { PlatformDropdown } from "@/components/PlatformDropdown";
import { JellyserrRatings } from "@/components/Ratings";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

823
bun.lock

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,23 +1,14 @@
import { Feather } from "@expo/vector-icons";
import type { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
import { router } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react";
import { Platform } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { useCallback, useEffect } from "react";
import { Platform, TouchableOpacity } from "react-native";
import GoogleCast, {
CastButton,
CastContext,
CastState,
useCastDevice,
useCastState,
useDevices,
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { ChromecastConnectionMenu } from "./chromecast/ChromecastConnectionMenu";
import { RoundButton } from "./RoundButton";
export function Chromecast({
@@ -26,137 +17,23 @@ export function Chromecast({
background = "transparent",
...props
}) {
// Hooks called for their side effects (keep Chromecast session active)
useRemoteMediaClient();
useCastDevice();
const castState = useCastState();
useDevices();
const client = useRemoteMediaClient();
const castDevice = useCastDevice();
const devices = useDevices();
const sessionManager = GoogleCast.getSessionManager();
const discoveryManager = GoogleCast.getDiscoveryManager();
const mediaStatus = useMediaStatus();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
// Connection menu state
const [showConnectionMenu, setShowConnectionMenu] = useState(false);
const isConnected = castState === CastState.CONNECTED;
const lastReportedProgressRef = useRef(0);
const lastReportedPlayerStateRef = useRef<string | null>(null);
const playSessionIdRef = useRef<string | null>(null);
const lastContentIdRef = useRef<string | null>(null);
const discoveryAttempts = useRef(0);
const maxDiscoveryAttempts = 3;
// Enhanced discovery with retry mechanism - runs once on mount
useEffect(() => {
let isSubscribed = true;
let retryTimeout: NodeJS.Timeout;
const startDiscoveryWithRetry = async () => {
(async () => {
if (!discoveryManager) {
console.warn("DiscoveryManager is not initialized");
return;
}
try {
// Stop any existing discovery first
try {
await discoveryManager.stopDiscovery();
} catch {
// Ignore errors when stopping
}
// Start fresh discovery
await discoveryManager.startDiscovery();
discoveryAttempts.current = 0; // Reset on success
} catch (error) {
console.error("[Chromecast Discovery] Failed:", error);
// Retry on error
if (discoveryAttempts.current < maxDiscoveryAttempts && isSubscribed) {
discoveryAttempts.current++;
retryTimeout = setTimeout(() => {
if (isSubscribed) {
startDiscoveryWithRetry();
}
}, 2000);
}
}
};
startDiscoveryWithRetry();
return () => {
isSubscribed = false;
if (retryTimeout) {
clearTimeout(retryTimeout);
}
};
}, [discoveryManager]); // Only re-run if discoveryManager changes
// Report video progress to Jellyfin server
useEffect(() => {
if (!api || !user?.Id || !mediaStatus?.mediaInfo?.contentId) {
return;
}
const streamPosition = mediaStatus.streamPosition || 0;
const playerState = mediaStatus.playerState || null;
// Report every 10 seconds OR immediately when playerState changes (pause/resume)
const positionChanged =
Math.abs(streamPosition - lastReportedProgressRef.current) >= 10;
const stateChanged = playerState !== lastReportedPlayerStateRef.current;
if (!positionChanged && !stateChanged) {
return;
}
const contentId = mediaStatus.mediaInfo.contentId;
// Generate a new PlaySessionId when the content changes
if (contentId !== lastContentIdRef.current) {
const randomBytes = new Uint8Array(16);
crypto.getRandomValues(randomBytes);
// Format as UUID v4
randomBytes[6] = (randomBytes[6] & 0x0f) | 0x40; // Version 4
randomBytes[8] = (randomBytes[8] & 0x3f) | 0x80; // Variant 10
const uuid = Array.from(randomBytes, (b, i) => {
const hex = b.toString(16).padStart(2, "0");
return [4, 6, 8, 10].includes(i) ? `-${hex}` : hex;
}).join("");
playSessionIdRef.current = uuid;
lastContentIdRef.current = contentId;
}
const positionTicks = Math.floor(streamPosition * 10000000);
const isPaused = mediaStatus.playerState === "paused";
const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
const isTranscoding = /m3u8/i.test(streamUrl);
const progressInfo: PlaybackProgressInfo = {
ItemId: contentId,
PositionTicks: positionTicks,
IsPaused: isPaused,
PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
PlaySessionId: playSessionIdRef.current || contentId,
};
getPlaystateApi(api)
.reportPlaybackProgress({ playbackProgressInfo: progressInfo })
.then(() => {
lastReportedProgressRef.current = streamPosition;
lastReportedPlayerStateRef.current = playerState;
})
.catch((error) => {
console.error("Failed to report Chromecast progress:", error);
});
}, [
api,
user?.Id,
mediaStatus?.streamPosition,
mediaStatus?.mediaInfo?.contentId,
mediaStatus?.playerState,
mediaStatus?.mediaInfo?.contentUrl,
]);
await discoveryManager.startDiscovery();
})();
}, [client, devices, castDevice, sessionManager, discoveryManager]);
// Android requires the cast button to be present for startDiscovery to work
const AndroidCastButton = useCallback(
@@ -165,92 +42,50 @@ export function Chromecast({
[Platform.OS],
);
// Handle press - show connection menu when connected, otherwise show cast dialog
const handlePress = useCallback(() => {
if (isConnected) {
if (mediaStatus?.currentItemId) {
// Media is playing - navigate to full player
router.push("/casting-player");
} else {
// Connected but no media - show connection menu
setShowConnectionMenu(true);
}
} else {
// Not connected - show cast dialog
CastContext.showCastDialog();
}
}, [isConnected, mediaStatus?.currentItemId]);
// Handle disconnect from Chromecast
const handleDisconnect = useCallback(async () => {
try {
const sessionManager = GoogleCast.getSessionManager();
await sessionManager.endCurrentSession(true);
} catch (error) {
console.error("[Chromecast] Disconnect error:", error);
}
}, []);
if (Platform.OS === "ios") {
return (
<>
<Pressable className='mr-4' onPress={handlePress} {...props}>
<AndroidCastButton />
<Feather
name='cast'
size={22}
color={isConnected ? "#a855f7" : "white"}
/>
</Pressable>
<ChromecastConnectionMenu
visible={showConnectionMenu}
onClose={() => setShowConnectionMenu(false)}
onDisconnect={handleDisconnect}
/>
</>
<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")
return (
<>
<RoundButton
size='large'
className='mr-2'
background={false}
onPress={handlePress}
{...props}
>
<AndroidCastButton />
<Feather
name='cast'
size={22}
color={isConnected ? "#a855f7" : "white"}
/>
</RoundButton>
<ChromecastConnectionMenu
visible={showConnectionMenu}
onClose={() => setShowConnectionMenu(false)}
onDisconnect={handleDisconnect}
/>
</>
<RoundButton
size='large'
className='mr-2'
background={false}
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
{...props}
>
<AndroidCastButton />
<Feather name='cast' size={22} color={"white"} />
</RoundButton>
);
return (
<>
<RoundButton size='large' onPress={handlePress} {...props}>
<AndroidCastButton />
<Feather
name='cast'
size={22}
color={isConnected ? "#a855f7" : "white"}
/>
</RoundButton>
<ChromecastConnectionMenu
visible={showConnectionMenu}
onClose={() => setShowConnectionMenu(false)}
onDisconnect={handleDisconnect}
/>
</>
<RoundButton
size='large'
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
{...props}
>
<AndroidCastButton />
<Feather name='cast' size={22} color={"white"} />
</RoundButton>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,14 +25,7 @@ export type ToggleOption = {
disabled?: boolean;
};
export type ActionOption = {
type: "action";
label: string;
onPress: () => void;
disabled?: boolean;
};
export type Option = RadioOption | ToggleOption | ActionOption;
export type Option = RadioOption | ToggleOption;
// Option group structure
export type OptionGroup = {
@@ -47,7 +40,6 @@ interface PlatformDropdownProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
onOptionSelect?: (value?: any) => void;
disabled?: boolean;
expoUIConfig?: {
hostStyle?: any;
};
@@ -72,10 +64,7 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
isLast,
}) => {
const isToggle = option.type === "toggle";
const isAction = option.type === "action";
const handlePress = isToggle
? option.onToggle
: (option as RadioOption | ActionOption).onPress;
const handlePress = isToggle ? option.onToggle : option.onPress;
return (
<>
@@ -87,7 +76,7 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
<Text className='flex-1 text-white'>{option.label}</Text>
{isToggle ? (
<ToggleSwitch value={option.value} />
) : isAction ? null : (option as RadioOption).selected ? (
) : option.selected ? (
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
) : (
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
@@ -161,15 +150,6 @@ const BottomSheetContent: React.FC<{
},
};
}
if (option.type === "action") {
return {
...option,
onPress: () => {
option.onPress();
onClose?.();
},
};
}
return option;
}),
}));
@@ -198,7 +178,6 @@ const PlatformDropdownComponent = ({
onOpenChange: controlledOnOpenChange,
onOptionSelect,
expoUIConfig,
disabled,
bottomSheetConfig,
}: PlatformDropdownProps) => {
const { showModal, hideModal, isVisible } = useGlobalModal();
@@ -233,13 +212,6 @@ const PlatformDropdownComponent = ({
}, [isVisible, controlledOpen, controlledOnOpenChange]);
if (Platform.OS === "ios") {
if (disabled) {
return (
<View style={{ opacity: 0.5 }} pointerEvents='none'>
{trigger || <Text className='text-white'>Open Menu</Text>}
</View>
);
}
return (
<Host style={expoUIConfig?.hostStyle}>
<ContextMenu>
@@ -253,9 +225,6 @@ const PlatformDropdownComponent = ({
const toggleOptions = group.options.filter(
(opt) => opt.type === "toggle",
) as ToggleOption[];
const actionOptions = group.options.filter(
(opt) => opt.type === "action",
) as ActionOption[];
const items = [];
@@ -322,21 +291,6 @@ const PlatformDropdownComponent = ({
);
});
// Add Buttons for action options (no icon)
actionOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`action-${groupIndex}-${optionIndex}`}
onPress={() => {
option.onPress();
}}
disabled={option.disabled}
>
{option.label}
</Button>,
);
});
return items;
})}
</ContextMenu.Items>
@@ -362,14 +316,8 @@ const PlatformDropdownComponent = ({
};
return (
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.7}
disabled={disabled}
>
<View style={disabled ? { opacity: 0.5 } : undefined}>
{trigger || <Text className='text-white'>Open Menu</Text>}
</View>
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
{trigger || <Text className='text-white'>Open Menu</Text>}
</TouchableOpacity>
);
};

View File

@@ -2,13 +2,13 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons } from "@expo/vector-icons";
import { BottomSheetView } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import CastContext, {
CastButton,
MediaPlayerState,
PlayServicesState,
useMediaStatus,
useRemoteMediaClient,
@@ -23,16 +23,15 @@ import Animated, {
useSharedValue,
withTiming,
} from "react-native-reanimated";
import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { getDownloadedItemById } from "@/providers/Downloads/database";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { buildCastMediaInfo } from "@/utils/casting/mediaInfo";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
@@ -44,6 +43,7 @@ import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
item: BaseItemDto;
selectedOptions: SelectedOptions;
isOffline?: boolean;
colors?: ThemeColors;
}
@@ -53,9 +53,9 @@ const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({
item,
selectedOptions,
isOffline,
colors,
}: Props) => {
const isOffline = useOfflineMode();
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const mediaStatus = useMediaStatus();
@@ -110,11 +110,7 @@ export const PlayButton: React.FC<Props> = ({
return;
}
const options = [
t("casting_player.chromecast"),
t("casting_player.device"),
t("casting_player.cancel"),
];
const options = ["Chromecast", "Device", "Cancel"];
const cancelButtonIndex = 2;
showActionSheetWithOptions(
{
@@ -123,14 +119,9 @@ export const PlayButton: React.FC<Props> = ({
},
async (selectedIndex: number | undefined) => {
if (!api) return;
// Compare item IDs AND check if media is actually playing (not stopped/idle)
const currentContentId = mediaStatus?.mediaInfo?.contentId;
const isMediaActive =
mediaStatus?.playerState === MediaPlayerState.PLAYING ||
mediaStatus?.playerState === MediaPlayerState.PAUSED ||
mediaStatus?.playerState === MediaPlayerState.BUFFERING;
const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
const isOpeningCurrentlyPlayingMedia =
isMediaActive && currentContentId && currentContentId === item?.Id;
currentTitle && currentTitle === item?.Name;
switch (selectedIndex) {
case 0:
@@ -183,6 +174,8 @@ export const PlayButton: React.FC<Props> = ({
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
console.log("URL: ", data?.url, enableH265);
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
@@ -192,30 +185,73 @@ export const PlayButton: React.FC<Props> = ({
return;
}
const startTimeSeconds =
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
client
.loadMedia({
mediaInfo: buildCastMediaInfo({
item,
streamUrl: data.url,
api,
}),
startTime: startTimeSeconds,
mediaInfo: {
contentUrl: data?.url,
contentType: "video/mp4",
metadata:
item.Type === "Episode"
? {
type: "tvShow",
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: item.Type === "Movie"
? {
type: "movie",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: {
type: "generic",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
},
},
startTime: 0,
})
.then(() => {
// state is already set when reopening current media, so skip it here.
if (isOpeningCurrentlyPlayingMedia) {
return;
}
router.push("/casting-player");
})
.catch((err) => {
console.error("[PlayButton] loadMedia failed:", err);
CastContext.showExpandedControls();
});
} catch (e) {
console.error("[PlayButton] Cast error:", e);
console.log(e);
}
}
});
@@ -251,19 +287,6 @@ export const PlayButton: React.FC<Props> = ({
// Check if item is downloaded
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
// If already in offline mode, play downloaded file directly
if (isOffline && downloadedItem) {
const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
return;
}
// If online but file is downloaded, ask user which version to play
if (downloadedItem) {
if (Platform.OS === "android") {
// Show bottom sheet for Android

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import {
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 {
@@ -25,7 +26,6 @@ import Animated, {
useSharedValue,
withTiming,
} from "react-native-reanimated";
import useRouter from "@/hooks/useAppRouter";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";

View File

@@ -1,457 +0,0 @@
/**
* Unified Casting Mini Player
* Works with all supported casting protocols
*/
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { router } from "expo-router";
import { useAtomValue } from "jotai";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Dimensions, Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
MediaPlayerState,
useCastDevice,
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
import Animated, {
SlideInDown,
SlideOutDown,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useTrickplay } from "@/hooks/useTrickplay";
import { apiAtom } from "@/providers/JellyfinProvider";
import {
formatTime,
formatTrickplayTime,
getPosterUrl,
} from "@/utils/casting/helpers";
import { CASTING_CONSTANTS } from "@/utils/casting/types";
import { msToTicks, ticksToSeconds } from "@/utils/time";
export const CastingMiniPlayer: React.FC = () => {
const api = useAtomValue(apiAtom);
const insets = useSafeAreaInsets();
const castDevice = useCastDevice();
const mediaStatus = useMediaStatus();
const remoteMediaClient = useRemoteMediaClient();
const currentItem = useMemo(() => {
return mediaStatus?.mediaInfo?.customData as BaseItemDto | undefined;
}, [mediaStatus?.mediaInfo?.customData]);
// Trickplay support - pass currentItem as BaseItemDto or null
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
currentItem || null,
);
const [trickplayTime, setTrickplayTime] = useState({
hours: 0,
minutes: 0,
seconds: 0,
});
const [scrubPercentage, setScrubPercentage] = useState(0);
const isScrubbing = useRef(false);
// Slider shared values
const sliderProgress = useSharedValue(0);
const sliderMin = useSharedValue(0);
const sliderMax = useSharedValue(100);
// Live progress state that updates every second when playing
const [liveProgress, setLiveProgress] = useState(
mediaStatus?.streamPosition || 0,
);
// Track baseline for elapsed-time computation
const baselinePositionRef = useRef(mediaStatus?.streamPosition || 0);
const baselineTimestampRef = useRef(Date.now());
// Sync live progress with mediaStatus and poll every second when playing
useEffect(() => {
// Resync baseline whenever mediaStatus reports a new position
if (mediaStatus?.streamPosition !== undefined) {
baselinePositionRef.current = mediaStatus.streamPosition;
baselineTimestampRef.current = Date.now();
setLiveProgress(mediaStatus.streamPosition);
}
// Update based on elapsed real time when playing
const interval = setInterval(() => {
if (mediaStatus?.playerState === MediaPlayerState.PLAYING) {
const elapsed =
((Date.now() - baselineTimestampRef.current) *
(mediaStatus.playbackRate || 1)) /
1000;
setLiveProgress(baselinePositionRef.current + elapsed);
} else if (mediaStatus?.streamPosition !== undefined) {
// Sync with actual position when paused/buffering
baselinePositionRef.current = mediaStatus.streamPosition;
baselineTimestampRef.current = Date.now();
setLiveProgress(mediaStatus.streamPosition);
}
}, 1000);
return () => clearInterval(interval);
}, [
mediaStatus?.playerState,
mediaStatus?.streamPosition,
mediaStatus?.playbackRate,
]);
const progress = liveProgress * 1000; // Convert to ms
const duration = (mediaStatus?.mediaInfo?.streamDuration || 0) * 1000;
const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING;
// Update slider max value when duration changes
useEffect(() => {
if (duration > 0) {
sliderMax.value = duration;
}
}, [duration, sliderMax]);
// Sync slider progress with live progress (when not scrubbing)
useEffect(() => {
if (!isScrubbing.current && progress >= 0) {
sliderProgress.value = progress;
}
}, [progress, sliderProgress]);
// For episodes, use series poster; for other content, use item poster
const posterUrl = useMemo(() => {
if (!api?.basePath || !currentItem) return null;
if (
currentItem.Type === "Episode" &&
currentItem.SeriesId &&
currentItem.ParentIndexNumber !== undefined &&
currentItem.SeasonId
) {
// Build series poster URL using SeriesId and series-level image tag
const imageTag = currentItem.SeriesPrimaryImageTag || "";
const tagParam = imageTag ? `&tag=${imageTag}` : "";
return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96${tagParam}`;
}
// For non-episodes, use item's own poster
return getPosterUrl(
api.basePath,
currentItem.Id,
currentItem.ImageTags?.Primary,
80,
120,
);
}, [api?.basePath, currentItem]);
// Hide mini player when:
// - No cast device connected
// - No media info (currentItem)
// - No media status
// - Media is stopped (IDLE state)
// - Media is unknown state
const playerState = mediaStatus?.playerState;
const isMediaStopped = playerState === MediaPlayerState.IDLE;
if (!castDevice || !currentItem || !mediaStatus || isMediaStopped) {
return null;
}
const protocolColor = "#a855f7"; // Streamyfin purple
const TAB_BAR_HEIGHT = 80; // Standard tab bar height
const handlePress = () => {
router.push("/casting-player");
};
const handleTogglePlayPause = () => {
if (isPlaying) {
remoteMediaClient?.pause()?.catch((error: unknown) => {
console.error("[CastingMiniPlayer] Pause error:", error);
});
} else {
remoteMediaClient?.play()?.catch((error: unknown) => {
console.error("[CastingMiniPlayer] Play error:", error);
});
}
};
return (
<Animated.View
entering={SlideInDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
exiting={SlideOutDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
style={{
position: "absolute",
bottom: TAB_BAR_HEIGHT + insets.bottom,
left: 0,
right: 0,
backgroundColor: "#1a1a1a",
borderTopWidth: 1,
borderTopColor: "#333",
zIndex: 100,
}}
>
{/* Interactive progress slider with trickplay */}
<View style={{ paddingHorizontal: 8, paddingTop: 4 }}>
<Slider
style={{ width: "100%", height: 20 }}
progress={sliderProgress}
minimumValue={sliderMin}
maximumValue={sliderMax}
theme={{
maximumTrackTintColor: "#333",
minimumTrackTintColor: protocolColor,
bubbleBackgroundColor: protocolColor,
bubbleTextColor: "#fff",
}}
onSlidingStart={() => {
isScrubbing.current = true;
}}
onValueChange={(value) => {
// Calculate trickplay preview
const progressInTicks = msToTicks(value);
calculateTrickplayUrl(progressInTicks);
// Update time display for trickplay bubble
const progressInSeconds = Math.floor(
ticksToSeconds(progressInTicks),
);
const hours = Math.floor(progressInSeconds / 3600);
const minutes = Math.floor((progressInSeconds % 3600) / 60);
const seconds = progressInSeconds % 60;
setTrickplayTime({ hours, minutes, seconds });
// Track scrub percentage for bubble positioning
if (duration > 0) {
setScrubPercentage(value / duration);
}
}}
onSlidingComplete={(value) => {
isScrubbing.current = false;
// Seek to the position (value is in milliseconds, convert to seconds)
const positionSeconds = value / 1000;
if (remoteMediaClient && duration > 0) {
remoteMediaClient
.seek({ position: positionSeconds })
.catch((error) => {
console.error("[Mini Player] Seek error:", error);
});
}
}}
renderBubble={() => {
// Calculate bubble position with edge clamping
const screenWidth = Dimensions.get("window").width;
const sliderPadding = 8;
const thumbWidth = 10; // matches thumbWidth prop on Slider
const sliderWidth = screenWidth - sliderPadding * 2;
// Adjust thumb position to account for thumb width affecting travel range
const effectiveTrackWidth = sliderWidth - thumbWidth;
const thumbPosition =
thumbWidth / 2 + scrubPercentage * effectiveTrackWidth;
if (!trickPlayUrl || !trickplayInfo) {
// Show simple time bubble when no trickplay
const timeBubbleWidth = 70;
const minLeft = -thumbPosition;
const maxLeft = sliderWidth - thumbPosition - timeBubbleWidth;
const centeredLeft = -timeBubbleWidth / 2;
const clampedLeft = Math.max(
minLeft,
Math.min(maxLeft, centeredLeft),
);
return (
<View
style={{
position: "absolute",
bottom: 12,
left: clampedLeft,
backgroundColor: protocolColor,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
}}
>
<Text
style={{ color: "#fff", fontSize: 11, fontWeight: "600" }}
>
{formatTrickplayTime(trickplayTime)}
</Text>
</View>
);
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 140; // Smaller preview for mini player
const tileHeight = tileWidth / (trickplayInfo.aspectRatio ?? 1.78);
// Calculate clamped position for trickplay preview
const minLeft = -thumbPosition;
const maxLeft = sliderWidth - thumbPosition - tileWidth;
const centeredLeft = -tileWidth / 2;
const clampedLeft = Math.max(
minLeft,
Math.min(maxLeft, centeredLeft),
);
return (
<View
style={{
position: "absolute",
bottom: 12,
left: clampedLeft,
width: tileWidth,
alignItems: "center",
}}
>
{/* Trickplay image preview */}
<View
style={{
width: tileWidth,
height: tileHeight,
borderRadius: 6,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
<Image
cachePolicy='memory-disk'
style={{
width: tileWidth * (trickplayInfo.data?.TileWidth ?? 1),
height:
(tileWidth / (trickplayInfo.aspectRatio ?? 1.78)) *
(trickplayInfo.data?.TileHeight ?? 1),
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
}}
source={{ uri: url }}
contentFit='cover'
/>
</View>
{/* Time overlay */}
<View
style={{
position: "absolute",
bottom: 2,
left: 2,
backgroundColor: "rgba(0, 0, 0, 0.7)",
paddingHorizontal: 4,
paddingVertical: 1,
borderRadius: 3,
}}
>
<Text
style={{ color: "#fff", fontSize: 10, fontWeight: "600" }}
>
{formatTrickplayTime(trickplayTime)}
</Text>
</View>
</View>
);
}}
sliderHeight={3}
thumbWidth={10}
panHitSlop={{ top: 20, bottom: 20, left: 5, right: 5 }}
/>
</View>
<Pressable onPress={handlePress}>
{/* Content */}
<View
style={{
flexDirection: "row",
alignItems: "center",
padding: 12,
paddingTop: 6,
gap: 12,
}}
>
{/* Poster */}
{posterUrl && (
<Image
source={{ uri: posterUrl }}
style={{
width: 40,
height: 60,
borderRadius: 4,
}}
contentFit='cover'
/>
)}
{/* Info */}
<View style={{ flex: 1 }}>
<Text
style={{
color: "white",
fontSize: 14,
fontWeight: "600",
}}
numberOfLines={1}
>
{currentItem.Name}
</Text>
{currentItem.SeriesName && (
<Text
style={{
color: "#999",
fontSize: 12,
}}
numberOfLines={1}
>
{currentItem.SeriesName}
</Text>
)}
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 8,
marginTop: 2,
}}
>
<Ionicons name='tv' size={12} color={protocolColor} />
<Text
style={{
color: protocolColor,
fontSize: 11,
}}
numberOfLines={1}
>
{castDevice.friendlyName || "Chromecast"}
</Text>
<Text
style={{
color: "#666",
fontSize: 11,
}}
>
{formatTime(progress)} / {formatTime(duration)}
</Text>
</View>
</View>
{/* Play/Pause button */}
<Pressable
onPress={(e) => {
e.stopPropagation();
handleTogglePlayPause();
}}
style={{ padding: 8 }}
>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={28}
color='white'
/>
</Pressable>
</View>
</Pressable>
</Animated.View>
);
};

View File

@@ -1,321 +0,0 @@
/**
* Chromecast Connection Menu
* Shows device info, volume control, and disconnect option
* Simple menu for when connected but not actively controlling playback
*/
import { Ionicons } from "@expo/vector-icons";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Modal, Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { useCastDevice, useCastSession } from "react-native-google-cast";
import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
interface ChromecastConnectionMenuProps {
visible: boolean;
onClose: () => void;
onDisconnect?: () => Promise<void>;
}
export const ChromecastConnectionMenu: React.FC<
ChromecastConnectionMenuProps
> = ({ visible, onClose, onDisconnect }) => {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const castDevice = useCastDevice();
const castSession = useCastSession();
// Volume state - use refs to avoid triggering re-renders during sliding
const [displayVolume, setDisplayVolume] = useState(50);
const [isMuted, setIsMuted] = useState(false);
const isMutedRef = useRef(false);
const volumeValue = useSharedValue(50);
const minimumValue = useSharedValue(0);
const maximumValue = useSharedValue(100);
const isSliding = useRef(false);
const lastSetVolume = useRef(50);
const protocolColor = "#a855f7";
// Get initial volume and mute state when menu opens
useEffect(() => {
if (!visible || !castSession) return;
// Get initial states
const fetchInitialState = async () => {
try {
const vol = await castSession.getVolume();
if (vol !== undefined) {
const percent = Math.round(vol * 100);
setDisplayVolume(percent);
volumeValue.value = percent;
lastSetVolume.current = percent;
}
const muted = await castSession.isMute();
isMutedRef.current = muted;
setIsMuted(muted);
} catch {
// Ignore errors
}
};
fetchInitialState();
// Poll for external volume changes (physical buttons) - only when not sliding
const interval = setInterval(async () => {
if (isSliding.current) return;
try {
const vol = await castSession.getVolume();
if (vol !== undefined) {
const percent = Math.round(vol * 100);
// Only update if external change detected (not our own change)
if (Math.abs(percent - lastSetVolume.current) > 2) {
setDisplayVolume(percent);
volumeValue.value = percent;
lastSetVolume.current = percent;
}
}
const muted = await castSession.isMute();
if (muted !== isMutedRef.current) {
isMutedRef.current = muted;
setIsMuted(muted);
}
} catch {
// Ignore errors
}
}, 1000); // Poll less frequently
return () => clearInterval(interval);
}, [visible, castSession, volumeValue]);
// Volume change during sliding - update display only, don't call API
const handleVolumeChange = useCallback((value: number) => {
const rounded = Math.round(value);
setDisplayVolume(rounded);
}, []);
// Volume change complete - call API
const handleVolumeComplete = useCallback(
async (value: number) => {
isSliding.current = false;
const rounded = Math.round(value);
setDisplayVolume(rounded);
lastSetVolume.current = rounded;
try {
if (castSession) {
await castSession.setVolume(rounded / 100);
}
} catch (error) {
console.error("[Connection Menu] Volume error:", error);
}
},
[castSession],
);
// Toggle mute
const handleToggleMute = useCallback(async () => {
if (!castSession) return;
try {
const newMute = !isMuted;
await castSession.setMute(newMute);
isMutedRef.current = newMute;
setIsMuted(newMute);
} catch (error) {
console.error("[Connection Menu] Mute error:", error);
}
}, [castSession, isMuted]);
// Disconnect
const handleDisconnect = useCallback(async () => {
try {
if (onDisconnect) {
await onDisconnect();
}
} catch (error) {
console.error("[Connection Menu] Disconnect error:", error);
} finally {
onClose();
}
}, [onDisconnect, onClose]);
return (
<Modal
visible={visible}
transparent={true}
animationType='slide'
onRequestClose={onClose}
>
<GestureHandlerRootView style={{ flex: 1 }}>
<Pressable
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.9)",
justifyContent: "flex-end",
}}
onPress={onClose}
>
<Pressable
style={{
backgroundColor: "#1a1a1a",
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingBottom: insets.bottom + 16,
}}
onPress={(e) => e.stopPropagation()}
>
{/* Header with device name */}
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#333",
}}
>
<View
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
>
<View
style={{
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: protocolColor,
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='tv' size={20} color='white' />
</View>
<View>
<Text
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
>
{castDevice?.friendlyName || t("casting_player.chromecast")}
</Text>
<Text style={{ color: protocolColor, fontSize: 12 }}>
{t("casting_player.connected")}
</Text>
</View>
</View>
<Pressable onPress={onClose} style={{ padding: 8 }}>
<Ionicons name='close' size={24} color='white' />
</Pressable>
</View>
{/* Volume Control */}
<View style={{ padding: 16 }}>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 12,
}}
>
<Text style={{ color: "#999", fontSize: 12 }}>
{t("casting_player.volume")}
</Text>
<Text style={{ color: "white", fontSize: 14 }}>
{isMuted ? t("casting_player.muted") : `${displayVolume}%`}
</Text>
</View>
<View
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
>
<Pressable
onPress={handleToggleMute}
style={{
padding: 8,
borderRadius: 20,
backgroundColor: isMuted ? protocolColor : "transparent",
}}
>
<Ionicons
name={isMuted ? "volume-mute" : "volume-low"}
size={20}
color={isMuted ? "white" : "#999"}
/>
</Pressable>
<View style={{ flex: 1 }}>
<Slider
style={{ width: "100%", height: 40 }}
progress={volumeValue}
minimumValue={minimumValue}
maximumValue={maximumValue}
theme={{
disableMinTrackTintColor: "#333",
maximumTrackTintColor: "#333",
minimumTrackTintColor: isMuted ? "#666" : protocolColor,
bubbleBackgroundColor: protocolColor,
}}
onSlidingStart={() => {
isSliding.current = true;
}}
onValueChange={async (value) => {
volumeValue.value = value;
handleVolumeChange(value);
// Unmute when adjusting volume - use ref to avoid
// stale closure and prevent repeated async calls
if (isMutedRef.current) {
isMutedRef.current = false;
setIsMuted(false);
try {
await castSession?.setMute(false);
} catch (error: unknown) {
console.error(
"[ChromecastConnectionMenu] Failed to unmute:",
error,
);
isMutedRef.current = true;
setIsMuted(true); // Rollback on failure
}
}
}}
onSlidingComplete={handleVolumeComplete}
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
/>
</View>
<Ionicons
name='volume-high'
size={20}
color={isMuted ? "#666" : "#999"}
/>
</View>
</View>
{/* Disconnect button */}
<View style={{ paddingHorizontal: 16 }}>
<Pressable
onPress={handleDisconnect}
style={{
backgroundColor: protocolColor,
padding: 14,
borderRadius: 8,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 8,
}}
>
<Ionicons name='power' size={20} color='white' />
<Text
style={{ color: "white", fontSize: 14, fontWeight: "500" }}
>
{t("casting_player.disconnect")}
</Text>
</Pressable>
</View>
</Pressable>
</Pressable>
</GestureHandlerRootView>
</Modal>
);
};

View File

@@ -1,348 +0,0 @@
/**
* Chromecast Device Info Sheet
* Shows device details, volume control, and disconnect option
*/
import { Ionicons } from "@expo/vector-icons";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Modal, Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { useCastSession } from "react-native-google-cast";
import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
interface ChromecastDeviceSheetProps {
visible: boolean;
onClose: () => void;
device: { friendlyName?: string } | null;
onDisconnect: () => Promise<void>;
volume?: number;
onVolumeChange?: (volume: number) => Promise<void>;
}
export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
visible,
onClose,
device,
onDisconnect,
volume = 0.5,
onVolumeChange,
}) => {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [isDisconnecting, setIsDisconnecting] = useState(false);
const [displayVolume, setDisplayVolume] = useState(Math.round(volume * 100));
const volumeValue = useSharedValue(volume * 100);
const minimumValue = useSharedValue(0);
const maximumValue = useSharedValue(100);
const castSession = useCastSession();
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
const [isMuted, setIsMuted] = useState(false);
const isSliding = useRef(false);
const lastSetVolume = useRef(Math.round(volume * 100));
// Sync volume slider with prop changes (updates from physical buttons)
// Skip updates while user is actively sliding to avoid overwriting drag
useEffect(() => {
if (isSliding.current) return;
volumeValue.value = volume * 100;
setDisplayVolume(Math.round(volume * 100));
}, [volume, volumeValue]);
// Poll for volume and mute updates when sheet is visible to catch physical button changes
useEffect(() => {
if (!visible || !castSession) return;
// Get initial mute state
castSession
.isMute()
.then(setIsMuted)
.catch(() => {});
// Poll CastSession for device volume and mute state (only when not sliding)
const interval = setInterval(async () => {
if (isSliding.current) return;
try {
const deviceVolume = await castSession.getVolume();
if (deviceVolume !== undefined) {
const volumePercent = Math.round(deviceVolume * 100);
// Only update if external change (physical buttons)
if (Math.abs(volumePercent - lastSetVolume.current) > 2) {
setDisplayVolume(volumePercent);
volumeValue.value = volumePercent;
lastSetVolume.current = volumePercent;
}
}
// Check mute state
const muteState = await castSession.isMute();
setIsMuted(muteState);
} catch {
// Ignore errors - device might be disconnected
}
}, 1000);
return () => clearInterval(interval);
}, [visible, castSession, volumeValue]);
const handleDisconnect = async () => {
setIsDisconnecting(true);
try {
await onDisconnect();
onClose();
} catch (error) {
console.error("Failed to disconnect:", error);
} finally {
setIsDisconnecting(false);
}
};
const handleVolumeComplete = async (value: number) => {
const newVolume = value / 100;
setDisplayVolume(Math.round(value));
try {
// Use CastSession.setVolume for DEVICE volume control
// This works even when no media is playing, unlike setStreamVolume
if (castSession) {
await castSession.setVolume(newVolume);
} else if (onVolumeChange) {
// Fallback to prop method if session not available
await onVolumeChange(newVolume);
}
} catch (error) {
console.error("[Volume] Error setting volume:", error);
}
};
// Debounced volume update during sliding for smooth live feedback
const handleVolumeChange = useCallback(
(value: number) => {
setDisplayVolume(Math.round(value));
// Debounce the API call to avoid too many requests
if (volumeDebounceRef.current) {
clearTimeout(volumeDebounceRef.current);
}
volumeDebounceRef.current = setTimeout(async () => {
const newVolume = value / 100;
try {
if (castSession) {
await castSession.setVolume(newVolume);
}
} catch {
// Ignore errors during sliding
}
}, 150); // 150ms debounce
},
[castSession],
);
// Toggle mute state
const handleToggleMute = useCallback(async () => {
if (!castSession) return;
try {
const newMuteState = !isMuted;
await castSession.setMute(newMuteState);
setIsMuted(newMuteState);
} catch (error) {
console.error("[Volume] Error toggling mute:", error);
}
}, [castSession, isMuted]);
// Cleanup debounce timer on unmount
useEffect(() => {
return () => {
if (volumeDebounceRef.current) {
clearTimeout(volumeDebounceRef.current);
}
};
}, []);
return (
<Modal
visible={visible}
transparent={true}
animationType='slide'
onRequestClose={onClose}
>
<GestureHandlerRootView style={{ flex: 1 }}>
<Pressable
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.85)",
justifyContent: "flex-end",
}}
onPress={onClose}
>
<Pressable
style={{
backgroundColor: "#1a1a1a",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingBottom: insets.bottom + 16,
}}
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#333",
}}
>
<View
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
>
<Ionicons name='tv' size={24} color='#a855f7' />
<Text
style={{ color: "white", fontSize: 18, fontWeight: "600" }}
>
{t("casting_player.chromecast")}
</Text>
</View>
<Pressable onPress={onClose} style={{ padding: 8 }}>
<Ionicons name='close' size={24} color='white' />
</Pressable>
</View>
{/* Device info */}
<View style={{ padding: 16 }}>
<View style={{ marginBottom: 20 }}>
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
{t("casting_player.device_name")}
</Text>
<Text
style={{ color: "white", fontSize: 16, fontWeight: "500" }}
>
{device?.friendlyName || t("casting_player.unknown_device")}
</Text>
</View>
{/* Volume control */}
<View style={{ marginBottom: 24 }}>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 12,
}}
>
<Text style={{ color: "#999", fontSize: 12 }}>
{t("casting_player.volume")}
</Text>
<Text style={{ color: "white", fontSize: 14 }}>
{isMuted ? t("casting_player.muted") : `${displayVolume}%`}
</Text>
</View>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 12,
}}
>
{/* Mute button */}
<Pressable
onPress={handleToggleMute}
style={{
padding: 8,
borderRadius: 20,
backgroundColor: isMuted ? "#a855f7" : "transparent",
}}
>
<Ionicons
name={isMuted ? "volume-mute" : "volume-low"}
size={20}
color={isMuted ? "white" : "#999"}
/>
</Pressable>
<View style={{ flex: 1 }}>
<Slider
style={{ width: "100%", height: 40 }}
progress={volumeValue}
minimumValue={minimumValue}
maximumValue={maximumValue}
theme={{
disableMinTrackTintColor: "#333",
maximumTrackTintColor: "#333",
minimumTrackTintColor: isMuted ? "#666" : "#a855f7",
bubbleBackgroundColor: "#a855f7",
}}
onSlidingStart={async () => {
isSliding.current = true;
// Auto-unmute when user starts adjusting volume
if (isMuted && castSession) {
setIsMuted(false);
try {
await castSession.setMute(false);
} catch (error) {
console.error("[Volume] Failed to unmute:", error);
setIsMuted(true); // Rollback on failure
}
}
}}
onValueChange={(value) => {
volumeValue.value = value;
handleVolumeChange(value);
}}
onSlidingComplete={(value) => {
isSliding.current = false;
lastSetVolume.current = Math.round(value);
handleVolumeComplete(value);
}}
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
/>
</View>
<Ionicons
name='volume-high'
size={20}
color={isMuted ? "#666" : "#999"}
/>
</View>
</View>
{/* Disconnect button */}
<Pressable
onPress={handleDisconnect}
disabled={isDisconnecting}
style={{
backgroundColor: "#a855f7",
padding: 16,
borderRadius: 8,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 8,
opacity: isDisconnecting ? 0.5 : 1,
}}
>
<Ionicons
name='power'
size={20}
color='white'
style={{ marginTop: 2 }}
/>
<Text
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
>
{isDisconnecting
? t("casting_player.disconnecting")
: t("casting_player.stop_casting")}
</Text>
</Pressable>
</View>
</Pressable>
</Pressable>
</GestureHandlerRootView>
</Modal>
);
};

View File

@@ -1,351 +0,0 @@
/**
* Episode List for Chromecast Player
* Displays list of episodes for TV shows with thumbnails
*/
import { Ionicons } from "@expo/vector-icons";
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, Modal, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { truncateTitle } from "@/utils/casting/helpers";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
interface ChromecastEpisodeListProps {
visible: boolean;
onClose: () => void;
currentItem: BaseItemDto | null;
episodes: BaseItemDto[];
onSelectEpisode: (episode: BaseItemDto) => void;
api: Api | null;
}
export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
visible,
onClose,
currentItem,
episodes,
onSelectEpisode,
api,
}) => {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const flatListRef = useRef<FlatList>(null);
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
const scrollRetryCountRef = useRef(0);
const scrollRetryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const MAX_SCROLL_RETRIES = 3;
// Cleanup pending retry timeout on unmount
useEffect(() => {
return () => {
if (scrollRetryTimeoutRef.current) {
clearTimeout(scrollRetryTimeoutRef.current);
scrollRetryTimeoutRef.current = null;
}
scrollRetryCountRef.current = 0;
};
}, []);
// Get unique seasons from episodes
const seasons = useMemo(() => {
const seasonSet = new Set<number>();
for (const ep of episodes) {
if (ep.ParentIndexNumber !== undefined && ep.ParentIndexNumber !== null) {
seasonSet.add(ep.ParentIndexNumber);
}
}
return Array.from(seasonSet).sort((a, b) => a - b);
}, [episodes]);
// Filter episodes by selected season and exclude virtual episodes
const filteredEpisodes = useMemo(() => {
let eps = episodes;
// Filter by season if selected
if (selectedSeason !== null) {
eps = eps.filter((ep) => ep.ParentIndexNumber === selectedSeason);
}
// Filter out virtual episodes (episodes without actual video files)
// LocationType === "Virtual" means the episode doesn't have a media file
eps = eps.filter((ep) => ep.LocationType !== "Virtual");
return eps;
}, [episodes, selectedSeason]);
// Set initial season to current episode's season
useEffect(() => {
if (currentItem?.ParentIndexNumber !== undefined) {
setSelectedSeason(currentItem.ParentIndexNumber);
}
}, [currentItem]);
useEffect(() => {
// Reset retry counter when visibility or data changes
scrollRetryCountRef.current = 0;
if (scrollRetryTimeoutRef.current) {
clearTimeout(scrollRetryTimeoutRef.current);
}
if (visible && currentItem && filteredEpisodes.length > 0) {
const currentIndex = filteredEpisodes.findIndex(
(ep) => ep.Id === currentItem.Id,
);
if (currentIndex !== -1 && flatListRef.current) {
// Delay to ensure FlatList is rendered
const timeoutId = setTimeout(() => {
flatListRef.current?.scrollToIndex({
index: currentIndex,
animated: true,
viewPosition: 0.5, // Center the item
});
}, 300);
return () => {
clearTimeout(timeoutId);
if (scrollRetryTimeoutRef.current) {
clearTimeout(scrollRetryTimeoutRef.current);
}
};
}
}
}, [visible, currentItem, filteredEpisodes]);
const renderEpisode = ({ item }: { item: BaseItemDto }) => {
const isCurrentEpisode = item.Id === currentItem?.Id;
return (
<Pressable
onPress={() => {
onSelectEpisode(item);
onClose();
}}
style={{
flexDirection: "row",
padding: 12,
backgroundColor: isCurrentEpisode ? "#a855f7" : "transparent",
borderRadius: 8,
marginBottom: 8,
}}
>
{/* Thumbnail */}
<View
style={{
width: 120,
height: 68,
borderRadius: 4,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{(() => {
const imageUrl =
api && item.Id ? getPrimaryImageUrl({ api, item }) : null;
if (imageUrl) {
return (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
);
}
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='film-outline' size={32} color='#333' />
</View>
);
})()}
</View>
{/* Episode info */}
<View style={{ flex: 1, marginLeft: 12, justifyContent: "center" }}>
<Text
style={{
color: "white",
fontSize: 14,
fontWeight: "600",
marginBottom: 4,
}}
numberOfLines={1}
>
{item.IndexNumber != null ? `${item.IndexNumber}. ` : ""}
{truncateTitle(item.Name || t("casting_player.unknown"), 30)}
</Text>
{item.Overview && (
<Text
style={{
color: "#999",
fontSize: 12,
marginBottom: 4,
}}
numberOfLines={2}
>
{item.Overview}
</Text>
)}
<View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}>
{item.ParentIndexNumber !== undefined &&
item.IndexNumber !== undefined && (
<Text
style={{ color: "#a855f7", fontSize: 11, fontWeight: "600" }}
>
S{String(item.ParentIndexNumber).padStart(2, "0")}:E
{String(item.IndexNumber).padStart(2, "0")}
</Text>
)}
{item.ProductionYear && (
<Text style={{ color: "#666", fontSize: 11 }}>
{item.ProductionYear}
</Text>
)}
{item.RunTimeTicks && (
<Text style={{ color: "#666", fontSize: 11 }}>
{Math.round(item.RunTimeTicks / 600000000)}{" "}
{t("casting_player.minutes_short")}
</Text>
)}
</View>
</View>
{isCurrentEpisode && (
<View
style={{
justifyContent: "center",
marginLeft: 8,
}}
>
<Ionicons name='play-circle' size={24} color='white' />
</View>
)}
</Pressable>
);
};
return (
<Modal
visible={visible}
transparent={true}
animationType='slide'
onRequestClose={onClose}
>
<Pressable
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.85)",
}}
onPress={onClose}
>
<Pressable
style={{
flex: 1,
paddingTop: insets.top,
}}
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
<View
style={{
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#333",
}}
>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: seasons.length > 1 ? 12 : 0,
}}
>
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
{t("casting_player.episodes")}
</Text>
<Pressable onPress={onClose} style={{ padding: 8 }}>
<Ionicons name='close' size={24} color='white' />
</Pressable>
</View>
{/* Season selector */}
{seasons.length > 1 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 8 }}
>
{seasons.map((season) => (
<Pressable
key={season}
onPress={() => setSelectedSeason(season)}
style={{
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
backgroundColor:
selectedSeason === season ? "#a855f7" : "#1a1a1a",
}}
>
<Text
style={{
color: "white",
fontSize: 14,
fontWeight: selectedSeason === season ? "600" : "400",
}}
>
{t("casting_player.season", { number: season })}
</Text>
</Pressable>
))}
</ScrollView>
)}
</View>
{/* Episode list */}
<FlatList
ref={flatListRef}
data={filteredEpisodes}
renderItem={renderEpisode}
keyExtractor={(item, index) => item.Id || `episode-${index}`}
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 16,
}}
showsVerticalScrollIndicator={false}
onScrollToIndexFailed={(info) => {
// Bounded retry for scroll failures
if (
scrollRetryCountRef.current >= MAX_SCROLL_RETRIES ||
info.index >= filteredEpisodes.length
) {
return;
}
scrollRetryCountRef.current += 1;
if (scrollRetryTimeoutRef.current) {
clearTimeout(scrollRetryTimeoutRef.current);
}
scrollRetryTimeoutRef.current = setTimeout(() => {
flatListRef.current?.scrollToIndex({
index: info.index,
animated: true,
viewPosition: 0.5,
});
}, 500);
}}
/>
</Pressable>
</Pressable>
</Modal>
);
};

View File

@@ -1,347 +0,0 @@
/**
* Chromecast Settings Menu
* Allows users to configure audio, subtitles, quality, and playback speed
*/
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Modal, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import type {
AudioTrack,
MediaSource,
SubtitleTrack,
} from "@/utils/casting/types";
interface ChromecastSettingsMenuProps {
visible: boolean;
onClose: () => void;
item: BaseItemDto;
mediaSources: MediaSource[];
selectedMediaSource: MediaSource | null;
onMediaSourceChange: (source: MediaSource) => void;
audioTracks: AudioTrack[];
selectedAudioTrack: AudioTrack | null;
onAudioTrackChange: (track: AudioTrack) => void;
subtitleTracks: SubtitleTrack[];
selectedSubtitleTrack: SubtitleTrack | null;
onSubtitleTrackChange: (track: SubtitleTrack | null) => void;
playbackSpeed: number;
onPlaybackSpeedChange: (speed: number) => void;
}
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
visible,
onClose,
item: _item, // Reserved for future use (technical info display)
mediaSources,
selectedMediaSource,
onMediaSourceChange,
audioTracks,
selectedAudioTrack,
onAudioTrackChange,
subtitleTracks,
selectedSubtitleTrack,
onSubtitleTrackChange,
playbackSpeed,
onPlaybackSpeedChange,
}) => {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [expandedSection, setExpandedSection] = useState<string | null>(null);
const toggleSection = (section: string) => {
setExpandedSection(expandedSection === section ? null : section);
};
const renderSectionHeader = (
title: string,
icon: keyof typeof Ionicons.glyphMap,
sectionKey: string,
) => (
<Pressable
onPress={() => toggleSection(sectionKey)}
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#333",
}}
>
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
<Ionicons name={icon} size={20} color='white' />
<Text style={{ color: "white", fontSize: 16, fontWeight: "500" }}>
{title}
</Text>
</View>
<Ionicons
name={expandedSection === sectionKey ? "chevron-up" : "chevron-down"}
size={20}
color='#999'
/>
</Pressable>
);
return (
<Modal
visible={visible}
transparent={true}
animationType='slide'
onRequestClose={onClose}
>
<Pressable
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.85)",
justifyContent: "flex-end",
}}
onPress={onClose}
>
<Pressable
style={{
backgroundColor: "#1a1a1a",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: "80%",
paddingBottom: insets.bottom,
}}
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#333",
}}
>
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
{t("casting_player.playback_settings")}
</Text>
<Pressable onPress={onClose} style={{ padding: 8 }}>
<Ionicons name='close' size={24} color='white' />
</Pressable>
</View>
<ScrollView>
{/* Quality/Media Source - only show when sources available */}
{mediaSources.length > 0 &&
renderSectionHeader(
t("casting_player.quality"),
"film-outline",
"quality",
)}
{mediaSources.length > 0 && expandedSection === "quality" && (
<View style={{ paddingVertical: 8 }}>
{mediaSources.map((source) => (
<Pressable
key={source.id}
onPress={() => {
onMediaSourceChange(source);
setExpandedSection(null);
}}
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
backgroundColor:
selectedMediaSource?.id === source.id
? "#2a2a2a"
: "transparent",
}}
>
<View>
<Text style={{ color: "white", fontSize: 15 }}>
{source.name}
</Text>
{source.bitrate && (
<Text
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
>
{Math.round(source.bitrate / 1000000)} Mbps
</Text>
)}
</View>
{selectedMediaSource?.id === source.id && (
<Ionicons name='checkmark' size={20} color='#a855f7' />
)}
</Pressable>
))}
</View>
)}
{/* Audio Tracks - only show if more than one track */}
{audioTracks.length > 1 &&
renderSectionHeader(
t("casting_player.audio"),
"musical-notes",
"audio",
)}
{audioTracks.length > 1 && expandedSection === "audio" && (
<View style={{ paddingVertical: 8 }}>
{audioTracks.map((track) => (
<Pressable
key={track.index}
onPress={() => {
onAudioTrackChange(track);
setExpandedSection(null);
}}
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
backgroundColor:
selectedAudioTrack?.index === track.index
? "#2a2a2a"
: "transparent",
}}
>
<View>
<Text style={{ color: "white", fontSize: 15 }}>
{track.displayTitle ||
track.language ||
t("casting_player.unknown")}
</Text>
{track.codec && (
<Text
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
>
{track.codec.toUpperCase()}
</Text>
)}
</View>
{selectedAudioTrack?.index === track.index && (
<Ionicons name='checkmark' size={20} color='#a855f7' />
)}
</Pressable>
))}
</View>
)}
{/* Subtitle Tracks - only show if subtitles available */}
{subtitleTracks.length > 0 &&
renderSectionHeader(
t("casting_player.subtitles"),
"text",
"subtitles",
)}
{subtitleTracks.length > 0 && expandedSection === "subtitles" && (
<View style={{ paddingVertical: 8 }}>
<Pressable
onPress={() => {
onSubtitleTrackChange(null);
setExpandedSection(null);
}}
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
backgroundColor:
selectedSubtitleTrack === null
? "#2a2a2a"
: "transparent",
}}
>
<Text style={{ color: "white", fontSize: 15 }}>
{t("casting_player.none")}
</Text>
{selectedSubtitleTrack === null && (
<Ionicons name='checkmark' size={20} color='#a855f7' />
)}
</Pressable>
{subtitleTracks.map((track) => (
<Pressable
key={track.index}
onPress={() => {
onSubtitleTrackChange(track);
setExpandedSection(null);
}}
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
backgroundColor:
selectedSubtitleTrack?.index === track.index
? "#2a2a2a"
: "transparent",
}}
>
<View>
<Text style={{ color: "white", fontSize: 15 }}>
{track.displayTitle ||
track.language ||
t("casting_player.unknown")}
</Text>
{(track.codec || track.isForced) && (
<Text
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
>
{track.codec ? track.codec.toUpperCase() : ""}
{track.isForced && `${t("casting_player.forced")}`}
</Text>
)}
</View>
{selectedSubtitleTrack?.index === track.index && (
<Ionicons name='checkmark' size={20} color='#a855f7' />
)}
</Pressable>
))}
</View>
)}
{/* Playback Speed */}
{renderSectionHeader(
t("casting_player.playback_speed"),
"speedometer",
"speed",
)}
{expandedSection === "speed" && (
<View style={{ paddingVertical: 8 }}>
{PLAYBACK_SPEEDS.map((speed) => (
<Pressable
key={speed}
onPress={() => {
onPlaybackSpeedChange(speed);
setExpandedSection(null);
}}
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
backgroundColor:
Math.abs(playbackSpeed - speed) < 0.01
? "#2a2a2a"
: "transparent",
}}
>
<Text style={{ color: "white", fontSize: 15 }}>
{speed === 1 ? t("casting_player.normal") : `${speed}x`}
</Text>
{Math.abs(playbackSpeed - speed) < 0.01 && (
<Ionicons name='checkmark' size={20} color='#a855f7' />
)}
</Pressable>
))}
</View>
)}
</ScrollView>
</Pressable>
</Pressable>
</Modal>
);
};

View File

@@ -1,171 +0,0 @@
/**
* Hook for managing Chromecast segments (intro, credits, recap, commercial, preview)
* Integrates with autoskip API for segment detection
*/
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtomValue } from "jotai";
import { useCallback, useMemo } from "react";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { isWithinSegment } from "@/utils/casting/helpers";
import type { ChromecastSegmentData } from "@/utils/chromecast/options";
import { useSegments } from "@/utils/segments";
export const useChromecastSegments = (
item: BaseItemDto | null,
currentProgressMs: number,
isOffline = false,
) => {
const api = useAtomValue(apiAtom);
const { settings } = useSettings();
// Fetch segments from autoskip API
const { data: segmentData } = useSegments(
item?.Id || "",
isOffline,
undefined, // downloadedFiles parameter
api,
);
// Parse segments into usable format
const segments = useMemo<ChromecastSegmentData>(() => {
if (!segmentData) {
return {
intro: null,
credits: null,
recap: null,
commercial: [],
preview: [],
};
}
const intro =
segmentData.introSegments && segmentData.introSegments.length > 0
? {
start: segmentData.introSegments[0].startTime,
end: segmentData.introSegments[0].endTime,
}
: null;
const credits =
segmentData.creditSegments && segmentData.creditSegments.length > 0
? {
start: segmentData.creditSegments[0].startTime,
end: segmentData.creditSegments[0].endTime,
}
: null;
const recap =
segmentData.recapSegments && segmentData.recapSegments.length > 0
? {
start: segmentData.recapSegments[0].startTime,
end: segmentData.recapSegments[0].endTime,
}
: null;
const commercial = (segmentData.commercialSegments || []).map((seg) => ({
start: seg.startTime,
end: seg.endTime,
}));
const preview = (segmentData.previewSegments || []).map((seg) => ({
start: seg.startTime,
end: seg.endTime,
}));
return { intro, credits, recap, commercial, preview };
}, [segmentData]);
// Check which segment we're currently in
// currentProgressMs is in milliseconds; isWithinSegment() converts ms→seconds internally
// before comparing with segment times (which are in seconds from the autoskip API)
const currentSegment = useMemo(() => {
if (isWithinSegment(currentProgressMs, segments.intro)) {
return { type: "intro" as const, segment: segments.intro };
}
if (isWithinSegment(currentProgressMs, segments.credits)) {
return { type: "credits" as const, segment: segments.credits };
}
if (isWithinSegment(currentProgressMs, segments.recap)) {
return { type: "recap" as const, segment: segments.recap };
}
for (const commercial of segments.commercial) {
if (isWithinSegment(currentProgressMs, commercial)) {
return { type: "commercial" as const, segment: commercial };
}
}
for (const preview of segments.preview) {
if (isWithinSegment(currentProgressMs, preview)) {
return { type: "preview" as const, segment: preview };
}
}
return null;
}, [currentProgressMs, segments]);
// Skip functions
const skipIntro = useCallback(
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
if (segments.intro) {
await seekFn(segments.intro.end * 1000);
}
},
[segments.intro],
);
const skipCredits = useCallback(
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
if (segments.credits) {
await seekFn(segments.credits.end * 1000);
}
},
[segments.credits],
);
const skipSegment = useCallback(
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
if (currentSegment?.segment) {
await seekFn(currentSegment.segment.end * 1000);
}
},
[currentSegment],
);
// Auto-skip logic based on settings
const shouldAutoSkip = useMemo(() => {
if (!currentSegment) return false;
switch (currentSegment.type) {
case "intro":
return settings?.skipIntro === "auto";
case "credits":
return settings?.skipOutro === "auto";
case "recap":
return settings?.skipRecap === "auto";
case "commercial":
return settings?.skipCommercial === "auto";
case "preview":
return settings?.skipPreview === "auto";
default:
return false;
}
}, [
currentSegment,
settings?.skipIntro,
settings?.skipOutro,
settings?.skipRecap,
settings?.skipCommercial,
settings?.skipPreview,
]);
return {
segments,
currentSegment,
skipIntro,
skipCredits,
skipSegment,
shouldAutoSkip,
hasIntro: !!segments.intro,
hasCredits: !!segments.credits,
};
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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