Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83a264d5a1 | ||
|
|
2d434a0125 | ||
|
|
0d7edca1ad |
@@ -1,13 +1,21 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(rm:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(for file in /Users/fredrikburmester/Documents/GitHub/streamyfin/translations/*.json)",
|
||||
"Bash(do)",
|
||||
"Bash(if grep -q \"live_tv\" \"$file\")",
|
||||
"Bash(then)",
|
||||
"Bash(echo \"Processing $file\")",
|
||||
"Bash(fi)",
|
||||
"Bash(done)",
|
||||
"Bash(bun run:*)",
|
||||
"Bash(pod install:*)",
|
||||
"Bash(bun install:*)",
|
||||
"Bash(bunx expo prebuild:*)",
|
||||
"Bash(bunx expo run:*)",
|
||||
"Bash(npx expo prebuild:*)",
|
||||
"Bash(npx expo run:*)",
|
||||
"Bash(xcodebuild:*)"
|
||||
"Bash(ls:*)",
|
||||
"Bash(cat:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
description: Don't write code directly in the ios folder.
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
We never write code directly in the ios folder. This code is generated by expo plugins.
|
||||
3
.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
}
|
||||
75
.github/workflows/build-android.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: 🤖 Android APK Build (Phone + TV)
|
||||
name: 🤖 Android APK Build
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -12,82 +12,58 @@ on:
|
||||
branches: [develop, master]
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
build:
|
||||
runs-on: ubuntu-24.04
|
||||
name: 🏗️ Build Android APK
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [phone, tv]
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
with:
|
||||
bun-version: latest
|
||||
bun-version: '1.2.17'
|
||||
|
||||
- name: ☕ Setup JDK
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-develop
|
||||
${{ runner.os }}-bun-develop
|
||||
${{ runner.os }}-bun-cache-
|
||||
|
||||
- name: 💾 Cache node_modules
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-modules-latest-develop-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-modules-latest-develop
|
||||
${{ runner.os }}-${{ runner.arch }}-modules-develop
|
||||
${{ runner.os }}-modules-develop
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
- name: 📦 Install dependencies
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
- name: 💾 Cache Android dependencies
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: ${{ runner.os }}-gradle-develop
|
||||
android/.gradle
|
||||
key: ${{ runner.os }}-android-deps-${{ hashFiles('android/**/build.gradle') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-android-deps-
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: |
|
||||
if [ "${{ matrix.target }}" = "tv" ]; then
|
||||
bun run prebuild:tv
|
||||
else
|
||||
bun run prebuild
|
||||
fi
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 💾 Cache project Gradle (.gradle)
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: android/.gradle
|
||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: ${{ runner.os }}-android-gradle-develop
|
||||
|
||||
- name: 🚀 Build APK
|
||||
env:
|
||||
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
|
||||
- name: 🚀 Build APK via Bun
|
||||
run: bun run build:android:local
|
||||
|
||||
- name: 📅 Set date tag
|
||||
@@ -96,7 +72,8 @@ jobs:
|
||||
- name: 📤 Upload APK artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: streamyfin-android-${{ matrix.target }}-apk-${{ env.DATE_TAG }}
|
||||
name: streamyfin-apk-${{ env.DATE_TAG }}
|
||||
path: |
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
android/app/build/outputs/bundle/release/*.aab
|
||||
retention-days: 7
|
||||
|
||||
55
.github/workflows/build-ios.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: 🤖 iOS IPA Build (Phone + TV)
|
||||
name: 🤖 iOS IPA Build
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -12,71 +12,51 @@ on:
|
||||
branches: [develop, master]
|
||||
|
||||
jobs:
|
||||
build-ios:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'
|
||||
build:
|
||||
runs-on: macos-15
|
||||
name: 🏗️ Build iOS IPA
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [phone, tv]
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: 📥 Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
with:
|
||||
bun-version: latest
|
||||
bun-version: '1.2.17'
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache
|
||||
${{ runner.os }}-bun-cache-
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
- name: 📦 Install & Prepare
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: |
|
||||
if [ "${{ matrix.target }}" = "tv" ]; then
|
||||
bun run prebuild:tv
|
||||
else
|
||||
bun run prebuild
|
||||
fi
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
- name: 🏗 Setup EAS
|
||||
uses: expo/expo-github-action@main
|
||||
with:
|
||||
eas-version: latest
|
||||
eas-version: 16.7.1
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: ⚙️ Ensure iOS/tvOS SDKs installed
|
||||
- name: 🏗️ Build iOS app
|
||||
run: |
|
||||
if [ "${{ matrix.target }}" = "tv" ]; then
|
||||
xcodebuild -downloadPlatform tvOS
|
||||
else
|
||||
xcodebuild -downloadPlatform iOS
|
||||
fi
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
|
||||
run: eas build -p ios --local --non-interactive
|
||||
eas build -p ios --local --non-interactive
|
||||
|
||||
- name: 📅 Set date tag
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
@@ -84,6 +64,7 @@ jobs:
|
||||
- name: 📤 Upload IPA artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: streamyfin-ios-${{ matrix.target }}-ipa-${{ env.DATE_TAG }}
|
||||
path: build-*.ipa
|
||||
name: streamyfin-ipa-${{ env.DATE_TAG }}
|
||||
path: |
|
||||
build-*.ipa
|
||||
retention-days: 7
|
||||
|
||||
6
.github/workflows/check-lockfile.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
@@ -29,10 +29,10 @@ jobs:
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
with:
|
||||
bun-version: latest
|
||||
bun-version: '1.2.17'
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
|
||||
10
.github/workflows/ci-codeql.yml
vendored
@@ -20,24 +20,24 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript-typescript', 'actions' ]
|
||||
language: [ 'javascript-typescript' ]
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🏁 Initialize CodeQL
|
||||
uses: github/codeql-action/init@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10
|
||||
uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended,security-and-quality
|
||||
|
||||
- name: 🛠️ Autobuild
|
||||
uses: github/codeql-action/autobuild@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10
|
||||
uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
|
||||
- name: 🧪 Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10
|
||||
uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
|
||||
44
.github/workflows/linting.yml
vendored
@@ -1,12 +1,10 @@
|
||||
name: 🚦 Security & Quality Gate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
branches: [develop, master]
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [develop]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -14,18 +12,17 @@ permissions:
|
||||
jobs:
|
||||
validate_pr_title:
|
||||
name: "📝 Validate PR Title"
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@7f33ba792281b034f64e96f4c0b5496782dd3b37 # v6.1.0
|
||||
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3
|
||||
id: lint_pr_title
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
- uses: marocchino/sticky-pull-request-comment@d2ad0de260ae8b0235ce059e63f2949ba9e05943 # v2.9.3
|
||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
@@ -39,7 +36,7 @@ jobs:
|
||||
```
|
||||
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
uses: marocchino/sticky-pull-request-comment@d2ad0de260ae8b0235ce059e63f2949ba9e05943 # v2.9.3
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
delete: true
|
||||
@@ -51,41 +48,19 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@bc41886e18ea39df68b1b1245f4184881938e050 # v4.7.2
|
||||
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
|
||||
with:
|
||||
fail-on-severity: high
|
||||
deny-licenses: GPL-3.0, AGPL-3.0
|
||||
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
||||
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||
|
||||
expo-doctor:
|
||||
name: 🚑 Expo Doctor Check
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: 🛒 Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 📦 Install dependencies (bun)
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: 🚑 Run Expo Doctor
|
||||
run: bun expo-doctor
|
||||
|
||||
code_quality:
|
||||
name: "🔍 Lint & Test (${{ matrix.command }})"
|
||||
runs-on: ubuntu-latest
|
||||
@@ -95,10 +70,9 @@ jobs:
|
||||
command:
|
||||
- "lint"
|
||||
- "check"
|
||||
- "format"
|
||||
steps:
|
||||
- name: "📥 Checkout PR code"
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
submodules: recursive
|
||||
@@ -107,12 +81,12 @@ jobs:
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: '24.x'
|
||||
node-version: '22.x'
|
||||
|
||||
- name: "🍞 Setup Bun"
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
with:
|
||||
bun-version: latest
|
||||
bun-version: '1.2.17'
|
||||
|
||||
- name: "📦 Install dependencies"
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
2
.github/workflows/notification.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: 🛎️ Notify Discord
|
||||
uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # 0.4.0
|
||||
uses: Ilshidur/action-discord@0c4b27844ba47cb1c7bee539c8eead5284ce9fa9 # 0.3.2
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640
|
||||
|
||||
2
.gitignore
vendored
@@ -10,6 +10,7 @@ npm-debug.*
|
||||
*.orig.*
|
||||
web-build/
|
||||
modules/vlc-player/android/build
|
||||
modules/vlc-player/android/.gradle
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
@@ -45,4 +46,3 @@ streamyfin-4fec1-firebase-adminsdk.json
|
||||
.env
|
||||
.env.local
|
||||
*.aab
|
||||
/version-backup-*
|
||||
|
||||
82
README.md
@@ -1,24 +1,15 @@
|
||||
# 📺 Streamyfin
|
||||
|
||||
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||
|
||||
A simple and user-friendly Jellyfin video streaming client built with Expo. If you are looking for an alternative to other Jellyfin clients, we hope you find Streamyfin a useful addition to your media streaming toolbox.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
|
||||
</p>
|
||||
|
||||
**Streamyfin is a simple, user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img src="./assets/images/screenshots/screenshot1.png" width="22%">
|
||||
|
||||
<img src="./assets/images/screenshots/screenshot3.png" width="22%">
|
||||
|
||||
<img src="./assets/images/screenshots/screenshot2.png" width="22%">
|
||||
|
||||
<img src="./assets/images/jellyseerr.PNG" width="23%">
|
||||
</p>
|
||||
|
||||
<div style="display: flex; flex-direction: row; gap: 8px">
|
||||
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
||||
<img width=150 src="./assets/images/screenshots/screenshot3.png" />
|
||||
<img width=150 src="./assets/images/screenshots/screenshot2.png" />
|
||||
<img width=159 src="./assets/images/jellyseerr.PNG"/>
|
||||
</div>
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
@@ -56,7 +47,7 @@ The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that ho
|
||||
|
||||
### 🔍 Jellysearch
|
||||
|
||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin!
|
||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀
|
||||
|
||||
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
||||
|
||||
@@ -116,13 +107,13 @@ Key points of the MPL-2.0:
|
||||
- You must disclose your source code for any modifications to the covered files
|
||||
- Larger works may combine MPL code with code under other licenses
|
||||
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license
|
||||
- For the full text of the license, please see the LICENSE file in this repository
|
||||
- For the full text of the license, please see the LICENSE file in this repository.
|
||||
|
||||
## 🌐 Connect with Us
|
||||
|
||||
Join our Discord: [](https://discord.gg/BuGG9ZNhaE)
|
||||
Join our Discord: [https://discord.gg/aJvAYeycyY](https://discord.gg/aJvAYeycyY)
|
||||
|
||||
Need support or have questions:
|
||||
If you have questions or need support, feel free to reach out:
|
||||
|
||||
- GitHub Issues: Report bugs or request features here.
|
||||
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
||||
@@ -148,86 +139,77 @@ Special shoutout to the JF official clients for being an inspiration to ours.
|
||||
|
||||
Thanks to the following contributors for their significant contributions:
|
||||
|
||||
<div align="left">
|
||||
<table>
|
||||
<tr>
|
||||
<tr
|
||||
style="
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
"
|
||||
>
|
||||
<td align="center">
|
||||
<a href="https://github.com/Alexk2309">
|
||||
<img src="https://github.com/Alexk2309.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<img src="https://github.com/Alexk2309.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@Alexk2309</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/herrrta">
|
||||
<img src="https://github.com/herrrta.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<img src="https://github.com/herrrta.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@herrrta</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/lostb1t">
|
||||
<img src="https://github.com/lostb1t.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<img src="https://github.com/lostb1t.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@lostb1t</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/Simon-Eklundh">
|
||||
<img src="https://github.com/Simon-Eklundh.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<img src="https://github.com/Simon-Eklundh.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@Simon-Eklundh</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/topiga">
|
||||
<img src="https://github.com/topiga.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<img src="https://github.com/topiga.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@topiga</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/lancechant">
|
||||
<img src="https://github.com/lancechant.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@lancechant</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/simoncaron">
|
||||
<img src="https://github.com/simoncaron.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<img src="https://github.com/simoncaron.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@simoncaron</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/jakequade">
|
||||
<img src="https://github.com/jakequade.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<img src="https://github.com/jakequade.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@jakequade</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/Ryan0204">
|
||||
<img src="https://github.com/Ryan0204.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<img src="https://github.com/Ryan0204.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@Ryan0204</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/retardgerman">
|
||||
<img src="https://github.com/retardgerman.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<img src="https://github.com/retardgerman.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@retardgerman</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/whoopsi-daisy">
|
||||
<img src="https://github.com/whoopsi-daisy.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<img src="https://github.com/whoopsi-daisy.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@whoopsi-daisy</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/Gauvino">
|
||||
<img src="https://github.com/Gauvino.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@Gauvino</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
And all other developers who have contributed to Streamyfin, thank you for your contributions.
|
||||
|
||||
@@ -246,4 +228,4 @@ I'd also like to thank the following people and projects for their contributions
|
||||
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions or support requests related to piracy are strictly prohibited across all our channels.
|
||||
|
||||
## 🤝 Sponsorship
|
||||
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)
|
||||
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur)
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
module.exports = ({ config }) => {
|
||||
if (process.env.EXPO_TV !== "1") {
|
||||
config.plugins.push([
|
||||
"react-native-google-cast",
|
||||
{ useDefaultExpandedMediaControls: true },
|
||||
]);
|
||||
|
||||
// Add the background downloader plugin only for non-TV builds
|
||||
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
|
||||
}
|
||||
config.plugins.push([
|
||||
"react-native-google-cast",
|
||||
{ useDefaultExpandedMediaControls: true },
|
||||
]);
|
||||
return {
|
||||
android: {
|
||||
googleServicesFile: process.env.GOOGLE_SERVICES_JSON,
|
||||
|
||||
29
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.32.1",
|
||||
"version": "0.29.1",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -29,19 +29,18 @@
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
||||
"icon": {
|
||||
"dark": "./assets/images/icon-ios-plain.png",
|
||||
"dark": "./assets/images/icon-plain.png",
|
||||
"light": "./assets/images/icon-ios-light.png",
|
||||
"tinted": "./assets/images/icon-ios-tinted.png"
|
||||
},
|
||||
"appleTeamId": "MWD5K362T8"
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 62,
|
||||
"versionCode": 56,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||
"backgroundColor": "#2E2E2E"
|
||||
"foregroundImage": "./assets/images/icon-plain.png",
|
||||
"monochromeImage": "./assets/images/icon-mono.png",
|
||||
"backgroundColor": "#464646"
|
||||
},
|
||||
"package": "com.fredrikburmester.streamyfin",
|
||||
"permissions": [
|
||||
@@ -52,7 +51,6 @@
|
||||
"googleServicesFile": "./google-services.json"
|
||||
},
|
||||
"plugins": [
|
||||
"@react-native-tvos/config-tv",
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
[
|
||||
@@ -114,15 +112,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
["react-native-bottom-tabs"],
|
||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||
["./plugins/withAndroidManifest.js"],
|
||||
["./plugins/withTrustLocalCerts.js"],
|
||||
["./plugins/withGradleProperties.js"],
|
||||
["./plugins/withRNBackgroundDownloader.js"],
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"backgroundColor": "#2e2e2e",
|
||||
"image": "./assets/images/icon-ios-plain.png",
|
||||
"image": "./assets/images/StreamyFinFinal.png",
|
||||
"imageWidth": 100
|
||||
}
|
||||
],
|
||||
@@ -133,8 +133,13 @@
|
||||
"color": "#9333EA"
|
||||
}
|
||||
],
|
||||
"./plugins/with-runtime-framework-headers.js",
|
||||
"react-native-bottom-tabs"
|
||||
[
|
||||
"react-native-google-cast",
|
||||
{
|
||||
"useDefaultExpandedMediaControls": true
|
||||
}
|
||||
],
|
||||
"expo-background-task"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function SearchLayout() {
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: t("tabs.favorites"),
|
||||
headerLargeStyle: {
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function IndexLayout() {
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: t("tabs.home"),
|
||||
headerBlurEffect: "prominent",
|
||||
@@ -66,6 +66,12 @@ export default function IndexLayout() {
|
||||
title: t("home.settings.settings_title"),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/optimized-server/page'
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/marlin-search/page'
|
||||
options={{
|
||||
|
||||
@@ -23,12 +23,12 @@ export default function page() {
|
||||
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
||||
{},
|
||||
);
|
||||
const { getDownloadedItems, deleteItems } = useDownload();
|
||||
const { downloadedFiles, deleteItems } = useDownload();
|
||||
|
||||
const series = useMemo(() => {
|
||||
try {
|
||||
return (
|
||||
getDownloadedItems()
|
||||
downloadedFiles
|
||||
?.filter((f) => f.item.SeriesId === seriesId)
|
||||
?.sort(
|
||||
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
|
||||
@@ -37,37 +37,7 @@ export default function page() {
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [getDownloadedItems]);
|
||||
|
||||
// Group episodes by season in a single pass
|
||||
const seasonGroups = useMemo(() => {
|
||||
const groups: Record<number, BaseItemDto[]> = {};
|
||||
|
||||
series.forEach((episode) => {
|
||||
const seasonNumber = episode.item.ParentIndexNumber;
|
||||
if (seasonNumber !== undefined && seasonNumber !== null) {
|
||||
if (!groups[seasonNumber]) {
|
||||
groups[seasonNumber] = [];
|
||||
}
|
||||
groups[seasonNumber].push(episode.item);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort episodes within each season
|
||||
Object.values(groups).forEach((episodes) => {
|
||||
episodes.sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0));
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [series]);
|
||||
|
||||
// Get unique seasons (just the season numbers, sorted)
|
||||
const uniqueSeasons = useMemo(() => {
|
||||
const seasonNumbers = Object.keys(seasonGroups)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b);
|
||||
return seasonNumbers.map((seasonNum) => seasonGroups[seasonNum][0]); // First episode of each season
|
||||
}, [seasonGroups]);
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const seasonIndex =
|
||||
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
|
||||
@@ -75,8 +45,20 @@ export default function page() {
|
||||
"";
|
||||
|
||||
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
||||
return seasonGroups[Number(seasonIndex)] ?? [];
|
||||
}, [seasonGroups, seasonIndex]);
|
||||
const seasons: Record<string, BaseItemDto[]> = {};
|
||||
|
||||
series?.forEach((episode) => {
|
||||
if (!seasons[episode.item.ParentIndexNumber!]) {
|
||||
seasons[episode.item.ParentIndexNumber!] = [];
|
||||
}
|
||||
|
||||
seasons[episode.item.ParentIndexNumber!].push(episode.item);
|
||||
});
|
||||
return (
|
||||
seasons[seasonIndex]?.sort((a, b) => a.IndexNumber! - b.IndexNumber!) ??
|
||||
[]
|
||||
);
|
||||
}, [series, seasonIndex]);
|
||||
|
||||
const initialSeasonIndex = useMemo(
|
||||
() =>
|
||||
@@ -120,7 +102,7 @@ export default function page() {
|
||||
<View className='flex flex-row items-center justify-start my-2 px-4'>
|
||||
<SeasonDropdown
|
||||
item={series[0].item}
|
||||
seasons={uniqueSeasons}
|
||||
seasons={series.map((s) => s.item)}
|
||||
state={seasonIndexState}
|
||||
initialSeasonIndex={initialSeasonIndex!}
|
||||
onSelect={(season) => {
|
||||
|
||||
@@ -6,69 +6,38 @@ import {
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { type DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { type DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const {
|
||||
removeProcess,
|
||||
getDownloadedItems,
|
||||
deleteFileByType,
|
||||
deleteAllFiles,
|
||||
} = useDownload();
|
||||
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
|
||||
const router = useRouter();
|
||||
const [settings] = useSettings();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const [showMigration, setShowMigration] = useState(false);
|
||||
|
||||
const migration_20241124 = () => {
|
||||
Alert.alert(
|
||||
t("home.downloads.new_app_version_requires_re_download"),
|
||||
t("home.downloads.new_app_version_requires_re_download_description"),
|
||||
[
|
||||
{
|
||||
text: t("home.downloads.back"),
|
||||
onPress: () => {
|
||||
setShowMigration(false);
|
||||
router.back();
|
||||
},
|
||||
},
|
||||
{
|
||||
text: t("home.downloads.delete"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await deleteAllFiles();
|
||||
setShowMigration(false);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
|
||||
const movies = useMemo(() => {
|
||||
try {
|
||||
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
||||
} catch {
|
||||
setShowMigration(true);
|
||||
migration_20241124();
|
||||
return [];
|
||||
}
|
||||
}, [downloadedFiles]);
|
||||
@@ -85,11 +54,13 @@ export default function page() {
|
||||
});
|
||||
return Object.values(series);
|
||||
} catch {
|
||||
setShowMigration(true);
|
||||
migration_20241124();
|
||||
return [];
|
||||
}
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
@@ -100,12 +71,6 @@ export default function page() {
|
||||
});
|
||||
}, [downloadedFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showMigration) {
|
||||
migration_20241124();
|
||||
}
|
||||
}, [showMigration]);
|
||||
|
||||
const deleteMovies = () =>
|
||||
deleteFileByType("Movie")
|
||||
.then(() =>
|
||||
@@ -133,10 +98,16 @@ export default function page() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={{ flex: 1 }}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
|
||||
<View className='py-4'>
|
||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 100,
|
||||
}}
|
||||
>
|
||||
<View className='py-4'>
|
||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||
{settings?.downloadMethod === DownloadMethod.Remux && (
|
||||
<View className='bg-neutral-900 p-4 rounded-2xl'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.queue")}
|
||||
@@ -180,74 +151,70 @@ export default function page() {
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ActiveDownloads />
|
||||
</View>
|
||||
|
||||
{movies.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.movies")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<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>
|
||||
)}
|
||||
{downloadedFiles?.length === 0 && (
|
||||
<View className='flex px-4'>
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_downloaded_items")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<ActiveDownloads />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{movies.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.movies")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{movies?.map((item) => (
|
||||
<View className='mb-2 last:mb-0' key={item.item.Id}>
|
||||
<MovieCard item={item.item} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{downloadedFiles?.length === 0 && (
|
||||
<View className='flex px-4'>
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_downloaded_items")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
@@ -282,3 +249,23 @@ export default function page() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function migration_20241124() {
|
||||
const router = useRouter();
|
||||
const { deleteAllFiles } = useDownload();
|
||||
Alert.alert(
|
||||
t("home.downloads.new_app_version_requires_re_download"),
|
||||
t("home.downloads.new_app_version_requires_re_download_description"),
|
||||
[
|
||||
{
|
||||
text: t("home.downloads.back"),
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
{
|
||||
text: t("home.downloads.delete"),
|
||||
style: "destructive",
|
||||
onPress: async () => await deleteAllFiles(),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { Linking, TouchableOpacity, View } from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
@@ -19,9 +19,7 @@ export default function page() {
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 space-y-4" : "py-16 space-y-8"} px-4`}
|
||||
>
|
||||
<View className='bg-neutral-900 h-full py-16 px-4 space-y-8'>
|
||||
<View>
|
||||
<Text className='text-3xl font-bold text-center mb-2'>
|
||||
{t("home.intro.welcome_to_streamyfin")}
|
||||
@@ -51,50 +49,42 @@ export default function page() {
|
||||
</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'
|
||||
>
|
||||
<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={{
|
||||
@@ -109,22 +99,19 @@ export default function page() {
|
||||
<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
|
||||
<Text className='shrink text-xs'>
|
||||
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
||||
<Text
|
||||
className='text-purple-600'
|
||||
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>
|
||||
{t("home.intro.read_more")}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -434,6 +434,8 @@ const TranscodingStreamView = ({
|
||||
isTranscoding,
|
||||
properties,
|
||||
transcodeProperties,
|
||||
value,
|
||||
transcodeValue,
|
||||
}: TranscodingStreamViewProps) => {
|
||||
return (
|
||||
<View className='flex flex-col pt-2 first:pt-0'>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useNavigation, useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
@@ -73,13 +73,13 @@ export default function settings() {
|
||||
|
||||
<OtherSettings />
|
||||
|
||||
{!Platform.isTV && <DownloadSettings />}
|
||||
<DownloadSettings />
|
||||
|
||||
<PluginSettings />
|
||||
|
||||
<AppLanguageSelector />
|
||||
|
||||
{!Platform.isTV && <ChromecastSettings />}
|
||||
<ChromecastSettings />
|
||||
|
||||
<ListGroup title={"Intro"}>
|
||||
<ListItem
|
||||
@@ -112,7 +112,7 @@ export default function settings() {
|
||||
</ListGroup>
|
||||
</View>
|
||||
|
||||
{!Platform.isTV && <StorageSettings />}
|
||||
<StorageSettings />
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useNavigation } from "expo-router";
|
||||
import * as Sharing from "expo-sharing";
|
||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import Collapsible from "react-native-collapsible";
|
||||
@@ -10,14 +10,11 @@ import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
||||
|
||||
export default function Page() {
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
const { logs } = useLog();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const orderFilterId = useId();
|
||||
const levelsFilterId = useId();
|
||||
|
||||
const defaultLevels: LogLevel[] = ["INFO", "ERROR", "DEBUG", "WARN"];
|
||||
const codeBlockStyle = {
|
||||
backgroundColor: "#000",
|
||||
@@ -28,12 +25,10 @@ export default function Page() {
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [state, setState] = useState<Record<string, boolean>>({});
|
||||
|
||||
const [order, setOrder] = useState<"asc" | "desc">("desc");
|
||||
const [levels, setLevels] = useState<LogLevel[]>(defaultLevels);
|
||||
|
||||
const _orderId = useId();
|
||||
const _levelsId = useId();
|
||||
|
||||
const filteredLogs = useMemo(
|
||||
() =>
|
||||
logs
|
||||
@@ -78,7 +73,7 @@ export default function Page() {
|
||||
<>
|
||||
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
||||
<FilterButton
|
||||
id={orderFilterId}
|
||||
id='order'
|
||||
queryKey='log'
|
||||
queryFn={async () => ["asc", "desc"]}
|
||||
set={(values) => setOrder(values[0])}
|
||||
@@ -88,7 +83,7 @@ export default function Page() {
|
||||
showSearch={false}
|
||||
/>
|
||||
<FilterButton
|
||||
id={levelsFilterId}
|
||||
id='levels'
|
||||
queryKey='log'
|
||||
queryFn={async () => defaultLevels}
|
||||
set={setLevels}
|
||||
@@ -127,7 +122,7 @@ export default function Page() {
|
||||
{new Date(log.timestamp).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
<Text selectable className='text-xs'>
|
||||
<Text uiTextView selectable className='text-xs'>
|
||||
{log.message}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
93
app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, TouchableOpacity } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getOrSetDeviceId } from "@/utils/device";
|
||||
import { getStatistics } from "@/utils/optimize-server";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||
|
||||
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
||||
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (newVal: string) => {
|
||||
if (newVal.length === 0 || !newVal.startsWith("http")) {
|
||||
toast.error(t("home.settings.toasts.invalid_url"));
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedUrl = newVal.endsWith("/") ? newVal : `${newVal}/`;
|
||||
|
||||
updateSettings({
|
||||
optimizedVersionsServerUrl: updatedUrl,
|
||||
});
|
||||
|
||||
return await getStatistics({
|
||||
url: updatedUrl,
|
||||
authHeader: api?.accessToken,
|
||||
deviceId: getOrSetDeviceId(),
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
toast.success(t("home.settings.toasts.connected"));
|
||||
} else {
|
||||
toast.error(t("home.settings.toasts.could_not_connect"));
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("home.settings.toasts.could_not_connect"));
|
||||
},
|
||||
});
|
||||
|
||||
const onSave = (newVal: string) => {
|
||||
saveMutation.mutate(newVal);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!pluginSettings?.optimizedVersionsServerUrl?.locked) {
|
||||
navigation.setOptions({
|
||||
title: t("home.settings.downloads.optimized_server"),
|
||||
headerRight: () =>
|
||||
saveMutation.isPending ? (
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => onSave(optimizedVersionsServerUrl)}
|
||||
>
|
||||
<Text className='text-blue-500'>
|
||||
{t("home.settings.downloads.save_button")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
|
||||
|
||||
return (
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
|
||||
className='p-4'
|
||||
>
|
||||
<OptimizedServerForm
|
||||
value={optimizedVersionsServerUrl}
|
||||
onChangeValue={setOptimizedVersionsServerUrl}
|
||||
/>
|
||||
</DisabledSetting>
|
||||
);
|
||||
}
|
||||
@@ -112,7 +112,7 @@ const page: React.FC = () => {
|
||||
recursive: true,
|
||||
genres: selectedGenres,
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => Number.parseInt(year, 10)),
|
||||
years: selectedYears.map((year) => Number.parseInt(year)),
|
||||
includeItemTypes: ["Movie", "Series"],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -11,16 +14,30 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ItemContent } from "@/components/ItemContent";
|
||||
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { id } = useLocalSearchParams() as { id: string };
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||
const isOffline = offline === "true";
|
||||
const { data: item, isError } = useQuery({
|
||||
queryKey: ["item", id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || !id) return;
|
||||
const res = await getUserLibraryApi(api).getItem({
|
||||
itemId: id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
const { data: item, isError } = useItemQuery(id, isOffline);
|
||||
return res.data;
|
||||
},
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
|
||||
const opacity = useSharedValue(1);
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
@@ -90,7 +107,7 @@ const Page: React.FC = () => {
|
||||
<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} />}
|
||||
{item && <ItemContent item={item} />}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function page() {
|
||||
const local = useLocalSearchParams();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
|
||||
const { companyId, image, type } = local as unknown as {
|
||||
const { companyId, name, image, type } = local as unknown as {
|
||||
companyId: string;
|
||||
name: string;
|
||||
image: string;
|
||||
|
||||
@@ -221,7 +221,11 @@ const Page: React.FC = () => {
|
||||
| TvDetails
|
||||
}
|
||||
/>
|
||||
<Text selectable className='font-bold text-2xl mb-1'>
|
||||
<Text
|
||||
uiTextView
|
||||
selectable
|
||||
className='font-bold text-2xl mb-1'
|
||||
>
|
||||
{mediaTitle}
|
||||
</Text>
|
||||
<Text className='opacity-50'>{releaseYear}</Text>
|
||||
@@ -252,28 +256,26 @@ const Page: React.FC = () => {
|
||||
) : (
|
||||
details?.mediaInfo?.jellyfinMediaId && (
|
||||
<View className='flex flex-row space-x-2 mt-4'>
|
||||
{!Platform.isTV && (
|
||||
<Button
|
||||
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
||||
color='transparent'
|
||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name='warning-outline'
|
||||
size={20}
|
||||
color='white'
|
||||
/>
|
||||
}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
<Text className='text-sm'>
|
||||
{t("jellyseerr.report_issue_button")}
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
||||
color='transparent'
|
||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name='warning-outline'
|
||||
size={20}
|
||||
color='white'
|
||||
/>
|
||||
}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
<Text className='text-sm'>
|
||||
{t("jellyseerr.report_issue_button")}
|
||||
</Text>
|
||||
</Button>
|
||||
<Button
|
||||
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
|
||||
onPress={() => {
|
||||
@@ -331,95 +333,92 @@ const Page: React.FC = () => {
|
||||
}}
|
||||
onDismiss={() => _setRequestBody(undefined)}
|
||||
/>
|
||||
{!Platform.isTV && (
|
||||
// This is till it's fixed because the menu isn't selectable on TV
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={renderBackdrop}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||
<View>
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{t("jellyseerr.whats_wrong")}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='flex flex-col space-y-2 items-start'>
|
||||
<View className='flex flex-col'>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.issue_type")}
|
||||
</Text>
|
||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text style={{}} className='' numberOfLines={1}>
|
||||
{issueType
|
||||
? IssueTypeName[issueType]
|
||||
: t("jellyseerr.select_an_issue")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={false}
|
||||
side='bottom'
|
||||
align='center'
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
sideOffset={0}
|
||||
>
|
||||
<DropdownMenu.Label>
|
||||
{t("jellyseerr.types")}
|
||||
</DropdownMenu.Label>
|
||||
{Object.entries(IssueTypeName)
|
||||
.reverse()
|
||||
.map(([key, value], _idx) => (
|
||||
<DropdownMenu.Item
|
||||
key={value}
|
||||
onSelect={() =>
|
||||
setIssueType(key as unknown as IssueType)
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{value}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
||||
<BottomSheetTextInput
|
||||
multiline
|
||||
maxLength={254}
|
||||
style={{ color: "white" }}
|
||||
clearButtonMode='always'
|
||||
placeholder={t("jellyseerr.describe_the_issue")}
|
||||
placeholderTextColor='#9CA3AF'
|
||||
// Issue with multiline + Textinput inside a portal
|
||||
// https://github.com/callstack/react-native-paper/issues/1668
|
||||
defaultValue={issueMessage}
|
||||
onChangeText={setIssueMessage}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Button className='mt-auto' onPress={submitIssue} color='purple'>
|
||||
{t("jellyseerr.submit_button")}
|
||||
</Button>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={renderBackdrop}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||
<View>
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{t("jellyseerr.whats_wrong")}
|
||||
</Text>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
)}
|
||||
<View className='flex flex-col space-y-2 items-start'>
|
||||
<View className='flex flex-col'>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.issue_type")}
|
||||
</Text>
|
||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text style={{}} className='' numberOfLines={1}>
|
||||
{issueType
|
||||
? IssueTypeName[issueType]
|
||||
: t("jellyseerr.select_an_issue")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={false}
|
||||
side='bottom'
|
||||
align='center'
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
sideOffset={0}
|
||||
>
|
||||
<DropdownMenu.Label>
|
||||
{t("jellyseerr.types")}
|
||||
</DropdownMenu.Label>
|
||||
{Object.entries(IssueTypeName)
|
||||
.reverse()
|
||||
.map(([key, value], _idx) => (
|
||||
<DropdownMenu.Item
|
||||
key={value}
|
||||
onSelect={() =>
|
||||
setIssueType(key as unknown as IssueType)
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{value}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
||||
<BottomSheetTextInput
|
||||
multiline
|
||||
maxLength={254}
|
||||
style={{ color: "white" }}
|
||||
clearButtonMode='always'
|
||||
placeholder={t("jellyseerr.describe_the_issue")}
|
||||
placeholderTextColor='#9CA3AF'
|
||||
// Issue with multiline + Textinput inside a portal
|
||||
// https://github.com/callstack/react-native-paper/issues/1668
|
||||
defaultValue={issueMessage}
|
||||
onChangeText={setIssueMessage}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Button className='mt-auto' onPress={submitIssue} color='purple'>
|
||||
{t("jellyseerr.submit_button")}
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,13 +21,14 @@ export default function page() {
|
||||
|
||||
const {
|
||||
jellyseerrApi,
|
||||
jellyseerrUser,
|
||||
jellyseerrRegion: region,
|
||||
jellyseerrLocale: locale,
|
||||
} = useJellyseerr();
|
||||
|
||||
const { personId } = local as { personId: string };
|
||||
|
||||
const { data } = useQuery({
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey: ["jellyseerr", "person", personId],
|
||||
queryFn: async () => ({
|
||||
details: await jellyseerrApi?.personDetails(personId),
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import {
|
||||
createMaterialTopTabNavigator,
|
||||
MaterialTopTabNavigationEventMap,
|
||||
MaterialTopTabNavigationOptions,
|
||||
} from "@react-navigation/material-top-tabs";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "@react-navigation/native";
|
||||
import { Stack, withLayoutContext } from "expo-router";
|
||||
|
||||
const { Navigator } = createMaterialTopTabNavigator();
|
||||
|
||||
export const Tab = withLayoutContext<
|
||||
MaterialTopTabNavigationOptions,
|
||||
typeof Navigator,
|
||||
TabNavigationState<ParamListBase>,
|
||||
MaterialTopTabNavigationEventMap
|
||||
>(Navigator);
|
||||
|
||||
const Layout = () => {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Live TV" }} />
|
||||
<Tab
|
||||
initialRouteName='programs'
|
||||
keyboardDismissMode='none'
|
||||
screenOptions={{
|
||||
tabBarBounces: true,
|
||||
tabBarLabelStyle: { fontSize: 10 },
|
||||
tabBarItemStyle: {
|
||||
width: 100,
|
||||
},
|
||||
tabBarStyle: { backgroundColor: "black" },
|
||||
animationEnabled: true,
|
||||
lazy: true,
|
||||
swipeEnabled: true,
|
||||
tabBarIndicatorStyle: { backgroundColor: "#9334E9" },
|
||||
tabBarScrollEnabled: true,
|
||||
}}
|
||||
>
|
||||
<Tab.Screen name='programs' />
|
||||
<Tab.Screen name='guide' />
|
||||
<Tab.Screen name='channels' />
|
||||
<Tab.Screen name='recordings' />
|
||||
</Tab>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -1,55 +0,0 @@
|
||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
export default function page() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const _insets = useSafeAreaInsets();
|
||||
|
||||
const { data: channels } = useQuery({
|
||||
queryKey: ["livetv", "channels"],
|
||||
queryFn: async () => {
|
||||
const res = await getLiveTvApi(api!).getLiveTvChannels({
|
||||
startIndex: 0,
|
||||
limit: 500,
|
||||
enableFavoriteSorting: true,
|
||||
userId: user?.Id,
|
||||
addCurrentProgram: false,
|
||||
enableUserData: false,
|
||||
enableImageTypes: ["Primary"],
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View className='flex flex-1'>
|
||||
<FlashList
|
||||
data={channels?.Items}
|
||||
estimatedItemSize={76}
|
||||
renderItem={({ item }) => (
|
||||
<View className='flex flex-row items-center px-4 mb-2'>
|
||||
<View className='w-22 mr-4 rounded-lg overflow-hidden'>
|
||||
<ItemImage
|
||||
style={{
|
||||
aspectRatio: "1/1",
|
||||
width: 60,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
item={item}
|
||||
/>
|
||||
</View>
|
||||
<Text className='font-bold'>{item.Name}</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dimensions, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { HourHeader } from "@/components/livetv/HourHeader";
|
||||
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
const HOUR_HEIGHT = 30;
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
|
||||
|
||||
export default function page() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [date, _setDate] = useState<Date>(new Date());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const { data: channels } = useQuery({
|
||||
queryKey: ["livetv", "channels", currentPage],
|
||||
queryFn: async () => {
|
||||
const res = await getLiveTvApi(api!).getLiveTvChannels({
|
||||
startIndex: (currentPage - 1) * ITEMS_PER_PAGE,
|
||||
limit: ITEMS_PER_PAGE,
|
||||
enableFavoriteSorting: true,
|
||||
userId: user?.Id,
|
||||
addCurrentProgram: false,
|
||||
enableUserData: false,
|
||||
enableImageTypes: ["Primary"],
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: programs } = useQuery({
|
||||
queryKey: ["livetv", "programs", date, currentPage],
|
||||
queryFn: async () => {
|
||||
const startOfDay = new Date(date);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date(date);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
const now = new Date();
|
||||
const isToday = startOfDay.toDateString() === now.toDateString();
|
||||
|
||||
const res = await getLiveTvApi(api!).getPrograms({
|
||||
getProgramsDto: {
|
||||
MaxStartDate: endOfDay.toISOString(),
|
||||
MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(),
|
||||
ChannelIds: channels?.Items?.map((c) => c.Id).filter(
|
||||
Boolean,
|
||||
) as string[],
|
||||
ImageTypeLimit: 1,
|
||||
EnableImages: false,
|
||||
SortBy: ["StartDate"],
|
||||
EnableTotalRecordCount: false,
|
||||
EnableUserData: false,
|
||||
},
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!channels,
|
||||
});
|
||||
|
||||
const screenWidth = Dimensions.get("window").width;
|
||||
|
||||
const [scrollX, setScrollX] = useState(0);
|
||||
|
||||
const handleNextPage = useCallback(() => {
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
const handlePrevPage = useCallback(() => {
|
||||
setCurrentPage((prev) => Math.max(1, prev - 1));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
key={"home"}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
>
|
||||
<PageButtons
|
||||
currentPage={currentPage}
|
||||
onPrevPage={handlePrevPage}
|
||||
onNextPage={handleNextPage}
|
||||
isNextDisabled={
|
||||
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
|
||||
}
|
||||
/>
|
||||
|
||||
<View className='flex flex-row'>
|
||||
<View className='flex flex-col w-[64px]'>
|
||||
<View
|
||||
style={{
|
||||
height: HOUR_HEIGHT,
|
||||
}}
|
||||
className='bg-neutral-800'
|
||||
/>
|
||||
{channels?.Items?.map((c, i) => (
|
||||
<View className='h-16 w-16 mr-4 rounded-lg overflow-hidden' key={i}>
|
||||
<ItemImage
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
item={c}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<ScrollView
|
||||
style={{
|
||||
width: screenWidth - 64,
|
||||
}}
|
||||
horizontal
|
||||
scrollEnabled
|
||||
onScroll={(e) => {
|
||||
setScrollX(e.nativeEvent.contentOffset.x);
|
||||
}}
|
||||
>
|
||||
<View className='flex flex-col'>
|
||||
<HourHeader height={HOUR_HEIGHT} />
|
||||
{channels?.Items?.map((c, _i) => (
|
||||
<MemoizedLiveTVGuideRow
|
||||
channel={c}
|
||||
programs={programs?.Items}
|
||||
key={c.Id}
|
||||
scrollX={scrollX}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageButtonsProps {
|
||||
currentPage: number;
|
||||
onPrevPage: () => void;
|
||||
onNextPage: () => void;
|
||||
isNextDisabled: boolean;
|
||||
}
|
||||
|
||||
const PageButtons: React.FC<PageButtonsProps> = ({
|
||||
currentPage,
|
||||
onPrevPage,
|
||||
onNextPage,
|
||||
isNextDisabled,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View className='flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2'>
|
||||
<TouchableOpacity
|
||||
onPress={onPrevPage}
|
||||
disabled={currentPage === 1}
|
||||
className='flex flex-row items-center'
|
||||
>
|
||||
<Ionicons
|
||||
name='chevron-back'
|
||||
size={24}
|
||||
color={currentPage === 1 ? "gray" : "white"}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-1 ${
|
||||
currentPage === 1 ? "text-gray-500" : "text-white"
|
||||
}`}
|
||||
>
|
||||
{t("live_tv.previous")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className='text-white'>Page {currentPage}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={onNextPage}
|
||||
disabled={isNextDisabled}
|
||||
className='flex flex-row items-center'
|
||||
>
|
||||
<Text
|
||||
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
|
||||
>
|
||||
{t("live_tv.next")}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-forward'
|
||||
size={24}
|
||||
color={isNextDisabled ? "gray" : "white"}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,145 +0,0 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
export default function page() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
key={"home"}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
paddingTop: 8,
|
||||
}}
|
||||
>
|
||||
<View className='flex flex-col space-y-2'>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "recommended"]}
|
||||
title={t("live_tv.on_now")}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getRecommendedPrograms({
|
||||
userId: user?.Id,
|
||||
isAiring: true,
|
||||
limit: 24,
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation='horizontal'
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "shows"]}
|
||||
title={t("live_tv.shows")}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isMovie: false,
|
||||
isSeries: true,
|
||||
isSports: false,
|
||||
isNews: false,
|
||||
isKids: false,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation='horizontal'
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "movies"]}
|
||||
title={t("live_tv.movies")}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isMovie: true,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation='horizontal'
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "sports"]}
|
||||
title={t("live_tv.sports")}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isSports: true,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation='horizontal'
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "kids"]}
|
||||
title={t("live_tv.for_kids")}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isKids: true,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation='horizontal'
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "news"]}
|
||||
title={t("live_tv.news")}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isNews: true,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation='horizontal'
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
export default function page() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View className='flex items-center justify-center h-full -mt-12'>
|
||||
<Text>{t("live_tv.coming_soon")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -69,18 +69,10 @@ const page: React.FC = () => {
|
||||
seriesId: item?.Id!,
|
||||
userId: user?.Id!,
|
||||
enableUserData: true,
|
||||
// Note: Including trick play is necessary to enable trick play downloads
|
||||
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||
fields: ["MediaSources", "MediaStreams", "Overview"],
|
||||
});
|
||||
return res?.data.Items || [];
|
||||
},
|
||||
select: (data) =>
|
||||
// This needs to be sorted by parent index number and then index number, that way we can download the episodes in the correct order.
|
||||
[...(data || [])].sort(
|
||||
(a, b) =>
|
||||
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
||||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
||||
),
|
||||
staleTime: 60,
|
||||
enabled: !!api && !!user?.Id && !!item?.Id,
|
||||
});
|
||||
@@ -144,7 +136,7 @@ const page: React.FC = () => {
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
/>
|
||||
) : undefined
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<View className='flex flex-col pt-4'>
|
||||
|
||||
@@ -168,7 +168,7 @@ const Page = () => {
|
||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||
genres: selectedGenres,
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => Number.parseInt(year, 10)),
|
||||
years: selectedYears.map((year) => Number.parseInt(year)),
|
||||
includeItemTypes: itemType ? [itemType] : undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function IndexLayout() {
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: t("tabs.library"),
|
||||
headerBlurEffect: "prominent",
|
||||
@@ -200,7 +200,7 @@ export default function IndexLayout() {
|
||||
name='[libraryId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: !Platform.isTV,
|
||||
headerShown: true,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
@@ -213,7 +213,7 @@ export default function IndexLayout() {
|
||||
name='collections/[collectionId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: !Platform.isTV,
|
||||
headerShown: true,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function SearchLayout() {
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: t("tabs.search"),
|
||||
headerLargeStyle: {
|
||||
@@ -31,7 +31,7 @@ export default function SearchLayout() {
|
||||
name='collections/[collectionId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: !Platform.isTV,
|
||||
headerShown: true,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useAtom } from "jotai";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -21,7 +20,6 @@ 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";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
@@ -59,9 +57,6 @@ export default function search() {
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const searchFilterId = useId();
|
||||
const orderFilterId = useId();
|
||||
|
||||
const { q } = params as { q: string };
|
||||
|
||||
const [searchType, setSearchType] = useState<SearchType>("Library");
|
||||
@@ -254,223 +249,205 @@ export default function search() {
|
||||
}, [l1, l2, l3, l7, l8]);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
keyboardDismissMode='on-drag'
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
{/* <View
|
||||
className='flex flex-col'
|
||||
style={{
|
||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||
}}
|
||||
> */}
|
||||
{Platform.isTV && (
|
||||
<Input
|
||||
placeholder={t("search.search")}
|
||||
onChangeText={(text) => {
|
||||
router.setParams({ q: "" });
|
||||
setSearch(text);
|
||||
}}
|
||||
keyboardType='default'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
className='flex flex-col'
|
||||
style={{
|
||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||
<>
|
||||
<ScrollView
|
||||
keyboardDismissMode='on-drag'
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
{jellyseerrApi && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
|
||||
>
|
||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||
<Tag
|
||||
text={t("search.library")}
|
||||
textClass='p-1'
|
||||
className={
|
||||
searchType === "Library" ? "bg-purple-600" : undefined
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||
<Tag
|
||||
text={t("search.discover")}
|
||||
textClass='p-1'
|
||||
className={
|
||||
searchType === "Discover" ? "bg-purple-600" : undefined
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
{searchType === "Discover" &&
|
||||
!loading &&
|
||||
noResults &&
|
||||
debouncedSearch.length > 0 && (
|
||||
<View className='flex flex-row justify-end items-center space-x-1'>
|
||||
<FilterButton
|
||||
id={searchFilterId}
|
||||
queryKey='jellyseerr_search'
|
||||
queryFn={async () =>
|
||||
Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||
Number.isNaN(Number(v)),
|
||||
)
|
||||
}
|
||||
set={(value) => setJellyseerrOrderBy(value[0])}
|
||||
values={[jellyseerrOrderBy]}
|
||||
title={t("library.filters.sort_by")}
|
||||
renderItemLabel={(item) =>
|
||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
||||
}
|
||||
showSearch={false}
|
||||
/>
|
||||
<FilterButton
|
||||
id={orderFilterId}
|
||||
queryKey='jellysearr_search'
|
||||
queryFn={async () => ["asc", "desc"]}
|
||||
set={(value) => setJellyseerrSortOrder(value[0])}
|
||||
values={[jellyseerrSortOrder]}
|
||||
title={t("library.filters.sort_order")}
|
||||
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
||||
showSearch={false}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
<View
|
||||
className='flex flex-col'
|
||||
style={{
|
||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||
}}
|
||||
>
|
||||
{jellyseerrApi && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
|
||||
>
|
||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||
<Tag
|
||||
text={t("search.library")}
|
||||
textClass='p-1'
|
||||
className={
|
||||
searchType === "Library" ? "bg-purple-600" : undefined
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||
<Tag
|
||||
text={t("search.discover")}
|
||||
textClass='p-1'
|
||||
className={
|
||||
searchType === "Discover" ? "bg-purple-600" : undefined
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
{searchType === "Discover" &&
|
||||
!loading &&
|
||||
noResults &&
|
||||
debouncedSearch.length > 0 && (
|
||||
<View className='flex flex-row justify-end items-center space-x-1'>
|
||||
<FilterButton
|
||||
id='search'
|
||||
queryKey='jellyseerr_search'
|
||||
queryFn={async () =>
|
||||
Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||
Number.isNaN(Number(v)),
|
||||
)
|
||||
}
|
||||
set={(value) => setJellyseerrOrderBy(value[0])}
|
||||
values={[jellyseerrOrderBy]}
|
||||
title={t("library.filters.sort_by")}
|
||||
renderItemLabel={(item) =>
|
||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
||||
}
|
||||
showSearch={false}
|
||||
/>
|
||||
<FilterButton
|
||||
id='order'
|
||||
queryKey='jellysearr_search'
|
||||
queryFn={async () => ["asc", "desc"]}
|
||||
set={(value) => setJellyseerrSortOrder(value[0])}
|
||||
values={[jellyseerrSortOrder]}
|
||||
title={t("library.filters.sort_order")}
|
||||
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
||||
showSearch={false}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<View className='mt-2'>
|
||||
<LoadingSkeleton isLoading={loading} />
|
||||
</View>
|
||||
|
||||
{searchType === "Library" ? (
|
||||
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||
<SearchItemWrapper
|
||||
header={t("search.movies")}
|
||||
items={movies}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
item={item}
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className='mt-2'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className='opacity-50 text-xs'>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={series}
|
||||
header={t("search.series")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
>
|
||||
<SeriesPoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className='mt-2'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className='opacity-50 text-xs'>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={episodes}
|
||||
header={t("search.episodes")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className='flex flex-col w-44 mr-2'
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={collections}
|
||||
header={t("search.collections")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className='mt-2'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={actors}
|
||||
header={t("search.actors")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<View className='mt-2'>
|
||||
<LoadingSkeleton isLoading={loading} />
|
||||
</View>
|
||||
) : (
|
||||
<JellyserrIndexPage
|
||||
searchQuery={debouncedSearch}
|
||||
sortType={jellyseerrOrderBy}
|
||||
order={jellyseerrSortOrder}
|
||||
/>
|
||||
)}
|
||||
|
||||
{searchType === "Library" &&
|
||||
(!loading && noResults && debouncedSearch.length > 0 ? (
|
||||
<View>
|
||||
<Text className='text-center text-lg font-bold mt-4'>
|
||||
{t("search.no_results_found_for")}
|
||||
</Text>
|
||||
<Text className='text-xs text-purple-600 text-center'>
|
||||
"{debouncedSearch}"
|
||||
</Text>
|
||||
{searchType === "Library" ? (
|
||||
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||
<SearchItemWrapper
|
||||
header={t("search.movies")}
|
||||
items={movies}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
item={item}
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className='mt-2'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className='opacity-50 text-xs'>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={series}
|
||||
header={t("search.series")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
>
|
||||
<SeriesPoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className='mt-2'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className='opacity-50 text-xs'>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={episodes}
|
||||
header={t("search.episodes")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className='flex flex-col w-44 mr-2'
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={collections}
|
||||
header={t("search.collections")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className='mt-2'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={actors}
|
||||
header={t("search.actors")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
) : debouncedSearch.length === 0 ? (
|
||||
<View className='mt-4 flex flex-col items-center space-y-2'>
|
||||
{exampleSearches.map((e) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setSearch(e);
|
||||
searchBarRef.current?.setText(e);
|
||||
}}
|
||||
key={e}
|
||||
className='mb-2'
|
||||
>
|
||||
<Text className='text-purple-600'>{e}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
) : null)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<JellyserrIndexPage
|
||||
searchQuery={debouncedSearch}
|
||||
sortType={jellyseerrOrderBy}
|
||||
order={jellyseerrSortOrder}
|
||||
/>
|
||||
)}
|
||||
|
||||
{searchType === "Library" &&
|
||||
(!loading && noResults && debouncedSearch.length > 0 ? (
|
||||
<View>
|
||||
<Text className='text-center text-lg font-bold mt-4'>
|
||||
{t("search.no_results_found_for")}
|
||||
</Text>
|
||||
<Text className='text-xs text-purple-600 text-center'>
|
||||
"{debouncedSearch}"
|
||||
</Text>
|
||||
</View>
|
||||
) : debouncedSearch.length === 0 ? (
|
||||
<View className='mt-4 flex flex-col items-center space-y-2'>
|
||||
{exampleSearches.map((e) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setSearch(e);
|
||||
searchBarRef.current?.setText(e);
|
||||
}}
|
||||
key={e}
|
||||
className='mb-2'
|
||||
>
|
||||
<Text className='text-purple-600'>{e}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
) : null)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import {
|
||||
createNativeBottomTabNavigator,
|
||||
type NativeBottomTabNavigationEventMap,
|
||||
type NativeBottomTabNavigationOptions,
|
||||
} from "@bottom-tabs/react-navigation";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "@react-navigation/native";
|
||||
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
const { Navigator } = createNativeBottomTabNavigator();
|
||||
|
||||
import type { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "@react-navigation/native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
const { Navigator } = createNativeBottomTabNavigator();
|
||||
|
||||
export const NativeTabs = withLayoutContext<
|
||||
NativeBottomTabNavigationOptions,
|
||||
BottomTabNavigationOptions,
|
||||
typeof Navigator,
|
||||
TabNavigationState<ParamListBase>,
|
||||
NativeBottomTabNavigationEventMap
|
||||
@@ -51,6 +52,7 @@ export default function TabLayout() {
|
||||
<SystemBars hidden={false} style='light' />
|
||||
<NativeTabs
|
||||
sidebarAdaptable={false}
|
||||
ignoresTopSafeArea
|
||||
tabBarStyle={{
|
||||
backgroundColor: "#121212",
|
||||
}}
|
||||
@@ -59,7 +61,7 @@ export default function TabLayout() {
|
||||
>
|
||||
<NativeTabs.Screen redirect name='index' />
|
||||
<NativeTabs.Screen
|
||||
listeners={(_e) => ({
|
||||
listeners={({ navigation }) => ({
|
||||
tabPress: (_e) => {
|
||||
eventBus.emit("scrollToTop");
|
||||
},
|
||||
@@ -69,7 +71,8 @@ export default function TabLayout() {
|
||||
title: t("tabs.home"),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/house.fill.png")
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/house.fill.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "house.fill" }
|
||||
@@ -77,7 +80,7 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
listeners={(_e) => ({
|
||||
listeners={({ navigation }) => ({
|
||||
tabPress: (_e) => {
|
||||
eventBus.emit("searchTabPressed");
|
||||
},
|
||||
@@ -87,7 +90,8 @@ export default function TabLayout() {
|
||||
title: t("tabs.search"),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/magnifyingglass.png")
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/magnifyingglass.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "magnifyingglass" }
|
||||
@@ -100,7 +104,7 @@ export default function TabLayout() {
|
||||
title: t("tabs.favorites"),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? ({ focused }) =>
|
||||
? ({ color, focused, size }) =>
|
||||
focused
|
||||
? require("@/assets/icons/heart.fill.png")
|
||||
: require("@/assets/icons/heart.png")
|
||||
@@ -116,7 +120,8 @@ export default function TabLayout() {
|
||||
title: t("tabs.library"),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/server.rack.png")
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/server.rack.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "rectangle.stack.fill" }
|
||||
@@ -127,10 +132,11 @@ export default function TabLayout() {
|
||||
name='(custom-links)'
|
||||
options={{
|
||||
title: t("tabs.custom_links"),
|
||||
// @ts-expect-error
|
||||
tabBarItemHidden: !settings?.showCustomMenuLinks,
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/list.png")
|
||||
? ({ focused }) => require("@/assets/icons/list.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "list.dash.fill" }
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type BaseItemDto,
|
||||
type MediaSourceInfo,
|
||||
PlaybackOrder,
|
||||
type PlaybackProgressInfo,
|
||||
PlaybackStartInfo,
|
||||
RepeatMode,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
@@ -14,15 +15,15 @@ import { router, useGlobalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Platform, View } from "react-native";
|
||||
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
|
||||
|
||||
import { Alert, View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { VlcPlayerView } from "@/modules";
|
||||
@@ -32,15 +33,18 @@ import type {
|
||||
ProgressUpdatePayload,
|
||||
VlcPlayerViewRef,
|
||||
} from "@/modules/VlcPlayer.types";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import generateDeviceProfile from "@/utils/profiles/native";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
|
||||
const downloadProvider = require("@/providers/DownloadProvider");
|
||||
|
||||
const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas";
|
||||
|
||||
export default function page() {
|
||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||
const user = useAtomValue(userAtom);
|
||||
@@ -50,12 +54,11 @@ export default function page() {
|
||||
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, _setShowControls] = useState(true);
|
||||
const [aspectRatio, setAspectRatio] = useState<
|
||||
"default" | "16:9" | "4:3" | "1:1" | "21:9"
|
||||
>("default");
|
||||
const [scaleFactor, setScaleFactor] = useState<
|
||||
1.0 | 1.1 | 1.2 | 1.3 | 1.4 | 1.5 | 1.6 | 1.7 | 1.8 | 1.9 | 2.0
|
||||
>(1.0);
|
||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(() => {
|
||||
// Load persisted state from storage
|
||||
const saved = storage.getBoolean(IGNORE_SAFE_AREAS_KEY);
|
||||
return saved ?? false;
|
||||
});
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
@@ -65,11 +68,9 @@ export default function page() {
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
const cacheProgress = useSharedValue(0);
|
||||
const VolumeManager = Platform.isTV
|
||||
? null
|
||||
: require("react-native-volume-manager");
|
||||
const VolumeManager = require("react-native-volume-manager");
|
||||
|
||||
const downloadUtils = useDownload();
|
||||
const getDownloadedItem = downloadProvider.useDownload();
|
||||
|
||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
@@ -80,6 +81,11 @@ export default function page() {
|
||||
lightHapticFeedback();
|
||||
}, []);
|
||||
|
||||
// Persist ignoreSafeAreas state whenever it changes
|
||||
useEffect(() => {
|
||||
storage.set(IGNORE_SAFE_AREAS_KEY, ignoreSafeAreas);
|
||||
}, [ignoreSafeAreas]);
|
||||
|
||||
const {
|
||||
itemId,
|
||||
audioIndex: audioIndexStr,
|
||||
@@ -98,10 +104,9 @@ export default function page() {
|
||||
/** Playback position in ticks. */
|
||||
playbackPosition?: string;
|
||||
}>();
|
||||
const [_settings] = useSettings();
|
||||
|
||||
const [settings] = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const offline = offlineStr === "true";
|
||||
const playbackManager = usePlaybackManager();
|
||||
|
||||
const audioIndex = audioIndexStr
|
||||
? Number.parseInt(audioIndexStr, 10)
|
||||
@@ -114,33 +119,27 @@ export default function page() {
|
||||
: BITRATES[0].value;
|
||||
|
||||
const [item, setItem] = useState<BaseItemDto | null>(null);
|
||||
const [downloadedItem, setDownloadedItem] = useState<DownloadedItem | null>(
|
||||
null,
|
||||
);
|
||||
const [itemStatus, setItemStatus] = useState({
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
/** Gets the initial playback position from the URL. */
|
||||
/** Gets the initial playback position from the URL or the item's user data. */
|
||||
const getInitialPlaybackTicks = useCallback((): number => {
|
||||
if (playbackPositionFromUrl) {
|
||||
return Number.parseInt(playbackPositionFromUrl, 10);
|
||||
}
|
||||
return item?.UserData?.PlaybackPositionTicks ?? 0;
|
||||
}, [playbackPositionFromUrl]);
|
||||
}, [playbackPositionFromUrl, item]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchItemData = async () => {
|
||||
setItemStatus({ isLoading: true, isError: false });
|
||||
try {
|
||||
let fetchedItem: BaseItemDto | null = null;
|
||||
if (offline && !Platform.isTV) {
|
||||
const data = downloadUtils.getDownloadedItemById(itemId);
|
||||
if (data) {
|
||||
fetchedItem = data.item as BaseItemDto;
|
||||
setDownloadedItem(data);
|
||||
}
|
||||
if (offline) {
|
||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||
if (data) fetchedItem = data.item as BaseItemDto;
|
||||
} else {
|
||||
const res = await getUserLibraryApi(api!).getItem({
|
||||
itemId,
|
||||
@@ -176,20 +175,17 @@ export default function page() {
|
||||
useEffect(() => {
|
||||
const fetchStreamData = async () => {
|
||||
setStreamStatus({ isLoading: true, isError: false });
|
||||
const native = await generateDeviceProfile();
|
||||
try {
|
||||
let result: Stream | null = null;
|
||||
if (offline && downloadedItem && downloadedItem.mediaSource) {
|
||||
const url = downloadedItem.videoFilePath;
|
||||
if (offline) {
|
||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||
if (!data?.mediaSource) return;
|
||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
||||
if (item) {
|
||||
result = {
|
||||
mediaSource: downloadedItem.mediaSource,
|
||||
sessionId: "",
|
||||
url: url,
|
||||
};
|
||||
result = { mediaSource: data.mediaSource, sessionId: "", url };
|
||||
}
|
||||
} else {
|
||||
const native = generateDeviceProfile();
|
||||
const transcoding = generateDeviceProfile({ transcode: true });
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
@@ -199,7 +195,7 @@ export default function page() {
|
||||
maxStreamingBitrate: bitrateValue,
|
||||
mediaSourceId: mediaSourceId,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: bitrateValue ? transcoding : native,
|
||||
deviceProfile: native,
|
||||
});
|
||||
if (!res) return;
|
||||
const { mediaSource, sessionId, url } = res;
|
||||
@@ -220,39 +216,26 @@ export default function page() {
|
||||
}
|
||||
};
|
||||
fetchStreamData();
|
||||
}, [
|
||||
itemId,
|
||||
mediaSourceId,
|
||||
bitrateValue,
|
||||
api,
|
||||
item,
|
||||
user?.Id,
|
||||
downloadedItem,
|
||||
]);
|
||||
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stream || !api) return;
|
||||
if (!stream) return;
|
||||
|
||||
const reportPlaybackStart = async () => {
|
||||
await getPlaystateApi(api).reportPlaybackStart({
|
||||
await getPlaystateApi(api!).reportPlaybackStart({
|
||||
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
|
||||
});
|
||||
};
|
||||
|
||||
reportPlaybackStart();
|
||||
}, [stream, api]);
|
||||
}, [stream]);
|
||||
|
||||
const togglePlay = async () => {
|
||||
lightHapticFeedback();
|
||||
setIsPlaying(!isPlaying);
|
||||
if (isPlaying) {
|
||||
await videoRef.current?.pause();
|
||||
playbackManager.reportPlaybackProgress(
|
||||
item?.Id!,
|
||||
msToTicks(progress.get()),
|
||||
{
|
||||
AudioStreamIndex: audioIndex ?? -1,
|
||||
SubtitleStreamIndex: subtitleIndex ?? -1,
|
||||
},
|
||||
);
|
||||
reportPlaybackProgress();
|
||||
} else {
|
||||
videoRef.current?.play();
|
||||
await getPlaystateApi(api!).reportPlaybackStart({
|
||||
@@ -262,6 +245,7 @@ export default function page() {
|
||||
};
|
||||
|
||||
const reportPlaybackStopped = useCallback(async () => {
|
||||
if (offline) return;
|
||||
const currentTimeInTicks = msToTicks(progress.get());
|
||||
await getPlaystateApi(api!).onPlaybackStopped({
|
||||
itemId: item?.Id!,
|
||||
@@ -269,6 +253,8 @@ export default function page() {
|
||||
positionTicks: currentTimeInTicks,
|
||||
playSessionId: stream?.sessionId!,
|
||||
});
|
||||
|
||||
revalidateProgressCache();
|
||||
}, [
|
||||
api,
|
||||
item,
|
||||
@@ -280,15 +266,10 @@ export default function page() {
|
||||
]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
// Update URL with final playback position before stopping
|
||||
router.setParams({
|
||||
playbackPosition: msToTicks(progress.get()).toString(),
|
||||
});
|
||||
reportPlaybackStopped();
|
||||
setIsPlaybackStopped(true);
|
||||
videoRef.current?.stop();
|
||||
revalidateProgressCache();
|
||||
}, [videoRef, reportPlaybackStopped, progress]);
|
||||
}, [videoRef, reportPlaybackStopped]);
|
||||
|
||||
useEffect(() => {
|
||||
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
||||
@@ -297,7 +278,7 @@ export default function page() {
|
||||
};
|
||||
}, [navigation, stop]);
|
||||
|
||||
const currentPlayStateInfo = useCallback(() => {
|
||||
const currentPlayStateInfo = () => {
|
||||
if (!stream) return;
|
||||
return {
|
||||
itemId: item?.Id!,
|
||||
@@ -313,32 +294,7 @@ export default function page() {
|
||||
repeatMode: RepeatMode.RepeatNone,
|
||||
playbackOrder: PlaybackOrder.Default,
|
||||
};
|
||||
}, [
|
||||
stream,
|
||||
item?.Id,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
progress,
|
||||
isPlaying,
|
||||
isMuted,
|
||||
]);
|
||||
|
||||
const lastUrlUpdateTime = useSharedValue(0);
|
||||
const wasJustSeeking = useSharedValue(false);
|
||||
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second
|
||||
|
||||
// Track when seeking ends to update URL immediately
|
||||
useAnimatedReaction(
|
||||
() => isSeeking.get(),
|
||||
(currentSeeking, previousSeeking) => {
|
||||
if (previousSeeking && !currentSeeking) {
|
||||
// Seeking just ended
|
||||
wasJustSeeking.value = true;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
};
|
||||
|
||||
const onProgress = useCallback(
|
||||
async (data: ProgressUpdatePayload) => {
|
||||
@@ -351,31 +307,15 @@ export default function page() {
|
||||
|
||||
progress.set(currentTime);
|
||||
|
||||
// Update URL immediately after seeking, or every 30 seconds during normal playback
|
||||
const now = Date.now();
|
||||
const shouldUpdateUrl = wasJustSeeking.get();
|
||||
wasJustSeeking.value = false;
|
||||
// Update the playback position in the URL.
|
||||
router.setParams({
|
||||
playbackPosition: msToTicks(currentTime).toString(),
|
||||
});
|
||||
|
||||
if (
|
||||
shouldUpdateUrl ||
|
||||
now - lastUrlUpdateTime.get() > URL_UPDATE_INTERVAL
|
||||
) {
|
||||
router.setParams({
|
||||
playbackPosition: msToTicks(currentTime).toString(),
|
||||
});
|
||||
lastUrlUpdateTime.value = now;
|
||||
}
|
||||
if (offline) return;
|
||||
if (!item?.Id || !stream) return;
|
||||
|
||||
if (!item?.Id) return;
|
||||
|
||||
playbackManager.reportPlaybackProgress(
|
||||
item.Id,
|
||||
msToTicks(progress.get()),
|
||||
{
|
||||
AudioStreamIndex: audioIndex ?? -1,
|
||||
SubtitleStreamIndex: subtitleIndex ?? -1,
|
||||
},
|
||||
);
|
||||
reportPlaybackProgress();
|
||||
},
|
||||
[
|
||||
item?.Id,
|
||||
@@ -395,14 +335,30 @@ export default function page() {
|
||||
setIsPipStarted(pipStarted);
|
||||
}, []);
|
||||
|
||||
const reportPlaybackProgress = useCallback(async () => {
|
||||
if (!api || offline || !stream) return;
|
||||
await getPlaystateApi(api).reportPlaybackProgress({
|
||||
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
|
||||
});
|
||||
}, [
|
||||
api,
|
||||
isPlaying,
|
||||
offline,
|
||||
stream,
|
||||
item?.Id,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
progress,
|
||||
]);
|
||||
|
||||
/** Gets the initial playback position in seconds. */
|
||||
const startPosition = useMemo(() => {
|
||||
if (offline) return 0;
|
||||
return ticksToSeconds(getInitialPlaybackTicks());
|
||||
}, [getInitialPlaybackTicks]);
|
||||
}, [offline, getInitialPlaybackTicks]);
|
||||
|
||||
const volumeUpCb = useCallback(async () => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
try {
|
||||
const { volume: currentVolume } = await VolumeManager.getVolume();
|
||||
const newVolume = Math.min(currentVolume + 0.1, 1.0);
|
||||
@@ -415,8 +371,6 @@ export default function page() {
|
||||
const [previousVolume, setPreviousVolume] = useState<number | null>(null);
|
||||
|
||||
const toggleMuteCb = useCallback(async () => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
try {
|
||||
const { volume: currentVolume } = await VolumeManager.getVolume();
|
||||
const currentVolumePercent = currentVolume * 100;
|
||||
@@ -437,10 +391,7 @@ export default function page() {
|
||||
console.error("Error toggling mute:", error);
|
||||
}
|
||||
}, [previousVolume]);
|
||||
|
||||
const volumeDownCb = useCallback(async () => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
try {
|
||||
const { volume: currentVolume } = await VolumeManager.getVolume();
|
||||
const newVolume = Math.max(currentVolume - 0.1, 0); // Decrease by 10%
|
||||
@@ -457,8 +408,6 @@ export default function page() {
|
||||
}, []);
|
||||
|
||||
const setVolumeCb = useCallback(async (newVolume: number) => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
try {
|
||||
const clampedVolume = Math.max(0, Math.min(newVolume, 100));
|
||||
console.log("Setting volume to", clampedVolume);
|
||||
@@ -484,33 +433,15 @@ export default function page() {
|
||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||
if (state === "Playing") {
|
||||
setIsPlaying(true);
|
||||
if (item?.Id) {
|
||||
playbackManager.reportPlaybackProgress(
|
||||
item.Id,
|
||||
msToTicks(progress.get()),
|
||||
{
|
||||
AudioStreamIndex: audioIndex ?? -1,
|
||||
SubtitleStreamIndex: subtitleIndex ?? -1,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!Platform.isTV) await activateKeepAwakeAsync();
|
||||
reportPlaybackProgress();
|
||||
await activateKeepAwakeAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === "Paused") {
|
||||
setIsPlaying(false);
|
||||
if (item?.Id) {
|
||||
playbackManager.reportPlaybackProgress(
|
||||
item.Id,
|
||||
msToTicks(progress.get()),
|
||||
{
|
||||
AudioStreamIndex: audioIndex ?? -1,
|
||||
SubtitleStreamIndex: subtitleIndex ?? -1,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!Platform.isTV) await deactivateKeepAwake();
|
||||
reportPlaybackProgress();
|
||||
await deactivateKeepAwake();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -521,7 +452,7 @@ export default function page() {
|
||||
setIsBuffering(true);
|
||||
}
|
||||
},
|
||||
[playbackManager, item?.Id, progress],
|
||||
[reportPlaybackProgress],
|
||||
);
|
||||
|
||||
const allAudio =
|
||||
@@ -539,29 +470,25 @@ export default function page() {
|
||||
.filter((sub: any) => sub.DeliveryMethod === "External")
|
||||
.map((sub: any) => ({
|
||||
name: sub.DisplayTitle,
|
||||
DeliveryUrl: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl,
|
||||
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
|
||||
}));
|
||||
/** The text based subtitle tracks */
|
||||
|
||||
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
||||
/** The user chosen subtitle track from the server */
|
||||
|
||||
const chosenSubtitleTrack = allSubs.find(
|
||||
(sub) => sub.Index === subtitleIndex,
|
||||
);
|
||||
/** The user chosen audio track from the server */
|
||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||
/** Whether the stream we're playing is not transcoding*/
|
||||
|
||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||
/** The initial options to pass to the VLC Player */
|
||||
const initOptions = [``];
|
||||
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||
if (
|
||||
chosenSubtitleTrack &&
|
||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
||||
) {
|
||||
// If not transcoding, we can the index as normal.
|
||||
// If transcoding, we need to reverse the text based subtitles, because VLC reverses the HLS subtitles.
|
||||
const finalIndex = notTranscoding
|
||||
? allSubs.indexOf(chosenSubtitleTrack)
|
||||
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
|
||||
: textSubs.indexOf(chosenSubtitleTrack);
|
||||
initOptions.push(`--sub-track=${finalIndex}`);
|
||||
}
|
||||
|
||||
@@ -577,66 +504,7 @@ export default function page() {
|
||||
return () => setIsMounted(false);
|
||||
}, []);
|
||||
|
||||
// Memoize video ref functions to prevent unnecessary re-renders
|
||||
const startPictureInPicture = useMemo(
|
||||
() => videoRef.current?.startPictureInPicture,
|
||||
[isVideoLoaded],
|
||||
);
|
||||
const play = useMemo(
|
||||
() => videoRef.current?.play || (() => {}),
|
||||
[isVideoLoaded],
|
||||
);
|
||||
const pause = useMemo(
|
||||
() => videoRef.current?.pause || (() => {}),
|
||||
[isVideoLoaded],
|
||||
);
|
||||
const seek = useMemo(
|
||||
() => videoRef.current?.seekTo || (() => {}),
|
||||
[isVideoLoaded],
|
||||
);
|
||||
const getAudioTracks = useMemo(
|
||||
() => videoRef.current?.getAudioTracks,
|
||||
[isVideoLoaded],
|
||||
);
|
||||
const getSubtitleTracks = useMemo(
|
||||
() => videoRef.current?.getSubtitleTracks,
|
||||
[isVideoLoaded],
|
||||
);
|
||||
const setSubtitleTrack = useMemo(
|
||||
() => videoRef.current?.setSubtitleTrack,
|
||||
[isVideoLoaded],
|
||||
);
|
||||
const setSubtitleURL = useMemo(
|
||||
() => videoRef.current?.setSubtitleURL,
|
||||
[isVideoLoaded],
|
||||
);
|
||||
const setAudioTrack = useMemo(
|
||||
() => videoRef.current?.setAudioTrack,
|
||||
[isVideoLoaded],
|
||||
);
|
||||
const setVideoAspectRatio = useMemo(
|
||||
() => videoRef.current?.setVideoAspectRatio,
|
||||
[isVideoLoaded],
|
||||
);
|
||||
const setVideoScaleFactor = useMemo(
|
||||
() => videoRef.current?.setVideoScaleFactor,
|
||||
[isVideoLoaded],
|
||||
);
|
||||
|
||||
console.log("Debug: component render"); // Uncomment to debug re-renders
|
||||
|
||||
// Show error UI first, before checking loading/missing‐data
|
||||
if (itemStatus.isError || streamStatus.isError) {
|
||||
return (
|
||||
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
|
||||
<Text className='text-white'>{t("player.error")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Then show loader while either side is still fetching or data isn’t present
|
||||
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
|
||||
// …loader UI…
|
||||
if (itemStatus.isLoading || streamStatus.isLoading) {
|
||||
return (
|
||||
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
|
||||
<Loader />
|
||||
@@ -652,14 +520,7 @@ export default function page() {
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "black",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
||||
<View
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -668,6 +529,8 @@ export default function page() {
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
paddingLeft: ignoreSafeAreas ? 0 : insets.left,
|
||||
paddingRight: ignoreSafeAreas ? 0 : insets.right,
|
||||
}}
|
||||
>
|
||||
<VlcPlayerView
|
||||
@@ -675,7 +538,7 @@ export default function page() {
|
||||
source={{
|
||||
uri: stream?.url || "",
|
||||
autoplay: true,
|
||||
isNetwork: !offline,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
externalSubtitles,
|
||||
initOptions,
|
||||
@@ -698,7 +561,7 @@ export default function page() {
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
{!isPipStarted && isMounted === true && item && (
|
||||
{videoRef.current && !isPipStarted && isMounted === true && item ? (
|
||||
<Controls
|
||||
mediaSource={stream?.mediaSource}
|
||||
item={item}
|
||||
@@ -711,27 +574,23 @@ export default function page() {
|
||||
isBuffering={isBuffering}
|
||||
showControls={showControls}
|
||||
setShowControls={setShowControls}
|
||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||
ignoreSafeAreas={ignoreSafeAreas}
|
||||
isVideoLoaded={isVideoLoaded}
|
||||
startPictureInPicture={startPictureInPicture}
|
||||
play={play}
|
||||
pause={pause}
|
||||
seek={seek}
|
||||
startPictureInPicture={videoRef?.current?.startPictureInPicture}
|
||||
play={videoRef.current?.play}
|
||||
pause={videoRef.current?.pause}
|
||||
seek={videoRef.current?.seekTo}
|
||||
enableTrickplay={true}
|
||||
getAudioTracks={getAudioTracks}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
getAudioTracks={videoRef.current?.getAudioTracks}
|
||||
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
||||
offline={offline}
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
setAudioTrack={setAudioTrack}
|
||||
setVideoAspectRatio={setVideoAspectRatio}
|
||||
setVideoScaleFactor={setVideoScaleFactor}
|
||||
aspectRatio={aspectRatio}
|
||||
scaleFactor={scaleFactor}
|
||||
setAspectRatio={setAspectRatio}
|
||||
setScaleFactor={setScaleFactor}
|
||||
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
||||
setAudioTrack={videoRef.current.setAudioTrack}
|
||||
isVlc
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ScrollViewStyleReset } from "expo-router/html";
|
||||
import { type PropsWithChildren } from "react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
/**
|
||||
* This file is web-only and used to configure the root HTML for every web page during static rendering.
|
||||
|
||||
401
app/_layout.tsx
@@ -1,6 +1,7 @@
|
||||
import "@/augmentations";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Platform } from "react-native";
|
||||
import i18n from "@/i18n";
|
||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
getTokenFromStorage,
|
||||
JellyfinProvider,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
import { type Settings, useSettings } from "@/utils/atoms/settings";
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
writeToLog,
|
||||
} from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
||||
|
||||
const BackGroundDownloader = !Platform.isTV
|
||||
? require("@kesha-antonov/react-native-background-downloader")
|
||||
@@ -33,10 +36,6 @@ const BackGroundDownloader = !Platform.isTV
|
||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const BackgroundFetch = !Platform.isTV
|
||||
? require("expo-background-fetch")
|
||||
: null;
|
||||
|
||||
import * as Device from "expo-device";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
|
||||
@@ -46,7 +45,7 @@ import { router, Stack, useSegments } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
|
||||
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
|
||||
import * as TaskManager from "expo-task-manager";
|
||||
|
||||
import { getLocales } from "expo-localization";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
@@ -88,9 +87,9 @@ SplashScreen.setOptions({
|
||||
});
|
||||
|
||||
function useNotificationObserver() {
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) return;
|
||||
if (Platform.isTV) return;
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
function redirect(notification: typeof Notifications.Notification) {
|
||||
@@ -127,7 +126,9 @@ if (!Platform.isTV) {
|
||||
console.log("TaskManager ~ sessions trigger");
|
||||
|
||||
const api = store.get(apiAtom);
|
||||
if (api === null || api === undefined) return;
|
||||
if (api === null || api === undefined) {
|
||||
return { value: null };
|
||||
}
|
||||
|
||||
const response = await getSessionApi(api).getSessions({
|
||||
activeWithinSeconds: 360,
|
||||
@@ -136,30 +137,99 @@ if (!Platform.isTV) {
|
||||
const result = response.data.filter((s) => s.NowPlayingItem);
|
||||
Notifications.setBadgeCountAsync(result.length);
|
||||
|
||||
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||
return { value: "success" };
|
||||
});
|
||||
|
||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||
console.log("TaskManager ~ trigger");
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const settingsData = storage.getString("settings");
|
||||
|
||||
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
if (!settingsData) return { value: null };
|
||||
|
||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||
const url = settings?.optimizedVersionsServerUrl;
|
||||
|
||||
if (!settings?.autoDownload)
|
||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
if (!settings?.autoDownload || !url) return { value: null };
|
||||
|
||||
const token = getTokenFromStorage();
|
||||
const deviceId = getOrSetDeviceId();
|
||||
const baseDirectory = FileSystem.documentDirectory;
|
||||
|
||||
if (!token || !deviceId || !baseDirectory)
|
||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
if (!token || !deviceId || !baseDirectory) return { value: null };
|
||||
|
||||
const jobs = await getAllJobsByDeviceId({
|
||||
deviceId,
|
||||
authHeader: token,
|
||||
url,
|
||||
});
|
||||
|
||||
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job.status === "completed") {
|
||||
const downloadUrl = `${url}download/${job.id}`;
|
||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||
|
||||
if (tasks.find((task: { id: string }) => task.id === job.id)) {
|
||||
console.log("TaskManager ~ Download already in progress: ", job.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
BackGroundDownloader.download({
|
||||
id: job.id,
|
||||
url: downloadUrl,
|
||||
destination: `${baseDirectory}${job.item.Id}.mp4`,
|
||||
headers: {
|
||||
Authorization: token,
|
||||
},
|
||||
})
|
||||
.begin(() => {
|
||||
console.log("TaskManager ~ Download started: ", job.id);
|
||||
})
|
||||
.done(() => {
|
||||
console.log("TaskManager ~ Download completed: ", job.id);
|
||||
_saveDownloadedItemInfo(job.item);
|
||||
BackGroundDownloader.completeHandler(job.id);
|
||||
cancelJobById({
|
||||
authHeader: token,
|
||||
id: job.id,
|
||||
url: url,
|
||||
});
|
||||
Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: job.item.Name,
|
||||
body: "Download completed",
|
||||
data: {
|
||||
url: "/downloads",
|
||||
},
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
})
|
||||
.error((error: any) => {
|
||||
console.log("TaskManager ~ Download error: ", job.id, error);
|
||||
BackGroundDownloader.completeHandler(job.id);
|
||||
Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: job.item.Name,
|
||||
body: "Download failed",
|
||||
data: {
|
||||
url: "/downloads",
|
||||
},
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
||||
|
||||
// Be sure to return the successful result type!
|
||||
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||
return { value: "success" };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -235,51 +305,51 @@ function Layout() {
|
||||
);
|
||||
}, [settings?.preferedLanguage, i18n]);
|
||||
|
||||
useNotificationObserver();
|
||||
if (!Platform.isTV) {
|
||||
useNotificationObserver();
|
||||
|
||||
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>();
|
||||
const notificationListener = useRef<EventSubscription>();
|
||||
const responseListener = useRef<EventSubscription>();
|
||||
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>();
|
||||
const notificationListener = useRef<EventSubscription>();
|
||||
const responseListener = useRef<EventSubscription>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV && expoPushToken && api && user) {
|
||||
api
|
||||
?.post("/Streamyfin/device", {
|
||||
token: expoPushToken.data,
|
||||
deviceId: getOrSetDeviceId(),
|
||||
userId: user.Id,
|
||||
})
|
||||
.then((_) => console.log("Posted expo push token"))
|
||||
.catch((_) =>
|
||||
writeErrorLog("Failed to push expo push token to plugin"),
|
||||
);
|
||||
} else console.log("No token available");
|
||||
}, [api, expoPushToken, user]);
|
||||
useEffect(() => {
|
||||
if (expoPushToken && api && user) {
|
||||
api
|
||||
?.post("/Streamyfin/device", {
|
||||
token: expoPushToken.data,
|
||||
deviceId: getOrSetDeviceId(),
|
||||
userId: user.Id,
|
||||
})
|
||||
.then((_) => console.log("Posted expo push token"))
|
||||
.catch((_) =>
|
||||
writeErrorLog("Failed to push expo push token to plugin"),
|
||||
);
|
||||
} else console.log("No token available");
|
||||
}, [api, expoPushToken, user]);
|
||||
|
||||
async function registerNotifications() {
|
||||
if (Platform.OS === "android") {
|
||||
console.log("Setting android notification channel 'default'");
|
||||
await Notifications?.setNotificationChannelAsync("default", {
|
||||
name: "default",
|
||||
});
|
||||
async function registerNotifications() {
|
||||
if (Platform.OS === "android") {
|
||||
console.log("Setting android notification channel 'default'");
|
||||
await Notifications?.setNotificationChannelAsync("default", {
|
||||
name: "default",
|
||||
});
|
||||
}
|
||||
|
||||
await checkAndRequestPermissions();
|
||||
|
||||
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
|
||||
await registerBackgroundFetchAsyncSessions();
|
||||
}
|
||||
|
||||
// only create push token for real devices (pointless for emulators)
|
||||
if (Device.isDevice) {
|
||||
Notifications?.getExpoPushTokenAsync()
|
||||
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
|
||||
.catch((reason: any) => console.log("Failed to get token", reason));
|
||||
}
|
||||
}
|
||||
|
||||
await checkAndRequestPermissions();
|
||||
|
||||
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
|
||||
await registerBackgroundFetchAsyncSessions();
|
||||
}
|
||||
|
||||
// only create push token for real devices (pointless for emulators)
|
||||
if (Device.isDevice) {
|
||||
Notifications?.getExpoPushTokenAsync()
|
||||
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
|
||||
.catch((reason: any) => console.log("Failed to get token", reason));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV) {
|
||||
useEffect(() => {
|
||||
registerNotifications();
|
||||
|
||||
notificationListener.current =
|
||||
@@ -297,10 +367,12 @@ function Layout() {
|
||||
(response: NotificationResponse) => {
|
||||
// Currently the notifications supported by the plugin will send data for deep links.
|
||||
const { title, data } = response.notification.request.content;
|
||||
|
||||
writeDebugLog(
|
||||
`Notification ${title} opened`,
|
||||
response.notification.request.content,
|
||||
);
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
const type = data?.type?.toLower?.();
|
||||
const itemId = data?.id;
|
||||
@@ -313,10 +385,12 @@ function Layout() {
|
||||
// We just clicked a notification for an individual episode.
|
||||
if (itemId) {
|
||||
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
|
||||
// summarized season notification for multiple episodes. Bring them to series season
|
||||
} else {
|
||||
}
|
||||
// summarized season notification for multiple episodes. Bring them to series season
|
||||
else {
|
||||
const seriesId = data.seriesId;
|
||||
const seasonIndex = data.seasonIndex;
|
||||
|
||||
if (seasonIndex) {
|
||||
router.push(
|
||||
`/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`,
|
||||
@@ -341,50 +415,48 @@ function Layout() {
|
||||
responseListener.current,
|
||||
);
|
||||
};
|
||||
}
|
||||
}, [user, api]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (segments.includes("direct-player" as never)) {
|
||||
if (
|
||||
!settings.followDeviceOrientation &&
|
||||
settings.defaultVideoOrientation
|
||||
) {
|
||||
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.followDeviceOrientation === true) {
|
||||
ScreenOrientation.unlockAsync();
|
||||
} else {
|
||||
ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
settings.followDeviceOrientation,
|
||||
settings.defaultVideoOrientation,
|
||||
segments,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (segments.includes("direct-player" as never)) {
|
||||
if (
|
||||
!settings.followDeviceOrientation &&
|
||||
settings.defaultVideoOrientation
|
||||
) {
|
||||
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.followDeviceOrientation === true) {
|
||||
ScreenOrientation.unlockAsync();
|
||||
} else {
|
||||
ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
settings.followDeviceOrientation,
|
||||
settings.defaultVideoOrientation,
|
||||
segments,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||
if (
|
||||
appState.current.match(/inactive|background/) &&
|
||||
nextAppState === "active"
|
||||
) {
|
||||
BackGroundDownloader.checkForExistingDownloads();
|
||||
}
|
||||
});
|
||||
const subscription = AppState.addEventListener(
|
||||
"change",
|
||||
(nextAppState) => {
|
||||
if (
|
||||
appState.current.match(/inactive|background/) &&
|
||||
nextAppState === "active"
|
||||
) {
|
||||
BackGroundDownloader.checkForExistingDownloads();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
BackGroundDownloader.checkForExistingDownloads();
|
||||
|
||||
@@ -395,62 +467,85 @@ function Layout() {
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JellyfinProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
<JobQueueProvider>
|
||||
<JellyfinProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<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='login'
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name='+not-found' />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/player'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='login'
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name='+not-found' />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</JellyfinProvider>
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</JellyfinProvider>
|
||||
</JobQueueProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function _saveDownloadedItemInfo(item: BaseItemDto) {
|
||||
try {
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
const items: BaseItemDto[] = downloadedItems
|
||||
? JSON.parse(downloadedItems)
|
||||
: [];
|
||||
|
||||
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
|
||||
if (existingItemIndex !== -1) {
|
||||
items[existingItemIndex] = item;
|
||||
} else {
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
storage.set("downloadedItems", JSON.stringify(items));
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Failed to save downloaded item information:", error);
|
||||
console.error("Failed to save downloaded item information:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +291,7 @@ const Login: React.FC = () => {
|
||||
marginLeft: -23,
|
||||
marginBottom: -20,
|
||||
}}
|
||||
source={require("@/assets/images/icon-ios-plain.png")}
|
||||
source={require("@/assets/images/StreamyFinFinal.png")}
|
||||
/>
|
||||
<Text className='text-3xl font-bold'>Streamyfin</Text>
|
||||
<Text className='text-neutral-500'>
|
||||
|
||||
BIN
assets/images/StreamyFinFinal.png
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
assets/images/adaptive_icon.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 28 KiB |
BIN
assets/images/icon-mono.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 49 KiB |
@@ -7,27 +7,15 @@ declare module "react-native-mmkv" {
|
||||
}
|
||||
}
|
||||
|
||||
// Add the augmentation methods directly to the MMKV prototype
|
||||
// This follows the recommended pattern while adding the helper methods your app uses
|
||||
MMKV.prototype.get = function <T>(key: string): T | undefined {
|
||||
try {
|
||||
const serializedItem = this.getString(key);
|
||||
if (!serializedItem) return undefined;
|
||||
return JSON.parse(serializedItem);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse MMKV value for key "${key}":`, error);
|
||||
return undefined;
|
||||
}
|
||||
const serializedItem = this.getString(key);
|
||||
return serializedItem ? JSON.parse(serializedItem) : undefined;
|
||||
};
|
||||
|
||||
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
||||
try {
|
||||
if (value === undefined) {
|
||||
this.delete(key);
|
||||
} else {
|
||||
this.set(key, JSON.stringify(value));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to set MMKV value for key "${key}":`, error);
|
||||
if (value === undefined) {
|
||||
this.delete(key);
|
||||
} else {
|
||||
this.set(key, JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
18
biome.json
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.0.5/schema.json",
|
||||
"files": {
|
||||
"includes": [
|
||||
"**/*",
|
||||
"!node_modules",
|
||||
"!ios",
|
||||
"!android",
|
||||
"!Streamyfin.app",
|
||||
"!utils/jellyseerr",
|
||||
"!.expo"
|
||||
"!node_modules/**",
|
||||
"!ios/**",
|
||||
"!android/**",
|
||||
"!Streamyfin.app/**",
|
||||
"!utils/jellyseerr/**",
|
||||
"!.expo/**"
|
||||
]
|
||||
},
|
||||
"linter": {
|
||||
@@ -24,9 +24,7 @@
|
||||
"noForEach": "off"
|
||||
},
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "off"
|
||||
},
|
||||
"correctness": { "useExhaustiveDependencies": "off" },
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noArrayIndexKey": "off"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
const DropdownMenu = require("zeego/dropdown-menu");
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "./common/Text";
|
||||
@@ -19,8 +19,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
|
||||
const audioStreams = useMemo(
|
||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||
[source],
|
||||
@@ -33,8 +31,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isTv) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
className='flex shrink'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
const DropdownMenu = require("zeego/dropdown-menu");
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -60,26 +60,22 @@ export const BitrateSelector: React.FC<Props> = ({
|
||||
inverted,
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (inverted)
|
||||
return BITRATES.slice().sort(
|
||||
return BITRATES.sort(
|
||||
(a, b) =>
|
||||
(a.value || Number.POSITIVE_INFINITY) -
|
||||
(b.value || Number.POSITIVE_INFINITY),
|
||||
);
|
||||
return BITRATES.slice().sort(
|
||||
return BITRATES.sort(
|
||||
(a, b) =>
|
||||
(b.value || Number.POSITIVE_INFINITY) -
|
||||
(a.value || Number.POSITIVE_INFINITY),
|
||||
);
|
||||
}, [inverted]);
|
||||
}, []);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isTv) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
className='flex shrink'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { Platform, type ViewProps } from "react-native";
|
||||
import GoogleCast, {
|
||||
CastButton,
|
||||
CastContext,
|
||||
@@ -11,6 +11,12 @@ import GoogleCast, {
|
||||
} from "react-native-google-cast";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
background?: "blur" | "transparent";
|
||||
}
|
||||
|
||||
export function Chromecast({
|
||||
width = 48,
|
||||
height = 48,
|
||||
@@ -38,7 +44,11 @@ export function Chromecast({
|
||||
// Android requires the cast button to be present for startDiscovery to work
|
||||
const AndroidCastButton = useCallback(
|
||||
() =>
|
||||
Platform.OS === "android" ? <CastButton tintColor='transparent' /> : null,
|
||||
Platform.OS === "android" ? (
|
||||
<CastButton tintColor='transparent' />
|
||||
) : (
|
||||
<></>
|
||||
),
|
||||
[Platform.OS],
|
||||
);
|
||||
|
||||
|
||||
1
components/ContextMenu.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "zeego/context-menu";
|
||||
@@ -6,7 +6,6 @@ import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { ProgressBar } from "./common/ProgressBar";
|
||||
import { WatchedIndicator } from "./WatchedIndicator";
|
||||
|
||||
type ContinueWatchingPosterProps = {
|
||||
@@ -63,6 +62,18 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
}, [item]);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
if (item.Type === "Program") {
|
||||
const startDate = new Date(item.StartDate || "");
|
||||
const endDate = new Date(item.EndDate || "");
|
||||
const now = new Date();
|
||||
const total = endDate.getTime() - startDate.getTime();
|
||||
const elapsed = now.getTime() - startDate.getTime();
|
||||
return (elapsed / total) * 100;
|
||||
}
|
||||
return item.UserData?.PlayedPercentage || 0;
|
||||
}, [item]);
|
||||
|
||||
if (!url)
|
||||
return <View className='aspect-video border border-neutral-800 w-44' />;
|
||||
|
||||
@@ -90,8 +101,22 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{!item.UserData?.Played && <WatchedIndicator item={item} />}
|
||||
<ProgressBar item={item} />
|
||||
{!progress && <WatchedIndicator item={item} />}
|
||||
{progress > 0 && (
|
||||
<>
|
||||
<View
|
||||
className={
|
||||
"absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full"
|
||||
}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
}}
|
||||
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,20 +9,21 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { type Href, router } from "expo-router";
|
||||
import { type Href, router, useFocusEffect } 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 { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Platform, View, type ViewProps } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getDownloadUrl } from "@/utils/jellyfin/media/getDownloadUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
||||
import download from "@/utils/profiles/download";
|
||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||
import { type Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||
import { Button } from "./Button";
|
||||
@@ -33,13 +34,6 @@ import ProgressCircle from "./ProgressCircle";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||
|
||||
export type SelectedOptions = {
|
||||
bitrate: Bitrate;
|
||||
mediaSource: MediaSourceInfo | undefined;
|
||||
audioIndex: number | undefined;
|
||||
subtitleIndex: number;
|
||||
};
|
||||
|
||||
interface DownloadProps extends ViewProps {
|
||||
items: BaseItemDto[];
|
||||
MissingDownloadIconComponent: () => React.ReactElement;
|
||||
@@ -60,29 +54,33 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [queue, _setQueue] = useAtom(queueAtom);
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const [settings] = useSettings();
|
||||
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
|
||||
|
||||
const { processes, startBackgroundDownload, getDownloadedItems } =
|
||||
useDownload();
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
|
||||
//const { startRemuxing } = useRemuxHlsToMp4();
|
||||
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
SelectedOptions | undefined
|
||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
||||
MediaSourceInfo | undefined | null
|
||||
>(undefined);
|
||||
|
||||
const {
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(items[0], settings);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(
|
||||
settings?.defaultBitrate ?? {
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const userCanDownload = useMemo(
|
||||
() => user?.Policy?.EnableContentDownloading,
|
||||
[user],
|
||||
);
|
||||
const usingOptimizedServer = useMemo(
|
||||
() => settings?.downloadMethod === DownloadMethod.Optimized,
|
||||
[settings],
|
||||
);
|
||||
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
@@ -104,28 +102,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
[items, downloadedFiles],
|
||||
);
|
||||
|
||||
// Initialize selectedOptions with default values
|
||||
useEffect(() => {
|
||||
setSelectedOptions(() => ({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
}));
|
||||
}, [
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultSubtitleIndex,
|
||||
defaultMediaSource,
|
||||
]);
|
||||
|
||||
const itemsToDownload = useMemo(() => {
|
||||
if (downloadUnwatchedOnly) {
|
||||
return itemsNotDownloaded.filter((item) => !item.UserData?.Played);
|
||||
}
|
||||
return itemsNotDownloaded;
|
||||
}, [itemsNotDownloaded, downloadUnwatchedOnly]);
|
||||
|
||||
const allItemsDownloaded = useMemo(() => {
|
||||
if (items.length === 0) return false;
|
||||
return itemsNotDownloaded.length === 0;
|
||||
@@ -168,98 +144,99 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const acceptDownloadOptions = useCallback(() => {
|
||||
if (userCanDownload === true) {
|
||||
if (itemsNotDownloaded.some((i) => !i.Id)) {
|
||||
throw new Error("No item id");
|
||||
}
|
||||
closeModal();
|
||||
|
||||
initiateDownload(...itemsNotDownloaded);
|
||||
} else {
|
||||
toast.error(
|
||||
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
||||
);
|
||||
}
|
||||
}, [
|
||||
queue,
|
||||
setQueue,
|
||||
itemsNotDownloaded,
|
||||
usingOptimizedServer,
|
||||
userCanDownload,
|
||||
maxBitrate,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
]);
|
||||
|
||||
const initiateDownload = useCallback(
|
||||
async (...items: BaseItemDto[]) => {
|
||||
if (
|
||||
!api ||
|
||||
!user?.Id ||
|
||||
items.some((p) => !p.Id) ||
|
||||
(itemsNotDownloaded.length === 1 && !selectedOptions?.mediaSource?.Id)
|
||||
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
|
||||
) {
|
||||
throw new Error(
|
||||
"DownloadItem ~ initiateDownload: No api or user or item",
|
||||
);
|
||||
}
|
||||
const downloadDetailsPromises = items.map(async (item) => {
|
||||
const { mediaSource, audioIndex, subtitleIndex } =
|
||||
itemsNotDownloaded.length > 1
|
||||
? getDefaultPlaySettings(item, settings!)
|
||||
: {
|
||||
mediaSource: selectedOptions?.mediaSource,
|
||||
audioIndex: selectedOptions?.audioIndex,
|
||||
subtitleIndex: selectedOptions?.subtitleIndex,
|
||||
};
|
||||
let mediaSource = selectedMediaSource;
|
||||
let audioIndex: number | undefined = selectedAudioStream;
|
||||
let subtitleIndex: number | undefined = selectedSubtitleStream;
|
||||
|
||||
const downloadDetails = await getDownloadUrl({
|
||||
for (const item of items) {
|
||||
if (itemsNotDownloaded.length > 1) {
|
||||
const defaults = getDefaultPlaySettings(item, settings!);
|
||||
mediaSource = defaults.mediaSource;
|
||||
audioIndex = defaults.audioIndex;
|
||||
subtitleIndex = defaults.subtitleIndex;
|
||||
}
|
||||
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
userId: user.Id!,
|
||||
mediaSource: mediaSource!,
|
||||
audioStreamIndex: audioIndex ?? -1,
|
||||
subtitleStreamIndex: subtitleIndex ?? -1,
|
||||
maxBitrate: selectedOptions?.bitrate || defaultBitrate,
|
||||
deviceId: api.deviceInfo.id,
|
||||
startTimeTicks: 0,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: audioIndex,
|
||||
maxStreamingBitrate: maxBitrate.value,
|
||||
mediaSourceId: mediaSource?.Id,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: download,
|
||||
download: true,
|
||||
// deviceId: mediaSource?.Id,
|
||||
});
|
||||
|
||||
return {
|
||||
url: downloadDetails?.url,
|
||||
item,
|
||||
mediaSource: downloadDetails?.mediaSource,
|
||||
};
|
||||
});
|
||||
|
||||
const downloadDetails = await Promise.all(downloadDetailsPromises);
|
||||
for (const { url, item, mediaSource } of downloadDetails) {
|
||||
if (!url) {
|
||||
if (!res) {
|
||||
Alert.alert(
|
||||
t("home.downloads.something_went_wrong"),
|
||||
t("home.downloads.could_not_get_stream_url_from_jellyfin"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!mediaSource) {
|
||||
console.error(`Could not get download URL for ${item.Name}`);
|
||||
toast.error(
|
||||
t("Could not get download URL for {{itemName}}", {
|
||||
itemName: item.Name,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await startBackgroundDownload(
|
||||
url,
|
||||
item,
|
||||
mediaSource,
|
||||
selectedOptions?.bitrate || defaultBitrate,
|
||||
);
|
||||
|
||||
const { mediaSource: source, url } = res;
|
||||
|
||||
if (!url || !source) throw new Error("No url");
|
||||
|
||||
saveDownloadItemInfoToDiskTmp(item, source, url);
|
||||
await startBackgroundDownload(url, item, source, maxBitrate);
|
||||
}
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
itemsNotDownloaded,
|
||||
selectedOptions,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
settings,
|
||||
defaultBitrate,
|
||||
maxBitrate,
|
||||
usingOptimizedServer,
|
||||
startBackgroundDownload,
|
||||
],
|
||||
);
|
||||
|
||||
const acceptDownloadOptions = useCallback(() => {
|
||||
if (userCanDownload === true) {
|
||||
if (itemsToDownload.some((i) => !i.Id)) {
|
||||
throw new Error("No item id");
|
||||
}
|
||||
closeModal();
|
||||
|
||||
initiateDownload(...itemsToDownload);
|
||||
} else {
|
||||
toast.error(
|
||||
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
||||
);
|
||||
}
|
||||
}, [closeModal, initiateDownload, itemsToDownload, userCanDownload]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
@@ -270,6 +247,19 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
),
|
||||
[],
|
||||
);
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!settings) return;
|
||||
if (itemsNotDownloaded.length !== 1) return;
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(items[0], settings);
|
||||
|
||||
setSelectedMediaSource(mediaSource ?? undefined);
|
||||
setSelectedAudioStream(audioIndex ?? 0);
|
||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||
setMaxBitrate(bitrate);
|
||||
}, [items, itemsNotDownloaded, settings]),
|
||||
);
|
||||
|
||||
const renderButtonContent = () => {
|
||||
if (processes.length > 0 && itemsProcesses.length > 0) {
|
||||
@@ -337,78 +327,40 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
<Text className='text-neutral-300'>
|
||||
{subtitle ||
|
||||
t("item_card.download.download_x_item", {
|
||||
item_count: itemsToDownload.length,
|
||||
item_count: itemsNotDownloaded.length,
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='flex flex-col space-y-2 w-full items-start'>
|
||||
<BitrateSelector
|
||||
inverted
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) => prev && { ...prev, bitrate: val },
|
||||
)
|
||||
}
|
||||
selected={selectedOptions?.bitrate}
|
||||
onChange={setMaxBitrate}
|
||||
selected={maxBitrate}
|
||||
/>
|
||||
{itemsNotDownloaded.length > 1 && (
|
||||
<View className='flex flex-row items-center justify-between w-full py-2'>
|
||||
<Text>{t("item_card.download.download_unwatched_only")}</Text>
|
||||
<Switch
|
||||
onValueChange={setDownloadUnwatchedOnly}
|
||||
value={downloadUnwatchedOnly}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{itemsNotDownloaded.length === 1 && (
|
||||
<View>
|
||||
<>
|
||||
<MediaSourceSelector
|
||||
item={items[0]}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
mediaSource: val,
|
||||
},
|
||||
)
|
||||
}
|
||||
selected={selectedOptions?.mediaSource}
|
||||
onChange={setSelectedMediaSource}
|
||||
selected={selectedMediaSource}
|
||||
/>
|
||||
{selectedOptions?.mediaSource && (
|
||||
{selectedMediaSource && (
|
||||
<View className='flex flex-col space-y-2'>
|
||||
<AudioTrackSelector
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
audioIndex: val,
|
||||
},
|
||||
);
|
||||
}}
|
||||
selected={selectedOptions.audioIndex}
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedAudioStream}
|
||||
selected={selectedAudioStream}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
subtitleIndex: val,
|
||||
},
|
||||
);
|
||||
}}
|
||||
selected={selectedOptions.subtitleIndex}
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedSubtitleStream}
|
||||
selected={selectedSubtitleStream}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Button
|
||||
className='mt-auto'
|
||||
onPress={acceptDownloadOptions}
|
||||
@@ -416,6 +368,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
>
|
||||
{t("item_card.download.download_button")}
|
||||
</Button>
|
||||
<View className='opacity-70 text-center w-full flex items-center'>
|
||||
<Text className='text-xs'>
|
||||
{usingOptimizedServer
|
||||
? t("item_card.download.using_optimized_server")
|
||||
: t("item_card.download.using_default_method")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Image } from "expo-image";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import { View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
@@ -36,7 +36,7 @@ import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||
|
||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||
const Chromecast = require("./Chromecast");
|
||||
|
||||
export type SelectedOptions = {
|
||||
bitrate: Bitrate;
|
||||
@@ -45,13 +45,8 @@ export type SelectedOptions = {
|
||||
subtitleIndex: number;
|
||||
};
|
||||
|
||||
interface ItemContentProps {
|
||||
item: BaseItemDto;
|
||||
isOffline: boolean;
|
||||
}
|
||||
|
||||
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
({ item, isOffline }) => {
|
||||
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
({ item }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [settings] = useSettings();
|
||||
const { orientation } = useOrientation();
|
||||
@@ -73,16 +68,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(item!, settings);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||
[api, item],
|
||||
);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(logoUrl && loadingLogo);
|
||||
}, [loadingLogo, logoUrl]);
|
||||
} = useDefaultPlaySettings(item, settings);
|
||||
|
||||
// Needs to automatically change the selected to the default values for default indexes.
|
||||
useEffect(() => {
|
||||
@@ -100,45 +86,40 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV) {
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
item && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<Chromecast.Chromecast
|
||||
background='blur'
|
||||
width={22}
|
||||
height={22}
|
||||
/>
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
{!Platform.isTV && (
|
||||
<DownloadSingleItem item={item} size='large' />
|
||||
)}
|
||||
{user?.Policy?.IsAdministrator && (
|
||||
<PlayInRemoteSessionButton item={item} size='large' />
|
||||
)}
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
item && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<Chromecast.Chromecast background='blur' width={22} height={22} />
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<DownloadSingleItem item={item} size='large' />
|
||||
{user?.Policy?.IsAdministrator && (
|
||||
<PlayInRemoteSessionButton item={item} size='large' />
|
||||
)}
|
||||
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [item, navigation, user]);
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [item]);
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||
setHeaderHeight(230);
|
||||
else if (item.Type === "Movie") setHeaderHeight(500);
|
||||
else setHeaderHeight(350);
|
||||
}
|
||||
}, [item, orientation]);
|
||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||
setHeaderHeight(230);
|
||||
else if (item.Type === "Movie") setHeaderHeight(500);
|
||||
else setHeaderHeight(350);
|
||||
}, [item.Type, orientation]);
|
||||
|
||||
if (!item || !selectedOptions) return null;
|
||||
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(logoUrl && loadingLogo);
|
||||
}, [loadingLogo, logoUrl]);
|
||||
if (!selectedOptions) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -179,15 +160,13 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
onLoad={() => setLoadingLogo(false)}
|
||||
onError={() => setLoadingLogo(false)}
|
||||
/>
|
||||
) : (
|
||||
<View />
|
||||
)
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<View className='flex flex-col bg-transparent shrink'>
|
||||
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
||||
<ItemHeader item={item} className='mb-2' />
|
||||
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
||||
<ItemHeader item={item} className='mb-4' />
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center justify-start w-full h-16'>
|
||||
<BitrateSelector
|
||||
className='mr-1'
|
||||
@@ -246,34 +225,25 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
className='grow'
|
||||
selectedOptions={selectedOptions}
|
||||
item={item}
|
||||
isOffline={isOffline}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{item.Type === "Episode" && (
|
||||
<SeasonEpisodesCarousel
|
||||
item={item}
|
||||
loading={loading}
|
||||
isOffline={isOffline}
|
||||
/>
|
||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||
)}
|
||||
|
||||
{!isOffline && (
|
||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||
)}
|
||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
||||
|
||||
{item.Type !== "Program" && (
|
||||
<>
|
||||
{item.Type === "Episode" && !isOffline && (
|
||||
{item.Type === "Episode" && (
|
||||
<CurrentSeries item={item} className='mb-4' />
|
||||
)}
|
||||
|
||||
{!isOffline && (
|
||||
<CastAndCrew item={item} className='mb-4' loading={loading} />
|
||||
)}
|
||||
<CastAndCrew item={item} className='mb-4' loading={loading} />
|
||||
|
||||
{item.People && item.People.length > 0 && !isOffline && (
|
||||
{item.People && item.People.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
{item.People.slice(0, 3).map((person, idx) => (
|
||||
<MoreMoviesWithActor
|
||||
@@ -286,7 +256,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!isOffline && <SimilarItems itemId={item.Id} />}
|
||||
<SimilarItems itemId={item.Id} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -33,16 +33,16 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||
<ItemActions item={item} />
|
||||
</View>
|
||||
{item.Type === "Episode" && (
|
||||
<View>
|
||||
<>
|
||||
<EpisodeTitleHeader item={item} />
|
||||
<GenreTags genres={item.Genres!} />
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
{item.Type === "Movie" && (
|
||||
<View>
|
||||
<>
|
||||
<MoviesTitleHeader item={item} />
|
||||
<GenreTags genres={item.Genres!} />
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -21,7 +21,7 @@ interface Props {
|
||||
source?: MediaSourceInfo;
|
||||
}
|
||||
|
||||
export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
|
||||
export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -53,7 +53,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
|
||||
>
|
||||
<BottomSheetScrollView>
|
||||
<View className='flex flex-col space-y-2 p-4 mb-4'>
|
||||
<View>
|
||||
<View className=''>
|
||||
<Text className='text-lg font-bold mb-4'>
|
||||
{t("item_card.video")}
|
||||
</Text>
|
||||
@@ -62,7 +62,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<View className=''>
|
||||
<Text className='text-lg font-bold mb-2'>
|
||||
{t("item_card.audio")}
|
||||
</Text>
|
||||
@@ -75,7 +75,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<View className=''>
|
||||
<Text className='text-lg font-bold mb-2'>
|
||||
{t("item_card.subtitles")}
|
||||
</Text>
|
||||
@@ -175,13 +175,15 @@ const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
|
||||
};
|
||||
|
||||
const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
||||
const videoStream = useMemo(() => {
|
||||
return source?.MediaStreams?.find((stream) => stream.Type === "Video") as
|
||||
| MediaStream
|
||||
| undefined;
|
||||
}, [source?.MediaStreams]);
|
||||
if (!source) return null;
|
||||
|
||||
if (!source || !videoStream) return null;
|
||||
const videoStream = useMemo(() => {
|
||||
return source.MediaStreams?.find(
|
||||
(stream) => stream.Type === "Video",
|
||||
) as MediaStream;
|
||||
}, [source.MediaStreams]);
|
||||
|
||||
if (!videoStream) return null;
|
||||
|
||||
return (
|
||||
<View className='flex-row flex-wrap gap-2'>
|
||||
@@ -219,11 +221,7 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
||||
<Badge
|
||||
variant='gray'
|
||||
iconLeft={<Ionicons name='play-outline' size={16} color='white' />}
|
||||
text={
|
||||
videoStream.AverageFrameRate != null
|
||||
? `${videoStream.AverageFrameRate.toFixed(0)} fps`
|
||||
: ""
|
||||
}
|
||||
text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -236,7 +234,6 @@ const formatFileSize = (bytes?: number | null) => {
|
||||
if (bytes === 0) return "0 Byte";
|
||||
const i = Number.parseInt(
|
||||
Math.floor(Math.log(bytes) / Math.log(1024)).toString(),
|
||||
10,
|
||||
);
|
||||
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
@@ -22,31 +22,37 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
if (Platform.isTV) return null;
|
||||
const selectedName = useMemo(
|
||||
() =>
|
||||
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
||||
(x) => x.Type === "Video",
|
||||
)?.DisplayTitle || "",
|
||||
[item, selected],
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
||||
if (videoStream?.DisplayTitle) {
|
||||
return videoStream.DisplayTitle;
|
||||
const commonPrefix = useMemo(() => {
|
||||
const mediaSources = item.MediaSources || [];
|
||||
if (!mediaSources.length) return "";
|
||||
|
||||
let commonPrefix = "";
|
||||
for (let i = 0; i < mediaSources[0].Name!.length; i++) {
|
||||
const char = mediaSources[0].Name![i];
|
||||
if (mediaSources.every((source) => source.Name![i] === char)) {
|
||||
commonPrefix += char;
|
||||
} else {
|
||||
commonPrefix = commonPrefix.slice(0, -1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return commonPrefix;
|
||||
}, [item.MediaSources]);
|
||||
|
||||
// Fallback to source name
|
||||
if (source.Name) {
|
||||
return source.Name;
|
||||
}
|
||||
|
||||
// Last resort fallback
|
||||
return `Source ${source.Id}`;
|
||||
}, []);
|
||||
|
||||
const selectedName = useMemo(() => {
|
||||
if (!selected) return "";
|
||||
return getDisplayName(selected);
|
||||
}, [selected, getDisplayName]);
|
||||
|
||||
if (isTv) return null;
|
||||
const name = (name?: string | null) => {
|
||||
return name?.replace(commonPrefix, "").toLowerCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -84,7 +90,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{getDisplayName(source)}
|
||||
{`${name(source.Name)}`}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
|
||||
@@ -38,7 +38,6 @@ import type { SelectedOptions } from "./ItemContent";
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item: BaseItemDto;
|
||||
selectedOptions: SelectedOptions;
|
||||
isOffline?: boolean;
|
||||
}
|
||||
|
||||
const ANIMATION_DURATION = 500;
|
||||
@@ -47,7 +46,6 @@ const MIN_PLAYBACK_WIDTH = 15;
|
||||
export const PlayButton: React.FC<Props> = ({
|
||||
item,
|
||||
selectedOptions,
|
||||
isOffline,
|
||||
...props
|
||||
}: Props) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
@@ -77,7 +75,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
}
|
||||
router.push(`/player/direct-player?${q}`);
|
||||
},
|
||||
[router, isOffline],
|
||||
[router],
|
||||
);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
@@ -92,8 +90,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
offline: isOffline ? "true" : "false",
|
||||
});
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
import { Ionicons, MaterialCommunityIcons } 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";
|
||||
import Animated, {
|
||||
Easing,
|
||||
interpolate,
|
||||
interpolateColor,
|
||||
useAnimatedReaction,
|
||||
useAnimatedStyle,
|
||||
useDerivedValue,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import type { Button } from "./Button";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item: BaseItemDto;
|
||||
selectedOptions: SelectedOptions;
|
||||
}
|
||||
|
||||
const ANIMATION_DURATION = 500;
|
||||
const MIN_PLAYBACK_WIDTH = 15;
|
||||
|
||||
export const PlayButton: React.FC<Props> = ({
|
||||
item,
|
||||
selectedOptions,
|
||||
...props
|
||||
}: Props) => {
|
||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const startWidth = useSharedValue(0);
|
||||
const targetWidth = useSharedValue(0);
|
||||
const endColor = useSharedValue(colorAtom);
|
||||
const startColor = useSharedValue(colorAtom);
|
||||
const widthProgress = useSharedValue(0);
|
||||
const colorChangeProgress = useSharedValue(0);
|
||||
const [settings] = useSettings();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const goToPlayer = useCallback(
|
||||
(q: string) => {
|
||||
router.push(`/player/direct-player?${q}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const onPress = () => {
|
||||
console.log("onpress");
|
||||
if (!item) return;
|
||||
|
||||
lightHapticFeedback();
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||
});
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
goToPlayer(queryString);
|
||||
return;
|
||||
};
|
||||
|
||||
const derivedTargetWidth = useDerivedValue(() => {
|
||||
if (!item || !item.RunTimeTicks) return 0;
|
||||
const userData = item.UserData;
|
||||
if (userData?.PlaybackPositionTicks) {
|
||||
return userData.PlaybackPositionTicks > 0
|
||||
? Math.max(
|
||||
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
||||
MIN_PLAYBACK_WIDTH,
|
||||
)
|
||||
: 0;
|
||||
}
|
||||
return 0;
|
||||
}, [item]);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => derivedTargetWidth.value,
|
||||
(newWidth) => {
|
||||
targetWidth.value = newWidth;
|
||||
widthProgress.value = 0;
|
||||
widthProgress.value = withTiming(1, {
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
|
||||
});
|
||||
},
|
||||
[item],
|
||||
);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => colorAtom,
|
||||
(newColor) => {
|
||||
endColor.value = newColor;
|
||||
colorChangeProgress.value = 0;
|
||||
colorChangeProgress.value = withTiming(1, {
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||
});
|
||||
},
|
||||
[colorAtom],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout_2 = setTimeout(() => {
|
||||
startColor.value = colorAtom;
|
||||
startWidth.value = targetWidth.value;
|
||||
}, ANIMATION_DURATION);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout_2);
|
||||
};
|
||||
}, [colorAtom, item]);
|
||||
|
||||
/**
|
||||
* ANIMATED STYLES
|
||||
*/
|
||||
const animatedAverageStyle = useAnimatedStyle(() => ({
|
||||
backgroundColor: interpolateColor(
|
||||
colorChangeProgress.value,
|
||||
[0, 1],
|
||||
[startColor.value.primary, endColor.value.primary],
|
||||
),
|
||||
}));
|
||||
|
||||
const animatedPrimaryStyle = useAnimatedStyle(() => ({
|
||||
backgroundColor: interpolateColor(
|
||||
colorChangeProgress.value,
|
||||
[0, 1],
|
||||
[startColor.value.primary, endColor.value.primary],
|
||||
),
|
||||
}));
|
||||
|
||||
const animatedWidthStyle = useAnimatedStyle(() => ({
|
||||
width: `${interpolate(
|
||||
widthProgress.value,
|
||||
[0, 1],
|
||||
[startWidth.value, targetWidth.value],
|
||||
)}%`,
|
||||
}));
|
||||
|
||||
const animatedTextStyle = useAnimatedStyle(() => ({
|
||||
color: interpolateColor(
|
||||
colorChangeProgress.value,
|
||||
[0, 1],
|
||||
[startColor.value.text, endColor.value.text],
|
||||
),
|
||||
}));
|
||||
/**
|
||||
* *********************
|
||||
*/
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
accessibilityLabel='Play button'
|
||||
accessibilityHint='Tap to play the media'
|
||||
onPress={onPress}
|
||||
className={"relative"}
|
||||
{...props}
|
||||
>
|
||||
<View className='absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden'>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedPrimaryStyle,
|
||||
animatedWidthStyle,
|
||||
{
|
||||
height: "100%",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Animated.View
|
||||
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||
className='absolute w-full h-full top-0 left-0 rounded-xl'
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: colorAtom.primary,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
|
||||
>
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
</Animated.Text>
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Ionicons name='play-circle' size={24} />
|
||||
</Animated.Text>
|
||||
{settings?.openInVLC && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name='vlc'
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type React from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
@@ -6,13 +7,44 @@ import { RoundButton } from "./RoundButton";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
items: BaseItemDto[];
|
||||
isOffline?: boolean;
|
||||
size?: "default" | "large";
|
||||
}
|
||||
|
||||
export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const _invalidateQueries = () => {
|
||||
items.forEach((item) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["item", item.Id],
|
||||
});
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["resumeItems"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["continueWatching"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["nextUp-all"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["nextUp"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["episodes"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["seasons"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["home"],
|
||||
});
|
||||
};
|
||||
|
||||
const allPlayed = items.every((item) => item.UserData?.Played);
|
||||
const toggle = useMarkAsPlayed(items);
|
||||
|
||||
const markAsPlayedStatus = useMarkAsPlayed(items);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
@@ -20,7 +52,8 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
||||
fillColor={allPlayed ? "primary" : undefined}
|
||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
||||
onPress={async () => {
|
||||
await toggle(!allPlayed);
|
||||
console.log(allPlayed);
|
||||
await markAsPlayedStatus(!allPlayed);
|
||||
}}
|
||||
size={props.size}
|
||||
/>
|
||||
|
||||
@@ -20,7 +20,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
if (Platform.isTV) return null;
|
||||
const subtitleStreams = useMemo(() => {
|
||||
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||
}, [source]);
|
||||
@@ -30,7 +30,9 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
[subtitleStreams, selected],
|
||||
);
|
||||
|
||||
if (Platform.isTV || subtitleStreams?.length === 0) return null;
|
||||
if (subtitleStreams?.length === 0) return null;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View
|
||||
|
||||
@@ -34,17 +34,14 @@ const Dropdown = <T,>({
|
||||
multiple = false,
|
||||
...props
|
||||
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
||||
const isTv = Platform.isTV;
|
||||
|
||||
if (Platform.isTV) return null;
|
||||
const [selected, setSelected] = useState<T[]>();
|
||||
|
||||
useEffect(() => {
|
||||
if (selected !== undefined) {
|
||||
onSelected(...selected);
|
||||
}
|
||||
}, [selected, onSelected]);
|
||||
|
||||
if (isTv) return null;
|
||||
}, [selected]);
|
||||
|
||||
return (
|
||||
<DisabledSetting disabled={disabled === true} showText={false} {...props}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FlashList, type FlashListProps } from "@shopify/flash-list";
|
||||
import React, { useImperativeHandle, useRef } from "react";
|
||||
import React, { forwardRef, useImperativeHandle, useRef } from "react";
|
||||
import { View, type ViewStyle } from "react-native";
|
||||
import { Text } from "./Text";
|
||||
|
||||
@@ -19,59 +19,64 @@ interface HorizontalScrollProps<T>
|
||||
keyExtractor?: (item: T, index: number) => string;
|
||||
containerStyle?: ViewStyle;
|
||||
contentContainerStyle?: ViewStyle;
|
||||
loadingContainerStyle?: ViewStyle;
|
||||
height?: number;
|
||||
loading?: boolean;
|
||||
extraData?: any;
|
||||
noItemsText?: string;
|
||||
}
|
||||
|
||||
export const HorizontalScroll = <T,>(
|
||||
props: HorizontalScrollProps<T> & {
|
||||
ref?: React.ForwardedRef<HorizontalScrollRef>;
|
||||
},
|
||||
) => {
|
||||
const {
|
||||
data = [],
|
||||
keyExtractor,
|
||||
renderItem,
|
||||
containerStyle,
|
||||
contentContainerStyle,
|
||||
loading = false,
|
||||
height = 164,
|
||||
extraData,
|
||||
noItemsText,
|
||||
ref,
|
||||
...restProps
|
||||
} = props;
|
||||
export const HorizontalScroll = forwardRef<
|
||||
HorizontalScrollRef,
|
||||
HorizontalScrollProps<any>
|
||||
>(
|
||||
<T,>(
|
||||
{
|
||||
data = [],
|
||||
keyExtractor,
|
||||
renderItem,
|
||||
containerStyle,
|
||||
contentContainerStyle,
|
||||
loadingContainerStyle,
|
||||
loading = false,
|
||||
height = 164,
|
||||
extraData,
|
||||
noItemsText,
|
||||
...props
|
||||
}: HorizontalScrollProps<T>,
|
||||
ref: React.ForwardedRef<HorizontalScrollRef>,
|
||||
) => {
|
||||
const flashListRef = useRef<FlashList<T>>(null);
|
||||
|
||||
const flashListRef = useRef<FlashList<T>>(null);
|
||||
useImperativeHandle(ref!, () => ({
|
||||
scrollToIndex: (index: number, viewOffset: number) => {
|
||||
flashListRef.current?.scrollToIndex({
|
||||
index,
|
||||
animated: true,
|
||||
viewPosition: 0,
|
||||
viewOffset,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
useImperativeHandle(ref!, () => ({
|
||||
scrollToIndex: (index: number, viewOffset: number) => {
|
||||
flashListRef.current?.scrollToIndex({
|
||||
index,
|
||||
animated: true,
|
||||
viewPosition: 0,
|
||||
viewOffset,
|
||||
});
|
||||
},
|
||||
}));
|
||||
const renderFlashListItem = ({
|
||||
item,
|
||||
index,
|
||||
}: {
|
||||
item: T;
|
||||
index: number;
|
||||
}) => <View className='mr-2'>{renderItem(item, index)}</View>;
|
||||
|
||||
const renderFlashListItem = ({ item, index }: { item: T; index: number }) => (
|
||||
<View className='mr-2'>{renderItem(item, index)}</View>
|
||||
);
|
||||
if (!data || loading) {
|
||||
return (
|
||||
<View className='px-4 mb-2'>
|
||||
<View className='bg-neutral-950 h-24 w-full rounded-md mb-2' />
|
||||
<View className='bg-neutral-950 h-10 w-full rounded-md mb-1' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || loading) {
|
||||
return (
|
||||
<View className='px-4 mb-2'>
|
||||
<View className='bg-neutral-950 h-24 w-full rounded-md mb-2' />
|
||||
<View className='bg-neutral-950 h-10 w-full rounded-md mb-1' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[{ height }, containerStyle]}>
|
||||
<FlashList<T>
|
||||
ref={flashListRef}
|
||||
data={data}
|
||||
@@ -92,8 +97,8 @@ export const HorizontalScroll = <T,>(
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{...restProps}
|
||||
{...props}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -56,7 +56,7 @@ export function InfiniteHorizontalScroll({
|
||||
};
|
||||
});
|
||||
|
||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey,
|
||||
queryFn,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useRouter, useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { type PropsWithChildren, useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
import * as ContextMenu from "@/components/ContextMenu";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import {
|
||||
@@ -60,67 +60,69 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
|
||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (!result) return;
|
||||
<>
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (!result) return;
|
||||
|
||||
// @ts-expect-error
|
||||
router.push({
|
||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
||||
params: {
|
||||
...result,
|
||||
mediaTitle,
|
||||
releaseYear,
|
||||
canRequest,
|
||||
posterSrc,
|
||||
mediaType,
|
||||
},
|
||||
});
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content
|
||||
avoidCollisions
|
||||
alignOffset={0}
|
||||
collisionPadding={0}
|
||||
loop={false}
|
||||
key={"content"}
|
||||
>
|
||||
<ContextMenu.Label key='label-1'>Actions</ContextMenu.Label>
|
||||
{canRequest && mediaType === MediaType.MOVIE && (
|
||||
<ContextMenu.Item
|
||||
key='item-1'
|
||||
onSelect={() => {
|
||||
if (autoApprove) {
|
||||
request();
|
||||
}
|
||||
}}
|
||||
shouldDismissMenuOnSelect
|
||||
>
|
||||
<ContextMenu.ItemTitle key='item-1-title'>
|
||||
Request
|
||||
</ContextMenu.ItemTitle>
|
||||
<ContextMenu.ItemIcon
|
||||
ios={{
|
||||
name: "arrow.down.to.line",
|
||||
pointSize: 18,
|
||||
weight: "semibold",
|
||||
scale: "medium",
|
||||
hierarchicalColor: {
|
||||
dark: "purple",
|
||||
light: "purple",
|
||||
// @ts-ignore
|
||||
router.push({
|
||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
||||
params: {
|
||||
...result,
|
||||
mediaTitle,
|
||||
releaseYear,
|
||||
canRequest,
|
||||
posterSrc,
|
||||
mediaType,
|
||||
},
|
||||
});
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content
|
||||
avoidCollisions
|
||||
alignOffset={0}
|
||||
collisionPadding={0}
|
||||
loop={false}
|
||||
key={"content"}
|
||||
>
|
||||
<ContextMenu.Label key='label-1'>Actions</ContextMenu.Label>
|
||||
{canRequest && mediaType === MediaType.MOVIE && (
|
||||
<ContextMenu.Item
|
||||
key='item-1'
|
||||
onSelect={() => {
|
||||
if (autoApprove) {
|
||||
request();
|
||||
}
|
||||
}}
|
||||
androidIconName='download'
|
||||
/>
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
shouldDismissMenuOnSelect
|
||||
>
|
||||
<ContextMenu.ItemTitle key='item-1-title'>
|
||||
Request
|
||||
</ContextMenu.ItemTitle>
|
||||
<ContextMenu.ItemIcon
|
||||
ios={{
|
||||
name: "arrow.down.to.line",
|
||||
pointSize: 18,
|
||||
weight: "semibold",
|
||||
scale: "medium",
|
||||
hierarchicalColor: {
|
||||
dark: "purple",
|
||||
light: "purple",
|
||||
},
|
||||
}}
|
||||
androidIconName='download'
|
||||
/>
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React, { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
interface ProgressBarProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const ProgressBar: React.FC<ProgressBarProps> = ({ item }) => {
|
||||
const progress = useMemo(() => {
|
||||
if (item.Type === "Program") {
|
||||
if (!item.StartDate || !item.EndDate) {
|
||||
return 0;
|
||||
}
|
||||
const startDate = new Date(item.StartDate);
|
||||
const endDate = new Date(item.EndDate);
|
||||
const now = new Date();
|
||||
const total = endDate.getTime() - startDate.getTime();
|
||||
if (total <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const elapsed = now.getTime() - startDate.getTime();
|
||||
return (elapsed / total) * 100;
|
||||
}
|
||||
return item.UserData?.PlayedPercentage || 0;
|
||||
}, [item]);
|
||||
|
||||
if (progress <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
className={
|
||||
"absolute bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full"
|
||||
}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
}}
|
||||
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Platform, Text as RNText, type TextProps } from "react-native";
|
||||
export function Text(props: TextProps) {
|
||||
import { UITextView } from "react-native-uitextview";
|
||||
export function Text(
|
||||
props: TextProps & {
|
||||
uiTextView?: boolean;
|
||||
},
|
||||
) {
|
||||
const { style, ...otherProps } = props;
|
||||
if (Platform.isTV)
|
||||
return (
|
||||
@@ -11,7 +16,7 @@ export function Text(props: TextProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<RNText
|
||||
<UITextView
|
||||
allowFontScaling={false}
|
||||
style={[{ color: "white" }, style]}
|
||||
{...otherProps}
|
||||
|
||||
@@ -11,17 +11,12 @@ import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
isOffline?: boolean;
|
||||
}
|
||||
|
||||
export const itemRouter = (
|
||||
item: BaseItemDto | BaseItemPerson,
|
||||
from: string,
|
||||
) => {
|
||||
if ("CollectionType" in item && item.CollectionType === "livetv") {
|
||||
return `/(auth)/(tabs)/${from}/livetv`;
|
||||
}
|
||||
|
||||
if (item.Type === "Series") {
|
||||
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
|
||||
}
|
||||
@@ -51,7 +46,6 @@ export const itemRouter = (
|
||||
|
||||
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
item,
|
||||
isOffline = false,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
@@ -107,10 +101,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
<TouchableOpacity
|
||||
onLongPress={showActionSheet}
|
||||
onPress={() => {
|
||||
let url = itemRouter(item, from);
|
||||
if (isOffline) {
|
||||
url += `&offline=true`;
|
||||
}
|
||||
const url = itemRouter(item, from);
|
||||
// @ts-expect-error
|
||||
router.push(url);
|
||||
}}
|
||||
@@ -119,6 +110,4 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { t } from "i18next";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
type TouchableOpacityProps,
|
||||
View,
|
||||
@@ -14,16 +15,17 @@ import {
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { JobStatus } from "@/providers/Downloads/types";
|
||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import type { JobStatus } from "@/utils/optimize-server";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
import { Button } from "../Button";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
const BackGroundDownloader = !Platform.isTV
|
||||
? require("@kesha-antonov/react-native-background-downloader")
|
||||
: null;
|
||||
|
||||
const bytesToMB = (bytes: number) => {
|
||||
return bytes / 1024 / 1024;
|
||||
};
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
||||
const { processes } = useDownload();
|
||||
@@ -58,18 +60,32 @@ interface DownloadCardProps extends TouchableOpacityProps {
|
||||
}
|
||||
|
||||
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
const { startDownload, removeProcess } = useDownload();
|
||||
const { processes, startDownload } = useDownload();
|
||||
const router = useRouter();
|
||||
const { removeProcess, setProcesses } = useDownload();
|
||||
const [settings] = useSettings();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const cancelJobMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
if (!process) throw new Error("No active download");
|
||||
removeProcess(id);
|
||||
|
||||
try {
|
||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||
for (const task of tasks) {
|
||||
if (task.id === id) {
|
||||
task.stop();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await removeProcess(id);
|
||||
if (settings?.downloadMethod === DownloadMethod.Optimized) {
|
||||
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("home.downloads.toasts.download_cancelled"));
|
||||
queryClient.invalidateQueries({ queryKey: ["downloads"] });
|
||||
},
|
||||
onError: (e) => {
|
||||
console.error(e);
|
||||
@@ -78,14 +94,11 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
});
|
||||
|
||||
const eta = (p: JobStatus) => {
|
||||
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
|
||||
if (!p.speed || !p.progress) return null;
|
||||
|
||||
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
|
||||
if (bytesRemaining <= 0) return null;
|
||||
|
||||
const secondsRemaining = bytesRemaining / p.speed;
|
||||
|
||||
return formatTimeString(secondsRemaining, "s");
|
||||
const length = p?.item?.RunTimeTicks || 0;
|
||||
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
|
||||
return formatTimeString(timeLeft, "tick");
|
||||
};
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
@@ -98,7 +111,8 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden'
|
||||
{...props}
|
||||
>
|
||||
{process.status === "downloading" && (
|
||||
{(process.status === "optimizing" ||
|
||||
process.status === "downloading") && (
|
||||
<View
|
||||
className={`
|
||||
bg-purple-600 h-1 absolute bottom-0 left-0
|
||||
@@ -138,10 +152,8 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
) : (
|
||||
<Text className='text-xs'>{process.progress.toFixed(0)}%</Text>
|
||||
)}
|
||||
{process.speed && process.speed > 0 && (
|
||||
<Text className='text-xs'>
|
||||
{bytesToMB(process.speed).toFixed(2)} MB/s
|
||||
</Text>
|
||||
{process.speed && (
|
||||
<Text className='text-xs'>{process.speed?.toFixed(2)}x</Text>
|
||||
)}
|
||||
{eta(process) && (
|
||||
<Text className='text-xs'>
|
||||
@@ -157,7 +169,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
<TouchableOpacity
|
||||
disabled={cancelJobMutation.isPending}
|
||||
onPress={() => cancelJobMutation.mutate(process.id)}
|
||||
className='ml-auto p-2 rounded-full'
|
||||
className='ml-auto'
|
||||
>
|
||||
{cancelJobMutation.isPending ? (
|
||||
<ActivityIndicator size='small' color='white' />
|
||||
|
||||
@@ -13,8 +13,7 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
|
||||
items,
|
||||
...props
|
||||
}) => {
|
||||
const { getDownloadedItemSize, getDownloadedItems } = useDownload();
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
const { downloadedFiles, getDownloadedItemSize } = useDownload();
|
||||
const [size, setSize] = useState<string | undefined>();
|
||||
|
||||
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
||||
@@ -40,8 +39,10 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
|
||||
}, [size]);
|
||||
|
||||
return (
|
||||
<Text className='text-xs text-neutral-500' {...props}>
|
||||
{sizeText}
|
||||
</Text>
|
||||
<>
|
||||
<Text className='text-xs text-neutral-500' {...props}>
|
||||
{sizeText}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,13 +4,18 @@ import {
|
||||
} from "@expo/react-native-action-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { type TouchableOpacityProps, View } from "react-native";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
TouchableOpacity,
|
||||
type TouchableOpacityProps,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
|
||||
@@ -18,17 +23,26 @@ interface EpisodeCardProps extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
||||
const { deleteFile } = useDownload();
|
||||
const { openFile } = useDownloadedFileOpener();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
|
||||
const _base64Image = useMemo(() => {
|
||||
return storage.getString(item.Id!);
|
||||
}, [item]);
|
||||
|
||||
const handleOpenFile = useCallback(() => {
|
||||
openFile(item);
|
||||
}, [item, openFile]);
|
||||
|
||||
/**
|
||||
* Handles deleting the file with haptic feedback.
|
||||
*/
|
||||
const handleDeleteFile = useCallback(() => {
|
||||
if (item.Id) {
|
||||
deleteFile(item.Id, "Episode");
|
||||
deleteFile(item.Id);
|
||||
successHapticFeedback();
|
||||
}
|
||||
}, [deleteFile, item.Id]);
|
||||
@@ -59,10 +73,10 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
||||
|
||||
return (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
isOffline={true}
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenFile}
|
||||
onLongPress={showActionSheet}
|
||||
key={item.Id}
|
||||
className='flex flex-col mb-4'
|
||||
>
|
||||
<View className='flex flex-row items-start mb-2'>
|
||||
@@ -86,7 +100,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||
<Text numberOfLines={3} className='text-xs text-neutral-500 shrink'>
|
||||
{item.Overview}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import type React from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { ProgressBar } from "../common/ProgressBar";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
|
||||
interface MovieCardProps {
|
||||
@@ -26,10 +26,16 @@ interface MovieCardProps {
|
||||
*/
|
||||
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
const { deleteFile } = useDownload();
|
||||
const { openFile } = useDownloadedFileOpener();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
|
||||
const handleOpenFile = useCallback(() => {
|
||||
openFile(item);
|
||||
}, [item, openFile]);
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(item?.Id!);
|
||||
return storage.getString(item.Id!);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
@@ -37,7 +43,8 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
*/
|
||||
const handleDeleteFile = useCallback(() => {
|
||||
if (item.Id) {
|
||||
deleteFile(item.Id, "Movie");
|
||||
deleteFile(item.Id);
|
||||
successHapticFeedback();
|
||||
}
|
||||
}, [deleteFile, item.Id]);
|
||||
|
||||
@@ -67,9 +74,9 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
||||
|
||||
return (
|
||||
<TouchableItemRouter onLongPress={showActionSheet} item={item} isOffline>
|
||||
<TouchableOpacity onPress={handleOpenFile} onLongPress={showActionSheet}>
|
||||
{base64Image ? (
|
||||
<View className='relative w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
|
||||
<View className='w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
|
||||
<Image
|
||||
source={{
|
||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||
@@ -80,24 +87,22 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
resizeMode: "cover",
|
||||
}}
|
||||
/>
|
||||
<ProgressBar item={item} />
|
||||
</View>
|
||||
) : (
|
||||
<View className='relative w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center'>
|
||||
<View className='w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center'>
|
||||
<Ionicons
|
||||
name='image-outline'
|
||||
size={24}
|
||||
color='gray'
|
||||
className='self-center mt-16'
|
||||
/>
|
||||
<ProgressBar item={item} />
|
||||
</View>
|
||||
)}
|
||||
<View className='w-28'>
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
<DownloadSize items={[item]} />
|
||||
</TouchableItemRouter>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ export const FilterSheet = <T,>({
|
||||
renderItemLabel,
|
||||
showSearch = true,
|
||||
multiple = false,
|
||||
...props
|
||||
}: Props<T>) => {
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const snapPoints = useMemo(() => ["80%"], []);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Image, Text, View } from "react-native";
|
||||
import heart from "@/assets/icons/heart.fill.png";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { InfiniteScrollingCollectionList } from "./InfiniteScrollingCollectionList";
|
||||
import { ScrollingCollectionList } from "./ScrollingCollectionList";
|
||||
|
||||
type FavoriteTypes =
|
||||
| "Series"
|
||||
@@ -33,11 +33,7 @@ export const Favorites = () => {
|
||||
});
|
||||
|
||||
const fetchFavoritesByType = useCallback(
|
||||
async (
|
||||
itemType: BaseItemKind,
|
||||
startIndex: number = 0,
|
||||
limit: number = 20,
|
||||
) => {
|
||||
async (itemType: BaseItemKind) => {
|
||||
const response = await getItemsApi(api as Api).getItems({
|
||||
userId: user?.Id,
|
||||
sortBy: ["SeriesSortName", "SortName"],
|
||||
@@ -48,19 +44,16 @@ export const Favorites = () => {
|
||||
collapseBoxSetItems: false,
|
||||
excludeLocationTypes: ["Virtual"],
|
||||
enableTotalRecordCount: false,
|
||||
startIndex: startIndex,
|
||||
limit: limit,
|
||||
limit: 20,
|
||||
includeItemTypes: [itemType],
|
||||
});
|
||||
const items = response.data.Items || [];
|
||||
|
||||
// Update empty state for this specific type only for the first page
|
||||
if (startIndex === 0) {
|
||||
setEmptyState((prev) => ({
|
||||
...prev,
|
||||
[itemType as FavoriteTypes]: items.length === 0,
|
||||
}));
|
||||
}
|
||||
// Update empty state for this specific type
|
||||
setEmptyState((prev) => ({
|
||||
...prev,
|
||||
[itemType as FavoriteTypes]: items.length === 0,
|
||||
}));
|
||||
|
||||
return items;
|
||||
},
|
||||
@@ -89,33 +82,27 @@ export const Favorites = () => {
|
||||
};
|
||||
|
||||
const fetchFavoriteSeries = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("Series", pageParam),
|
||||
() => fetchFavoritesByType("Series"),
|
||||
[fetchFavoritesByType],
|
||||
);
|
||||
const fetchFavoriteMovies = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("Movie", pageParam),
|
||||
() => fetchFavoritesByType("Movie"),
|
||||
[fetchFavoritesByType],
|
||||
);
|
||||
const fetchFavoriteEpisodes = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("Episode", pageParam),
|
||||
() => fetchFavoritesByType("Episode"),
|
||||
[fetchFavoritesByType],
|
||||
);
|
||||
const fetchFavoriteVideos = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("Video", pageParam),
|
||||
() => fetchFavoritesByType("Video"),
|
||||
[fetchFavoritesByType],
|
||||
);
|
||||
const fetchFavoriteBoxsets = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("BoxSet", pageParam),
|
||||
() => fetchFavoritesByType("BoxSet"),
|
||||
[fetchFavoritesByType],
|
||||
);
|
||||
const fetchFavoritePlaylists = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("Playlist", pageParam),
|
||||
() => fetchFavoritesByType("Playlist"),
|
||||
[fetchFavoritesByType],
|
||||
);
|
||||
|
||||
@@ -136,38 +123,38 @@ export const Favorites = () => {
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<InfiniteScrollingCollectionList
|
||||
<ScrollingCollectionList
|
||||
queryFn={fetchFavoriteSeries}
|
||||
queryKey={["home", "favorites", "series"]}
|
||||
title={t("favorites.series")}
|
||||
hideIfEmpty
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
<ScrollingCollectionList
|
||||
queryFn={fetchFavoriteMovies}
|
||||
queryKey={["home", "favorites", "movies"]}
|
||||
title={t("favorites.movies")}
|
||||
hideIfEmpty
|
||||
orientation='vertical'
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
<ScrollingCollectionList
|
||||
queryFn={fetchFavoriteEpisodes}
|
||||
queryKey={["home", "favorites", "episodes"]}
|
||||
title={t("favorites.episodes")}
|
||||
hideIfEmpty
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
<ScrollingCollectionList
|
||||
queryFn={fetchFavoriteVideos}
|
||||
queryKey={["home", "favorites", "videos"]}
|
||||
title={t("favorites.videos")}
|
||||
hideIfEmpty
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
<ScrollingCollectionList
|
||||
queryFn={fetchFavoriteBoxsets}
|
||||
queryKey={["home", "favorites", "boxsets"]}
|
||||
title={t("favorites.boxsets")}
|
||||
hideIfEmpty
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
<ScrollingCollectionList
|
||||
queryFn={fetchFavoritePlaylists}
|
||||
queryKey={["home", "favorites", "playlists"]}
|
||||
title={t("favorites.playlists")}
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
type QueryFunction,
|
||||
type QueryKey,
|
||||
useInfiniteQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
View,
|
||||
type ViewProps,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import SeriesPoster from "../posters/SeriesPoster";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
title?: string | null;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
disabled?: boolean;
|
||||
queryKey: QueryKey;
|
||||
queryFn: QueryFunction<BaseItemDto[], QueryKey, number>;
|
||||
hideIfEmpty?: boolean;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
title,
|
||||
orientation = "vertical",
|
||||
disabled = false,
|
||||
queryFn,
|
||||
queryKey,
|
||||
hideIfEmpty = false,
|
||||
pageSize = 20,
|
||||
...props
|
||||
}) => {
|
||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: queryKey,
|
||||
queryFn: ({ pageParam = 0, ...context }) =>
|
||||
queryFn({ ...context, queryKey, pageParam }),
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
// If the last page has fewer items than pageSize, we've reached the end
|
||||
if (lastPage.length < pageSize) {
|
||||
return undefined;
|
||||
}
|
||||
// Otherwise, return the next start index
|
||||
return allPages.length * pageSize;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Flatten all pages into a single array
|
||||
const allItems = data?.pages.flat() || [];
|
||||
|
||||
if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
|
||||
if (disabled || !title) return null;
|
||||
|
||||
const handleScroll = (event: any) => {
|
||||
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
|
||||
const paddingToBottom = 20;
|
||||
|
||||
// Check if we're near the end of the scroll
|
||||
if (
|
||||
layoutMeasurement.width + contentOffset.x >=
|
||||
contentSize.width - paddingToBottom
|
||||
) {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
||||
{title}
|
||||
</Text>
|
||||
{isLoading === false && allItems.length === 0 && (
|
||||
<View className='px-4'>
|
||||
<Text className='text-neutral-500'>{t("home.no_items")}</Text>
|
||||
</View>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<View
|
||||
className={`
|
||||
flex flex-row gap-2 px-4
|
||||
`}
|
||||
>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<View className='w-44' key={i}>
|
||||
<View className='bg-neutral-900 h-24 w-full rounded-md mb-1' />
|
||||
<View className='rounded-md overflow-hidden mb-1 self-start'>
|
||||
<Text
|
||||
className='text-neutral-900 bg-neutral-900 rounded-md'
|
||||
numberOfLines={1}
|
||||
>
|
||||
Nisi mollit voluptate amet.
|
||||
</Text>
|
||||
</View>
|
||||
<View className='rounded-md overflow-hidden self-start mb-1'>
|
||||
<Text
|
||||
className='text-neutral-900 bg-neutral-900 text-xs rounded-md '
|
||||
numberOfLines={1}
|
||||
>
|
||||
Lorem ipsum
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{allItems.map((item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className={`mr-2
|
||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||
`}
|
||||
>
|
||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
)}
|
||||
{item.Type === "Episode" && orientation === "vertical" && (
|
||||
<SeriesPoster item={item} />
|
||||
)}
|
||||
{item.Type === "Movie" && orientation === "horizontal" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
)}
|
||||
{item.Type === "Movie" && orientation === "vertical" && (
|
||||
<MoviePoster item={item} />
|
||||
)}
|
||||
{item.Type === "Series" && orientation === "vertical" && (
|
||||
<SeriesPoster item={item} />
|
||||
)}
|
||||
{item.Type === "Series" && orientation === "horizontal" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
)}
|
||||
{item.Type === "Program" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
)}
|
||||
{item.Type === "BoxSet" && orientation === "vertical" && (
|
||||
<MoviePoster item={item} />
|
||||
)}
|
||||
{item.Type === "BoxSet" && orientation === "horizontal" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
)}
|
||||
{item.Type === "Playlist" && orientation === "vertical" && (
|
||||
<MoviePoster item={item} />
|
||||
)}
|
||||
{item.Type === "Playlist" && orientation === "horizontal" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
)}
|
||||
{item.Type === "Video" && orientation === "vertical" && (
|
||||
<MoviePoster item={item} />
|
||||
)}
|
||||
{item.Type === "Video" && orientation === "horizontal" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
)}
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
{/* Loading indicator for next page */}
|
||||
{isFetchingNextPage && (
|
||||
<View className='justify-center items-center w-16'>
|
||||
<ActivityIndicator size='small' color='#6366f1' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -154,7 +154,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
if (!from) return;
|
||||
const url = itemRouter(item, from);
|
||||
lightHapticFeedback();
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
if (url) router.push(url);
|
||||
}, [item, from]);
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ interface Props extends ViewProps {
|
||||
queryKey: QueryKey;
|
||||
queryFn: QueryFunction<BaseItemDto[]>;
|
||||
hideIfEmpty?: boolean;
|
||||
isOffline?: boolean;
|
||||
}
|
||||
|
||||
export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
@@ -30,7 +29,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
queryFn,
|
||||
queryKey,
|
||||
hideIfEmpty = false,
|
||||
isOffline = false,
|
||||
...props
|
||||
}) => {
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -92,7 +90,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
isOffline={isOffline}
|
||||
className={`mr-2
|
||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||
`}
|
||||
|
||||
@@ -50,8 +50,11 @@ const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
|
||||
const DetailFacts: React.FC<
|
||||
{ details?: MovieDetails | TvDetails } & ViewProps
|
||||
> = ({ details, className, ...props }) => {
|
||||
const { jellyseerrRegion: region, jellyseerrLocale: locale } =
|
||||
useJellyseerr();
|
||||
const {
|
||||
jellyseerrUser,
|
||||
jellyseerrRegion: region,
|
||||
jellyseerrLocale: locale,
|
||||
} = useJellyseerr();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const releases = useMemo(
|
||||
|
||||
@@ -40,6 +40,7 @@ const ParallaxSlideShow = <T,>({
|
||||
renderItem,
|
||||
keyExtractor,
|
||||
onEndReached,
|
||||
...props
|
||||
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
@@ -139,7 +140,7 @@ const ParallaxSlideShow = <T,>({
|
||||
}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={false}
|
||||
//@ts-expect-error
|
||||
//@ts-ignore
|
||||
renderItem={({ item, index }) => renderItem(item, index)}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={3}
|
||||
|
||||
@@ -38,7 +38,16 @@ const RequestModal = forwardRef<
|
||||
Props & Omit<ViewProps, "id">
|
||||
>(
|
||||
(
|
||||
{ id, title, requestBody, type, isAnime = false, onRequested, onDismiss },
|
||||
{
|
||||
id,
|
||||
title,
|
||||
requestBody,
|
||||
type,
|
||||
isAnime = false,
|
||||
onRequested,
|
||||
onDismiss,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||
|
||||
@@ -14,22 +14,19 @@ import { studios } from "@/utils/jellyseerr/src/components/Discover/StudioSlider
|
||||
interface Props {
|
||||
sliders?: DiscoverSlider[];
|
||||
}
|
||||
|
||||
const Discover: React.FC<Props> = ({ sliders }) => {
|
||||
const hasSliders = !!sliders;
|
||||
if (!sliders) return;
|
||||
|
||||
const sortedSliders = useMemo(
|
||||
() =>
|
||||
sortBy(
|
||||
(sliders ?? []).filter((s) => s.enabled),
|
||||
sliders.filter((s) => s.enabled),
|
||||
"order",
|
||||
"asc",
|
||||
),
|
||||
[sliders],
|
||||
);
|
||||
|
||||
if (!hasSliders) return null;
|
||||
|
||||
return (
|
||||
<View className='flex flex-col space-y-4 mb-8'>
|
||||
{sortedSliders.map((slide) => {
|
||||
@@ -63,8 +60,6 @@ const Discover: React.FC<Props> = ({ sliders }) => {
|
||||
contentContainerStyle={{ paddingBottom: 16 }}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</View>
|
||||
|
||||
@@ -24,7 +24,7 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
||||
[slide],
|
||||
);
|
||||
|
||||
const { data } = useQuery({
|
||||
const { data, isFetching, isLoading } = useQuery({
|
||||
queryKey: ["jellyseerr", "discover", slide.type, slide.id],
|
||||
queryFn: async () => {
|
||||
return jellyseerrApi?.getGenreSliders(
|
||||
|
||||
@@ -11,7 +11,11 @@ import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces
|
||||
const RequestCard: React.FC<{ request: MediaRequest }> = ({ request }) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
|
||||
const { data: details } = useQuery({
|
||||
const {
|
||||
data: details,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
"jellyseerr",
|
||||
"detail",
|
||||
@@ -53,7 +57,11 @@ const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
|
||||
}) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
|
||||
const { data: requests } = useQuery({
|
||||
const {
|
||||
data: requests,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["jellyseerr", "recent_requests"],
|
||||
queryFn: async () => jellyseerrApi?.requests(),
|
||||
enabled: !!jellyseerrApi,
|
||||
|
||||
@@ -49,9 +49,9 @@ const Slide = <T,>({
|
||||
data={data}
|
||||
onEndReachedThreshold={1}
|
||||
onEndReached={onEndReached}
|
||||
//@ts-expect-error
|
||||
//@ts-ignore
|
||||
renderItem={({ item, index }) =>
|
||||
item ? renderItem(item, index) : null
|
||||
item ? renderItem(item, index) : <></>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -32,7 +32,6 @@ const icons: Record<CollectionType, IconName> = {
|
||||
boxsets: "albums",
|
||||
playlists: "list",
|
||||
folders: "folder",
|
||||
livetv: "tv",
|
||||
musicvideos: "musical-notes",
|
||||
photos: "images",
|
||||
trailers: "videocam",
|
||||
|
||||
@@ -82,6 +82,7 @@ const ListItemContent = ({
|
||||
showArrow,
|
||||
iconAfter,
|
||||
children,
|
||||
...props
|
||||
}: Props) => {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { View } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
export const HourHeader = ({ height }: { height: number }) => {
|
||||
const now = new Date();
|
||||
const currentHour = now.getHours();
|
||||
const hoursRemaining = 24 - currentHour;
|
||||
const hours = generateHours(currentHour, hoursRemaining);
|
||||
|
||||
return (
|
||||
<View
|
||||
className='flex flex-row'
|
||||
style={{
|
||||
height,
|
||||
}}
|
||||
>
|
||||
{hours.map((hour, index) => (
|
||||
<HourCell key={index} hour={hour} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const HourCell = ({ hour }: { hour: Date }) => (
|
||||
<View className='w-[200px] flex items-center justify-center bg-neutral-800'>
|
||||
<Text className='text-xs text-gray-600'>
|
||||
{hour.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const generateHours = (startHour: number, count: number): Date[] => {
|
||||
const now = new Date();
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const hour = new Date(now);
|
||||
hour.setHours(startHour + i, 0, 0, 0);
|
||||
return hour;
|
||||
});
|
||||
};
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { Dimensions, View } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
|
||||
export const LiveTVGuideRow = ({
|
||||
channel,
|
||||
programs,
|
||||
scrollX = 0,
|
||||
isVisible = true,
|
||||
}: {
|
||||
channel: BaseItemDto;
|
||||
programs?: BaseItemDto[] | null;
|
||||
scrollX?: number;
|
||||
isVisible?: boolean;
|
||||
}) => {
|
||||
const _positionRefs = useRef<{ [key: string]: number }>({});
|
||||
const screenWidth = Dimensions.get("window").width;
|
||||
|
||||
const calculateWidth = (s?: string | null, e?: string | null) => {
|
||||
if (!s || !e) return 0;
|
||||
const start = new Date(s);
|
||||
const end = new Date(e);
|
||||
const duration = end.getTime() - start.getTime();
|
||||
const minutes = duration / 60000;
|
||||
const width = (minutes / 60) * 200;
|
||||
return width;
|
||||
};
|
||||
|
||||
const programsWithPositions = useMemo(() => {
|
||||
let cumulativeWidth = 0;
|
||||
return programs
|
||||
?.filter((p) => p.ChannelId === channel.Id)
|
||||
.map((p) => {
|
||||
const width = calculateWidth(p.StartDate, p.EndDate);
|
||||
const position = cumulativeWidth;
|
||||
cumulativeWidth += width;
|
||||
return { ...p, width, position };
|
||||
});
|
||||
}, [programs, channel.Id]);
|
||||
|
||||
const isCurrentlyLive = (program: BaseItemDto) => {
|
||||
if (!program.StartDate || !program.EndDate) return false;
|
||||
const now = new Date();
|
||||
const start = new Date(program.StartDate);
|
||||
const end = new Date(program.EndDate);
|
||||
return now >= start && now <= end;
|
||||
};
|
||||
|
||||
if (!isVisible) {
|
||||
return <View style={{ height: 64 }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View key={channel.ChannelNumber} className='flex flex-row h-16'>
|
||||
{programsWithPositions?.map((p) => (
|
||||
<TouchableItemRouter item={p} key={p.Id}>
|
||||
<View
|
||||
style={{
|
||||
width: p.width,
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
left: p.position,
|
||||
backgroundColor: isCurrentlyLive(p)
|
||||
? "rgba(255, 255, 255, 0.1)"
|
||||
: "transparent",
|
||||
}}
|
||||
className='flex flex-col items-center justify-center border border-neutral-800 overflow-hidden'
|
||||
>
|
||||
{(() => {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
marginLeft:
|
||||
p.width > screenWidth && scrollX > p.position
|
||||
? scrollX - p.position
|
||||
: 0,
|
||||
}}
|
||||
className='px-4 self-start'
|
||||
>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
className='text-xs text-start self-start'
|
||||
>
|
||||
{p.Name}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -9,7 +9,7 @@ interface Props extends ViewProps {
|
||||
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text selectable className='font-bold text-2xl mb-1'>
|
||||
<Text uiTextView selectable className='font-bold text-2xl mb-1'>
|
||||
{item?.Name}
|
||||
</Text>
|
||||
<Text className='opacity-50'>{item?.ProductionYear}</Text>
|
||||
|
||||
@@ -38,6 +38,7 @@ const JellyseerrPoster: React.FC<Props> = ({
|
||||
horizontal,
|
||||
showDownloadInfo,
|
||||
mediaRequest,
|
||||
...props
|
||||
}) => {
|
||||
const { jellyseerrApi, getTitle, getYear, getMediaType } = useJellyseerr();
|
||||
const loadingOpacity = useSharedValue(1);
|
||||
|
||||