mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-12 04:16:37 +00:00
Compare commits
10 Commits
no-tv
...
feature/mp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de95b2dd18 | ||
|
|
3a8fa09881 | ||
|
|
b0c8aefda6 | ||
|
|
f477e86718 | ||
|
|
5ce4eb1be1 | ||
|
|
dd25feea25 | ||
|
|
d8f8224d0c | ||
|
|
6631cc5d65 | ||
|
|
f1f2777119 | ||
|
|
b6198b21bd |
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"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(ls:*)",
|
|
||||||
"Bash(cat:*)"
|
|
||||||
],
|
|
||||||
"deny": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -43,7 +43,6 @@ body:
|
|||||||
label: Version
|
label: Version
|
||||||
description: What version of Streamyfin are you running?
|
description: What version of Streamyfin are you running?
|
||||||
options:
|
options:
|
||||||
- 0.29.0
|
|
||||||
- 0.28.0
|
- 0.28.0
|
||||||
- 0.27.0
|
- 0.27.0
|
||||||
- 0.26.1
|
- 0.26.1
|
||||||
|
|||||||
79
.github/workflows/build-android.yml
vendored
79
.github/workflows/build-android.yml
vendored
@@ -1,79 +0,0 @@
|
|||||||
name: 🤖 Android APK Build
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
pull_request:
|
|
||||||
branches: [develop, master]
|
|
||||||
push:
|
|
||||||
branches: [develop, master]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
name: 🏗️ Build Android APK
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: 📥 Checkout code
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
|
||||||
show-progress: false
|
|
||||||
submodules: recursive
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
|
||||||
with:
|
|
||||||
bun-version: '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@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
|
||||||
with:
|
|
||||||
path: ~/.bun/install/cache
|
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-cache-
|
|
||||||
|
|
||||||
- name: 📦 Install dependencies
|
|
||||||
run: |
|
|
||||||
bun install --frozen-lockfile
|
|
||||||
bun run submodule-reload
|
|
||||||
|
|
||||||
- name: 💾 Cache Android dependencies
|
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
android/.gradle
|
|
||||||
key: ${{ runner.os }}-android-deps-${{ hashFiles('android/**/build.gradle') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-android-deps-
|
|
||||||
|
|
||||||
- name: 🛠️ Generate project files
|
|
||||||
run: bun run prebuild
|
|
||||||
|
|
||||||
- name: 🚀 Build APK via Bun
|
|
||||||
run: bun run build:android:local
|
|
||||||
|
|
||||||
- name: 📅 Set date tag
|
|
||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: 📤 Upload APK artifact
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
with:
|
|
||||||
name: streamyfin-apk-${{ env.DATE_TAG }}
|
|
||||||
path: |
|
|
||||||
android/app/build/outputs/apk/release/*.apk
|
|
||||||
android/app/build/outputs/bundle/release/*.aab
|
|
||||||
retention-days: 7
|
|
||||||
49
.github/workflows/build-ios.yaml
vendored
Normal file
49
.github/workflows/build-ios.yaml
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
name: Automatic Build and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: macos-15
|
||||||
|
name: Build IOS
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
name: Check out repository
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
- run: |
|
||||||
|
bun i && bun run submodule-reload
|
||||||
|
npx expo prebuild
|
||||||
|
- uses: sparkfabrik/ios-build-action@v2.3.0
|
||||||
|
with:
|
||||||
|
upload-to-testflight: false
|
||||||
|
increment-build-number: false
|
||||||
|
build-pods: true
|
||||||
|
pods-path: "ios/Podfile"
|
||||||
|
configuration: Release
|
||||||
|
# Change later to app-store if wanted
|
||||||
|
export-method: appstore
|
||||||
|
#export-method: ad-hoc
|
||||||
|
workspace-path: "ios/Streamyfin.xcodeproj/project.xcworkspace/"
|
||||||
|
project-path: "ios/Streamyfin.xcodeproj"
|
||||||
|
scheme: Streamyfin
|
||||||
|
apple-key-id: ${{ secrets.APPLE_KEY_ID }}
|
||||||
|
apple-key-issuer-id: ${{ secrets.APPLE_KEY_ISSUER_ID }}
|
||||||
|
apple-key-content: ${{ secrets.APPLE_KEY_CONTENT }}
|
||||||
|
team-id: ${{ secrets.TEAM_ID }}
|
||||||
|
team-name: ${{ secrets.TEAM_NAME }}
|
||||||
|
#match-password: ${{ secrets.MATCH_PASSWORD }}
|
||||||
|
#match-git-url: ${{ secrets.MATCH_GIT_URL }}
|
||||||
|
#match-git-basic-authorization: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
|
||||||
|
#match-build-type: "appstore"
|
||||||
|
#browserstack-upload: true
|
||||||
|
#browserstack-username: ${{ secrets.BROWSERSTACK_USERNAME }}
|
||||||
|
#browserstack-access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
|
||||||
|
#fastlane-env: stage
|
||||||
|
ios-app-id: com.stetsed.teststreamyfin
|
||||||
|
output-path: build-${{ github.sha }}.ipa
|
||||||
70
.github/workflows/build-ios.yml
vendored
70
.github/workflows/build-ios.yml
vendored
@@ -1,70 +0,0 @@
|
|||||||
name: 🤖 iOS IPA Build
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
pull_request:
|
|
||||||
branches: [develop, master]
|
|
||||||
push:
|
|
||||||
branches: [develop, master]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: macos-15
|
|
||||||
name: 🏗️ Build iOS IPA
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: 📥 Check out repository
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
|
||||||
show-progress: false
|
|
||||||
submodules: recursive
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
|
||||||
with:
|
|
||||||
bun-version: '1.2.17'
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
|
||||||
with:
|
|
||||||
path: ~/.bun/install/cache
|
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-cache-
|
|
||||||
|
|
||||||
- name: 📦 Install & Prepare
|
|
||||||
run: |
|
|
||||||
bun install --frozen-lockfile
|
|
||||||
bun run submodule-reload
|
|
||||||
|
|
||||||
- name: 🛠️ Generate project files
|
|
||||||
run: bun run prebuild
|
|
||||||
|
|
||||||
- name: 🏗 Setup EAS
|
|
||||||
uses: expo/expo-github-action@main
|
|
||||||
with:
|
|
||||||
eas-version: 16.7.1
|
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
|
||||||
|
|
||||||
- name: 🏗️ Build iOS app
|
|
||||||
run: |
|
|
||||||
eas build -p ios --local --non-interactive
|
|
||||||
|
|
||||||
- name: 📅 Set date tag
|
|
||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: 📤 Upload IPA artifact
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
with:
|
|
||||||
name: streamyfin-ipa-${{ env.DATE_TAG }}
|
|
||||||
path: |
|
|
||||||
build-*.ipa
|
|
||||||
retention-days: 7
|
|
||||||
46
.github/workflows/check-lockfile.yml
vendored
46
.github/workflows/check-lockfile.yml
vendored
@@ -1,46 +0,0 @@
|
|||||||
name: 🔒 Lockfile Consistency Check
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [develop, master]
|
|
||||||
push:
|
|
||||||
branches: [develop, master]
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check-lockfile:
|
|
||||||
name: 🔍 Check bun.lock and package.json consistency
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: 📥 Checkout repository
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
|
||||||
show-progress: false
|
|
||||||
submodules: recursive
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
|
||||||
with:
|
|
||||||
bun-version: '1.2.17'
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.bun/install/cache
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
|
||||||
|
|
||||||
- name: 🛡️ Verify lockfile consistency
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
echo "➡️ Checking for discrepancies between bun.lock and package.json..."
|
|
||||||
bun install --frozen-lockfile --dry-run --ignore-scripts
|
|
||||||
echo "✅ Lockfile is consistent with package.json!"
|
|
||||||
43
.github/workflows/ci-codeql.yml
vendored
43
.github/workflows/ci-codeql.yml
vendored
@@ -1,43 +0,0 @@
|
|||||||
name: 🛡️ CodeQL Analysis
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master, develop]
|
|
||||||
pull_request:
|
|
||||||
branches: [master, develop]
|
|
||||||
schedule:
|
|
||||||
- cron: '24 2 * * *'
|
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
analyze:
|
|
||||||
name: 🔎 Analyze with CodeQL
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
security-events: write
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
language: [ 'javascript-typescript' ]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: 📥 Checkout repository
|
|
||||||
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@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
|
||||||
with:
|
|
||||||
languages: ${{ matrix.language }}
|
|
||||||
queries: +security-extended,security-and-quality
|
|
||||||
|
|
||||||
- name: 🛠️ Autobuild
|
|
||||||
uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
|
||||||
uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
|
||||||
24
.github/workflows/conflict.yml
vendored
24
.github/workflows/conflict.yml
vendored
@@ -1,24 +0,0 @@
|
|||||||
name: 🏷️🔀Merge Conflict Labeler
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [develop]
|
|
||||||
pull_request_target:
|
|
||||||
branches: [develop]
|
|
||||||
types: [synchronize]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
label:
|
|
||||||
name: 🏷️ Labeling Merge Conflicts
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
if: ${{ github.repository == 'streamyfin/streamyfin' }}
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
|
||||||
- name: 🚩 Apply merge conflict label
|
|
||||||
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
|
||||||
with:
|
|
||||||
dirtyLabel: 'merge-conflict'
|
|
||||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
|
||||||
repoToken: '${{ secrets.GITHUB_TOKEN }}'
|
|
||||||
41
.github/workflows/lint-pr.yaml
vendored
Normal file
41
.github/workflows/lint-pr.yaml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: "Lint PR"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- edited
|
||||||
|
- synchronize
|
||||||
|
- reopened
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
main:
|
||||||
|
name: Validate PR title
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: amannn/action-semantic-pull-request@v5
|
||||||
|
id: lint_pr_title
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- uses: marocchino/sticky-pull-request-comment@v2
|
||||||
|
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
message: |
|
||||||
|
Hey there and thank you for opening this pull request! 👋🏼
|
||||||
|
|
||||||
|
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
|
||||||
|
|
||||||
|
Details:
|
||||||
|
|
||||||
|
```
|
||||||
|
${{ steps.lint_pr_title.outputs.error_message }}
|
||||||
|
```
|
||||||
|
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||||
|
uses: marocchino/sticky-pull-request-comment@v2
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
delete: true
|
||||||
28
.github/workflows/lint.yaml
vendored
Normal file
28
.github/workflows/lint.yaml
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
name: Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [ develop, master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '20.x'
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v1
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install
|
||||||
|
|
||||||
|
- name: Run linting checks
|
||||||
|
run: bun run check
|
||||||
95
.github/workflows/linting.yml
vendored
95
.github/workflows/linting.yml
vendored
@@ -1,95 +0,0 @@
|
|||||||
name: 🚦 Security & Quality Gate
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types: [opened, edited, synchronize, reopened]
|
|
||||||
branches: [develop, master]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
validate_pr_title:
|
|
||||||
name: "📝 Validate PR Title"
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
pull-requests: write
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- 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@d2ad0de260ae8b0235ce059e63f2949ba9e05943 # v2.9.3
|
|
||||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
|
||||||
with:
|
|
||||||
header: pr-title-lint-error
|
|
||||||
message: |
|
|
||||||
Hey there and thank you for opening this pull request! 👋🏼
|
|
||||||
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
|
|
||||||
|
|
||||||
**Error details:**
|
|
||||||
```
|
|
||||||
${{ steps.lint_pr_title.outputs.error_message }}
|
|
||||||
```
|
|
||||||
|
|
||||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
|
||||||
uses: marocchino/sticky-pull-request-comment@d2ad0de260ae8b0235ce059e63f2949ba9e05943 # v2.9.3
|
|
||||||
with:
|
|
||||||
header: pr-title-lint-error
|
|
||||||
delete: true
|
|
||||||
|
|
||||||
dependency-review:
|
|
||||||
name: 🔍 Vulnerable Dependencies
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- name: Checkout Repository
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Dependency Review
|
|
||||||
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 }}
|
|
||||||
|
|
||||||
code_quality:
|
|
||||||
name: "🔍 Lint & Test (${{ matrix.command }})"
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
command:
|
|
||||||
- "lint"
|
|
||||||
- "check"
|
|
||||||
steps:
|
|
||||||
- name: "📥 Checkout PR code"
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
|
||||||
submodules: recursive
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
|
||||||
with:
|
|
||||||
node-version: '22.x'
|
|
||||||
|
|
||||||
- name: "🍞 Setup Bun"
|
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
|
||||||
with:
|
|
||||||
bun-version: '1.2.17'
|
|
||||||
|
|
||||||
- name: "📦 Install dependencies"
|
|
||||||
run: bun install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: "🚨 Run ${{ matrix.command }}"
|
|
||||||
run: bun run ${{ matrix.command }}
|
|
||||||
39
.github/workflows/main.yml
vendored
Normal file
39
.github/workflows/main.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Handle Stale Issues
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "30 1 * * *" # Runs at 1:30 UTC every day
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v9
|
||||||
|
with:
|
||||||
|
# Issue specific settings
|
||||||
|
days-before-issue-stale: 90
|
||||||
|
days-before-issue-close: 7
|
||||||
|
stale-issue-label: "stale"
|
||||||
|
stale-issue-message: |
|
||||||
|
This issue has been automatically marked as stale because it has had no activity in the last 30 days.
|
||||||
|
|
||||||
|
If this issue is still relevant, please leave a comment to keep it open.
|
||||||
|
Otherwise, it will be closed in 7 days if no further activity occurs.
|
||||||
|
|
||||||
|
Thank you for your contributions!
|
||||||
|
close-issue-message: |
|
||||||
|
This issue has been automatically closed because it has been inactive for 7 days since being marked as stale.
|
||||||
|
|
||||||
|
If you believe this issue is still relevant, please feel free to reopen it and add a comment explaining the current status.
|
||||||
|
|
||||||
|
# Pull request settings (disabled)
|
||||||
|
days-before-pr-stale: -1
|
||||||
|
days-before-pr-close: -1
|
||||||
|
|
||||||
|
# Other settings
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
operations-per-run: 100
|
||||||
|
exempt-issue-labels: "Roadmap v1,help needed,enhancement"
|
||||||
18
.github/workflows/notification.yaml
vendored
Normal file
18
.github/workflows/notification.yaml
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
name: Discord Pull Request Notification
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: joelwmale/webhook-action@master
|
||||||
|
with:
|
||||||
|
url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
body: |
|
||||||
|
{
|
||||||
|
"content": "New Pull Request: ${{ github.event.pull_request.title }}\nBy: ${{ github.event.pull_request.user.login }}\n\n${{ github.event.pull_request.html_url }}",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/193271640"
|
||||||
|
}
|
||||||
23
.github/workflows/notification.yml
vendored
23
.github/workflows/notification.yml
vendored
@@ -1,23 +0,0 @@
|
|||||||
name: 🛎️ Discord Pull Request Notification
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, reopened]
|
|
||||||
branches: [develop]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
notify:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
steps:
|
|
||||||
- name: 🛎️ Notify Discord
|
|
||||||
uses: Ilshidur/action-discord@0c4b27844ba47cb1c7bee539c8eead5284ce9fa9 # 0.3.2
|
|
||||||
env:
|
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
|
||||||
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640
|
|
||||||
with:
|
|
||||||
args: |
|
|
||||||
📢 New Pull Request in **${{ github.repository }}**
|
|
||||||
**Title:** ${{ github.event.pull_request.title }}
|
|
||||||
**By:** ${{ github.event.pull_request.user.login }}
|
|
||||||
**Branch:** ${{ github.event.pull_request.head.ref }}
|
|
||||||
🔗 ${{ github.event.pull_request.html_url }}
|
|
||||||
49
.github/workflows/stale.yml
vendored
49
.github/workflows/stale.yml
vendored
@@ -1,49 +0,0 @@
|
|||||||
name: 🕒 Handle Stale Issues
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
# Runs daily at 1:30 AM UTC (3:30 AM CEST - France time)
|
|
||||||
- cron: "30 1 * * *"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
stale-issues:
|
|
||||||
name: 🗑️ Cleanup Stale Issues
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: 🔄 Mark/Close Stale Issues
|
|
||||||
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
|
||||||
with:
|
|
||||||
# Global settings
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
operations-per-run: 500 # Increase if you have >1000 issues
|
|
||||||
log-level: debug
|
|
||||||
|
|
||||||
# Issue configuration
|
|
||||||
days-before-issue-stale: 90
|
|
||||||
days-before-issue-close: 7
|
|
||||||
stale-issue-label: "stale"
|
|
||||||
exempt-issue-labels: "Roadmap v1,help needed,enhancement"
|
|
||||||
|
|
||||||
# Notifications messages
|
|
||||||
stale-issue-message: |
|
|
||||||
⏳ This issue has been automatically marked as **stale** because it has had no activity for 90 days.
|
|
||||||
|
|
||||||
**Next steps:**
|
|
||||||
- If this is still relevant, add a comment to keep it open
|
|
||||||
- Otherwise, it will be closed in 7 days
|
|
||||||
|
|
||||||
Thank you for your contributions! 🙌
|
|
||||||
|
|
||||||
close-issue-message: |
|
|
||||||
🚮 This issue has been automatically closed due to inactivity (7 days since being marked stale).
|
|
||||||
|
|
||||||
**Need to reopen?**
|
|
||||||
Click "Reopen" and add a comment explaining why this should stay open.
|
|
||||||
|
|
||||||
# Disable PR handling
|
|
||||||
days-before-pr-stale: -1
|
|
||||||
days-before-pr-close: -1
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -11,6 +11,7 @@ npm-debug.*
|
|||||||
web-build/
|
web-build/
|
||||||
modules/vlc-player/android/build
|
modules/vlc-player/android/build
|
||||||
modules/vlc-player/android/.gradle
|
modules/vlc-player/android/.gradle
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -19,7 +20,9 @@ expo-env.d.ts
|
|||||||
|
|
||||||
Streamyfin.app
|
Streamyfin.app
|
||||||
|
|
||||||
|
build-*
|
||||||
*.mp4
|
*.mp4
|
||||||
|
build-*
|
||||||
Streamyfin.app
|
Streamyfin.app
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
@@ -44,5 +47,4 @@ credentials.json
|
|||||||
modules/hls-downloader/android/build
|
modules/hls-downloader/android/build
|
||||||
streamyfin-4fec1-firebase-adminsdk.json
|
streamyfin-4fec1-firebase-adminsdk.json
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
*.aab
|
|
||||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -9,7 +9,7 @@
|
|||||||
},
|
},
|
||||||
"prettier.printWidth": 120,
|
"prettier.printWidth": 120,
|
||||||
"[swift]": {
|
"[swift]": {
|
||||||
"editor.defaultFormatter": "sswg.swift-lang"
|
"editor.defaultFormatter": "swiftlang.swift-vscode"
|
||||||
},
|
},
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
|
|||||||
53
README.md
53
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||||
|
|
||||||
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.
|
Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
|
||||||
|
|
||||||
<div style="display: flex; flex-direction: row; gap: 8px">
|
<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/screenshot1.png" />
|
||||||
@@ -15,47 +15,47 @@ A simple and user-friendly Jellyfin video streaming client built with Expo. If y
|
|||||||
|
|
||||||
- 🚀 **Skip Intro / Credits Support**
|
- 🚀 **Skip Intro / Credits Support**
|
||||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
||||||
|
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
||||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||||
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
||||||
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
|
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
|
||||||
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
||||||
- 👁️ **Sessions View:** View all active sessions currently streaming on your server.
|
|
||||||
|
|
||||||
## 🧪 Experimental Features
|
## 🧪 Experimental Features
|
||||||
|
|
||||||
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These features are still in development, and your patience and feedback are much appreciated as we work to improve them.
|
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These are still in development, and we appreciate your patience and feedback as we work to improve them.
|
||||||
|
|
||||||
### 📥 Downloading
|
### Downloading
|
||||||
|
|
||||||
Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
||||||
|
|
||||||
### 🎥 Chromecast
|
### Chromecast
|
||||||
|
|
||||||
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos, but we're working on adding support for subtitles and other features.
|
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features.
|
||||||
|
|
||||||
### 🧩 Streamyfin Plugin
|
### Streamyfin Plugin
|
||||||
|
|
||||||
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
|
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that hold all settings for the client Streamyfin. This allows you to syncronize settings accross all your users, like:
|
||||||
|
|
||||||
- Auto log in to Jellyseerr without the user having to do anything
|
- Auto log in to Jellyseerr without the user having to do anythin
|
||||||
- Choose the default languages
|
- Choose the default languages
|
||||||
- Set download method and search provider
|
- Set download method and search provider
|
||||||
- Customize home screen
|
- Customize homescreen
|
||||||
- And much more...
|
- And more...
|
||||||
|
|
||||||
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
||||||
|
|
||||||
### 🔍 Jellysearch
|
### Jellysearch
|
||||||
|
|
||||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀
|
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀
|
||||||
|
|
||||||
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
||||||
|
|
||||||
## 🛣️ Roadmap for V1
|
## Roadmap for V1
|
||||||
|
|
||||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open for feedback and suggestions, so please let us know if you have any ideas or feature requests.
|
||||||
|
|
||||||
## 📥 Get it now
|
## Get it now
|
||||||
|
|
||||||
<div style="display: flex; gap: 5px;">
|
<div style="display: flex; gap: 5px;">
|
||||||
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
||||||
@@ -64,9 +64,9 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To
|
|||||||
|
|
||||||
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
|
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
|
||||||
|
|
||||||
### 🧪 Beta testing
|
### Beta testing
|
||||||
|
|
||||||
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and I'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that I can manually add you.
|
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
|
||||||
|
|
||||||
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ To access the Streamyfin beta, you need to subscribe to the Member tier (or high
|
|||||||
|
|
||||||
We welcome any help to make Streamyfin better. If you'd like to contribute, please fork the repository and submit a pull request. For major changes, it's best to open an issue first to discuss your ideas.
|
We welcome any help to make Streamyfin better. If you'd like to contribute, please fork the repository and submit a pull request. For major changes, it's best to open an issue first to discuss your ideas.
|
||||||
|
|
||||||
### 👨💻 Development info
|
### Development info
|
||||||
|
|
||||||
1. Use node `>20`
|
1. Use node `>20`
|
||||||
2. Install dependencies `bun i && bun run submodule-reload`
|
2. Install dependencies `bun i && bun run submodule-reload`
|
||||||
@@ -118,13 +118,6 @@ If you have questions or need support, feel free to reach out:
|
|||||||
- GitHub Issues: Report bugs or request features here.
|
- GitHub Issues: Report bugs or request features here.
|
||||||
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
||||||
|
|
||||||
## ❓ FAQ
|
|
||||||
|
|
||||||
1. Q: Why can't I see my libraries in Streamyfin?
|
|
||||||
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only.
|
|
||||||
2. Q: Why can't I see my music library?
|
|
||||||
A: We don't currently support music and are unlikely to support music in the near future.
|
|
||||||
|
|
||||||
## 📝 Credits
|
## 📝 Credits
|
||||||
|
|
||||||
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
||||||
@@ -135,7 +128,7 @@ We would like to thank the Jellyfin team for their great software and awesome su
|
|||||||
|
|
||||||
Special shoutout to the JF official clients for being an inspiration to ours.
|
Special shoutout to the JF official clients for being an inspiration to ours.
|
||||||
|
|
||||||
### 🏆 Core Developers
|
### Core Developers
|
||||||
|
|
||||||
Thanks to the following contributors for their significant contributions:
|
Thanks to the following contributors for their significant contributions:
|
||||||
|
|
||||||
@@ -220,12 +213,6 @@ I'd also like to thank the following people and projects for their contributions
|
|||||||
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
|
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
|
||||||
- The Jellyfin devs for always being helpful in the Discord.
|
- The Jellyfin devs for always being helpful in the Discord.
|
||||||
|
|
||||||
## ⭐ Star History
|
## Star History
|
||||||
|
|
||||||
[](https://star-history.com/#streamyfin/streamyfin&Date)
|
[](https://star-history.com/#streamyfin/streamyfin&Date)
|
||||||
|
|
||||||
## ⚠️ Disclaimer
|
|
||||||
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions or support requests related to piracy are strictly prohibited across all our channels.
|
|
||||||
|
|
||||||
## 🤝 Sponsorship
|
|
||||||
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur)
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
module.exports = ({ config }) => {
|
module.exports = ({ config }) => {
|
||||||
config.plugins.push([
|
if (process.env.EXPO_TV !== "1") {
|
||||||
"react-native-google-cast",
|
config.plugins.push([
|
||||||
{ useDefaultExpandedMediaControls: true },
|
"react-native-google-cast",
|
||||||
]);
|
{ useDefaultExpandedMediaControls: true },
|
||||||
|
]);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
android: {
|
android: {
|
||||||
googleServicesFile: process.env.GOOGLE_SERVICES_JSON,
|
googleServicesFile: process.env.GOOGLE_SERVICES_JSON,
|
||||||
|
|||||||
26
app.json
26
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.29.1",
|
"version": "0.28.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -27,19 +27,13 @@
|
|||||||
"usesNonExemptEncryption": false
|
"usesNonExemptEncryption": false
|
||||||
},
|
},
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
"bundleIdentifier": "com.fredrikburmester.streamyfin"
|
||||||
"icon": {
|
|
||||||
"dark": "./assets/images/icon-plain.png",
|
|
||||||
"light": "./assets/images/icon-ios-light.png",
|
|
||||||
"tinted": "./assets/images/icon-ios-tinted.png"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 56,
|
"versionCode": 54,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-plain.png",
|
"foregroundImage": "./assets/images/adaptive_icon.png",
|
||||||
"monochromeImage": "./assets/images/icon-mono.png",
|
|
||||||
"backgroundColor": "#464646"
|
"backgroundColor": "#464646"
|
||||||
},
|
},
|
||||||
"package": "com.fredrikburmester.streamyfin",
|
"package": "com.fredrikburmester.streamyfin",
|
||||||
@@ -51,8 +45,10 @@
|
|||||||
"googleServicesFile": "./google-services.json"
|
"googleServicesFile": "./google-services.json"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
"@react-native-tvos/config-tv",
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
|
"@config-plugins/ffmpeg-kit-react-native",
|
||||||
[
|
[
|
||||||
"react-native-video",
|
"react-native-video",
|
||||||
{
|
{
|
||||||
@@ -117,7 +113,6 @@
|
|||||||
["./plugins/withAndroidManifest.js"],
|
["./plugins/withAndroidManifest.js"],
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
["./plugins/withGradleProperties.js"],
|
["./plugins/withGradleProperties.js"],
|
||||||
["./plugins/withRNBackgroundDownloader.js"],
|
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
@@ -132,14 +127,7 @@
|
|||||||
"icon": "./assets/images/notification.png",
|
"icon": "./assets/images/notification.png",
|
||||||
"color": "#9333EA"
|
"color": "#9333EA"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
[
|
|
||||||
"react-native-google-cast",
|
|
||||||
{
|
|
||||||
"useDefaultExpandedMediaControls": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"expo-background-task"
|
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
|
||||||
import { useAtom } from "jotai/index";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { FlatList, Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
|
import { useAtom } from "jotai/index";
|
||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { FlatList, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null;
|
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
|
||||||
|
|
||||||
export default function SearchLayout() {
|
export default function SearchLayout() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useCallback, useState } from "react";
|
|
||||||
import { RefreshControl, ScrollView, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Favorites } from "@/components/home/Favorites";
|
import { Favorites } from "@/components/home/Favorites";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
import { RefreshControl, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function favorites() {
|
export default function favorites() {
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
|
||||||
|
|
||||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||||
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const _router = useRouter();
|
const router = useRouter();
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +6,11 @@ import {
|
|||||||
} from "@/components/series/SeasonDropdown";
|
} from "@/components/series/SeasonDropdown";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|||||||
@@ -1,18 +1,3 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import {
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
type BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
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 { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||||
@@ -23,6 +8,21 @@ import { type DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
|||||||
import { queueAtom } from "@/utils/atoms/queue";
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { 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";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useFocusEffect, useRouter } from "expo-router";
|
import { useFocusEffect, useRouter } from "expo-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Linking, TouchableOpacity, View } from "react-native";
|
import { Linking, TouchableOpacity, View } from "react-native";
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -1,22 +1,6 @@
|
|||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
|
||||||
import {
|
|
||||||
HardwareAccelerationType,
|
|
||||||
type SessionInfoDto,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
|
||||||
GeneralCommandType,
|
|
||||||
PlaystateCommand,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { Badge } from "@/components/Badge";
|
import { Badge } from "@/components/Badge";
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import Poster from "@/components/posters/Poster";
|
import Poster from "@/components/posters/Poster";
|
||||||
import { useInterval } from "@/hooks/useInterval";
|
import { useInterval } from "@/hooks/useInterval";
|
||||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||||
@@ -24,6 +8,22 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { formatBitrate } from "@/utils/bitrate";
|
import { formatBitrate } from "@/utils/bitrate";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { formatTimeString } from "@/utils/time";
|
import { formatTimeString } from "@/utils/time";
|
||||||
|
import {
|
||||||
|
AntDesign,
|
||||||
|
Entypo,
|
||||||
|
Ionicons,
|
||||||
|
MaterialCommunityIcons,
|
||||||
|
} from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
HardwareAccelerationType,
|
||||||
|
type SessionInfoDto,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
||||||
@@ -110,77 +110,6 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle session controls
|
|
||||||
const [isControlLoading, setIsControlLoading] = useState<
|
|
||||||
Record<string, boolean>
|
|
||||||
>({});
|
|
||||||
|
|
||||||
const handleSystemCommand = async (command: GeneralCommandType) => {
|
|
||||||
if (!api || !session.Id) return false;
|
|
||||||
|
|
||||||
setIsControlLoading({ ...isControlLoading, [command]: true });
|
|
||||||
|
|
||||||
try {
|
|
||||||
getSessionApi(api).sendSystemCommand({
|
|
||||||
sessionId: session.Id,
|
|
||||||
command,
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error sending ${command} command:`, error);
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setIsControlLoading({ ...isControlLoading, [command]: false });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePlaystateCommand = async (command: PlaystateCommand) => {
|
|
||||||
if (!api || !session.Id) return false;
|
|
||||||
|
|
||||||
setIsControlLoading({ ...isControlLoading, [command]: true });
|
|
||||||
|
|
||||||
try {
|
|
||||||
getSessionApi(api).sendPlaystateCommand({
|
|
||||||
sessionId: session.Id,
|
|
||||||
command,
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error sending playstate ${command} command:`, error);
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setIsControlLoading({ ...isControlLoading, [command]: false });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePlayPause = async () => {
|
|
||||||
console.log("handlePlayPause");
|
|
||||||
await handlePlaystateCommand(PlaystateCommand.PlayPause);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStop = async () => {
|
|
||||||
await handlePlaystateCommand(PlaystateCommand.Stop);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrevious = async () => {
|
|
||||||
await handlePlaystateCommand(PlaystateCommand.PreviousTrack);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNext = async () => {
|
|
||||||
await handlePlaystateCommand(PlaystateCommand.NextTrack);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleMute = async () => {
|
|
||||||
await handleSystemCommand(GeneralCommandType.ToggleMute);
|
|
||||||
};
|
|
||||||
const handleVolumeUp = async () => {
|
|
||||||
await handleSystemCommand(GeneralCommandType.VolumeUp);
|
|
||||||
};
|
|
||||||
const handleVolumeDown = async () => {
|
|
||||||
await handleSystemCommand(GeneralCommandType.VolumeDown);
|
|
||||||
};
|
|
||||||
|
|
||||||
useInterval(tick, 1000);
|
useInterval(tick, 1000);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -252,107 +181,6 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Session controls */}
|
|
||||||
<View className='flex flex-row mt-2 space-x-4 justify-center'>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handlePrevious}
|
|
||||||
disabled={isControlLoading[PlaystateCommand.PreviousTrack]}
|
|
||||||
style={{
|
|
||||||
opacity: isControlLoading[PlaystateCommand.PreviousTrack]
|
|
||||||
? 0.5
|
|
||||||
: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name='skip-previous'
|
|
||||||
size={24}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handlePlayPause}
|
|
||||||
disabled={isControlLoading[PlaystateCommand.PlayPause]}
|
|
||||||
style={{
|
|
||||||
opacity: isControlLoading[PlaystateCommand.PlayPause]
|
|
||||||
? 0.5
|
|
||||||
: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{session.PlayState?.IsPaused ? (
|
|
||||||
<Ionicons name='play' size={24} color='white' />
|
|
||||||
) : (
|
|
||||||
<Ionicons name='pause' size={24} color='white' />
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleStop}
|
|
||||||
disabled={isControlLoading[PlaystateCommand.Stop]}
|
|
||||||
style={{
|
|
||||||
opacity: isControlLoading[PlaystateCommand.Stop] ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='stop' size={24} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleNext}
|
|
||||||
disabled={isControlLoading[PlaystateCommand.NextTrack]}
|
|
||||||
style={{
|
|
||||||
opacity: isControlLoading[PlaystateCommand.NextTrack]
|
|
||||||
? 0.5
|
|
||||||
: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name='skip-next'
|
|
||||||
size={24}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleVolumeDown}
|
|
||||||
disabled={isControlLoading[GeneralCommandType.VolumeDown]}
|
|
||||||
style={{
|
|
||||||
opacity: isControlLoading[GeneralCommandType.VolumeDown]
|
|
||||||
? 0.5
|
|
||||||
: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='volume-low' size={24} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleToggleMute}
|
|
||||||
disabled={isControlLoading[GeneralCommandType.ToggleMute]}
|
|
||||||
style={{
|
|
||||||
opacity: isControlLoading[GeneralCommandType.ToggleMute]
|
|
||||||
? 0.5
|
|
||||||
: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name='volume-mute'
|
|
||||||
size={24}
|
|
||||||
color={session.PlayState?.IsMuted ? "red" : "white"}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleVolumeUp}
|
|
||||||
disabled={isControlLoading[GeneralCommandType.VolumeUp]}
|
|
||||||
style={{
|
|
||||||
opacity: isControlLoading[GeneralCommandType.VolumeUp]
|
|
||||||
? 0.5
|
|
||||||
: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='volume-high' size={24} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -448,18 +276,20 @@ const TranscodingStreamView = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{isTranscoding && transcodeProperties ? (
|
{isTranscoding && transcodeProperties ? (
|
||||||
<View className='flex flex-row'>
|
<>
|
||||||
<Text className='-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4'>
|
<View className='flex flex-row'>
|
||||||
<MaterialCommunityIcons
|
<Text className='-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4'>
|
||||||
name='arrow-right-bottom'
|
<MaterialCommunityIcons
|
||||||
size={14}
|
name='arrow-right-bottom'
|
||||||
color='white'
|
size={14}
|
||||||
/>
|
color='white'
|
||||||
</Text>
|
/>
|
||||||
<Text className='flex-1 text-sm mt-1'>
|
</Text>
|
||||||
<TranscodingBadges properties={transcodeProperties} />
|
<Text className='flex-1 text-sm mt-1'>
|
||||||
</Text>
|
<TranscodingBadges properties={transcodeProperties} />
|
||||||
</View>
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
import { useNavigation, useRouter } from "expo-router";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
@@ -20,14 +14,21 @@ import { StorageSettings } from "@/components/settings/StorageSettings";
|
|||||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||||
import { UserInfo } from "@/components/settings/UserInfo";
|
import { UserInfo } from "@/components/settings/UserInfo";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { clearLogs } from "@/utils/log";
|
import { clearLogs } from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { ScrollView, Switch, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [_user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Switch, View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Switch, View } from "react-native";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const [_settings, _updateSettings, pluginSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import * as Sharing from "expo-sharing";
|
import * as Sharing from "expo-sharing";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import Collapsible from "react-native-collapsible";
|
import Collapsible from "react-native-collapsible";
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -10,11 +16,6 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
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 { Text } from "@/components/common/Text";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
||||||
@@ -12,6 +5,13 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getOrSetDeviceId } from "@/utils/device";
|
import { getOrSetDeviceId } from "@/utils/device";
|
||||||
import { getStatistics } from "@/utils/optimize-server";
|
import { getStatistics } from "@/utils/optimize-server";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||||
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import type { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -7,18 +19,6 @@ import { useAtom } from "jotai";
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
|
|||||||
@@ -1,3 +1,22 @@
|
|||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
SortByOption,
|
||||||
|
SortOrderOption,
|
||||||
|
genreFilterAtom,
|
||||||
|
sortByAtom,
|
||||||
|
sortOptions,
|
||||||
|
sortOrderAtom,
|
||||||
|
sortOrderOptions,
|
||||||
|
tagsFilterAtom,
|
||||||
|
yearFilterAtom,
|
||||||
|
} from "@/utils/atoms/filters";
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
@@ -16,25 +35,6 @@ import type React from "react";
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FlatList, View } from "react-native";
|
import { FlatList, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
genreFilterAtom,
|
|
||||||
SortByOption,
|
|
||||||
SortOrderOption,
|
|
||||||
sortByAtom,
|
|
||||||
sortOptions,
|
|
||||||
sortOrderAtom,
|
|
||||||
sortOrderOptions,
|
|
||||||
tagsFilterAtom,
|
|
||||||
yearFilterAtom,
|
|
||||||
} from "@/utils/atoms/filters";
|
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -43,7 +43,7 @@ const page: React.FC = () => {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const [orientation, _setOrientation] = useState(
|
const [orientation, setOrientation] = useState(
|
||||||
ScreenOrientation.Orientation.PORTRAIT_UP,
|
ScreenOrientation.Orientation.PORTRAIT_UP,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { ItemContent } from "@/components/ItemContent";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
@@ -12,9 +15,6 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ItemContent } from "@/components/ItemContent";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useLocalSearchParams } from "expo-router";
|
|
||||||
import { uniqBy } from "lodash";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
import {
|
import {
|
||||||
type MovieResult,
|
type MovieResult,
|
||||||
|
Results,
|
||||||
type TvResult,
|
type TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { uniqBy } from "lodash";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
@@ -98,7 +99,7 @@ export default function page() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
renderItem={(item, _index) => (
|
renderItem={(item, index) => (
|
||||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
||||||
import { useLocalSearchParams } from "expo-router";
|
|
||||||
import { uniqBy } from "lodash";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
|
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
||||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||||
|
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
|
import Poster from "@/components/posters/Poster";
|
||||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
import {
|
import {
|
||||||
type MovieResult,
|
type MovieResult,
|
||||||
|
Results,
|
||||||
type TvResult,
|
type TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { router, useLocalSearchParams, useSegments } from "expo-router";
|
||||||
|
import { uniqBy } from "lodash";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { TouchableOpacity } from "react-native";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
@@ -92,7 +96,7 @@ export default function page() {
|
|||||||
{name}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
renderItem={(item, _index) => (
|
renderItem={(item, index) => (
|
||||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,27 +1,11 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import {
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
type BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetTextInput,
|
|
||||||
BottomSheetView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
|
||||||
import type React from "react";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { GenreTags } from "@/components/GenreTags";
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
import Cast from "@/components/jellyseerr/Cast";
|
|
||||||
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { JellyserrRatings } from "@/components/Ratings";
|
import { JellyserrRatings } from "@/components/Ratings";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import Cast from "@/components/jellyseerr/Cast";
|
||||||
|
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
||||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||||
import { ItemActions } from "@/components/series/SeriesActions";
|
import { ItemActions } from "@/components/series/SeriesActions";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
@@ -36,9 +20,23 @@ import type {
|
|||||||
TvResult,
|
TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetTextInput,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
|
||||||
import RequestModal from "@/components/jellyseerr/RequestModal";
|
import RequestModal from "@/components/jellyseerr/RequestModal";
|
||||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
@@ -48,7 +46,6 @@ const Page: React.FC = () => {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } =
|
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } =
|
||||||
params as unknown as {
|
params as unknown as {
|
||||||
@@ -239,65 +236,30 @@ const Page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View>
|
<View className='mb-4'>
|
||||||
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
||||||
</View>
|
</View>
|
||||||
{isLoading || isFetching ? (
|
{isLoading || isFetching ? (
|
||||||
<Button
|
<Button loading={true} disabled={true} color='purple' />
|
||||||
loading={true}
|
|
||||||
disabled={true}
|
|
||||||
color='purple'
|
|
||||||
className='mt-4'
|
|
||||||
/>
|
|
||||||
) : canRequest ? (
|
) : canRequest ? (
|
||||||
<Button color='purple' onPress={request} className='mt-4'>
|
<Button color='purple' onPress={request}>
|
||||||
{t("jellyseerr.request_button")}
|
{t("jellyseerr.request_button")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
details?.mediaInfo?.jellyfinMediaId && (
|
<Button
|
||||||
<View className='flex flex-row space-x-2 mt-4'>
|
className='bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
||||||
<Button
|
color='transparent'
|
||||||
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||||
color='transparent'
|
iconLeft={
|
||||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
<Ionicons name='warning-outline' size={24} color='white' />
|
||||||
iconLeft={
|
}
|
||||||
<Ionicons
|
style={{
|
||||||
name='warning-outline'
|
borderWidth: 1,
|
||||||
size={20}
|
borderStyle: "solid",
|
||||||
color='white'
|
}}
|
||||||
/>
|
>
|
||||||
}
|
{t("jellyseerr.report_issue_button")}
|
||||||
style={{
|
</Button>
|
||||||
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={() => {
|
|
||||||
const url =
|
|
||||||
mediaType === MediaType.MOVIE
|
|
||||||
? `/(auth)/(tabs)/(search)/items/page?id=${details?.mediaInfo.jellyfinMediaId}`
|
|
||||||
: `/(auth)/(tabs)/(search)/series/${details?.mediaInfo.jellyfinMediaId}`;
|
|
||||||
// @ts-expect-error
|
|
||||||
router.push(url);
|
|
||||||
}}
|
|
||||||
iconLeft={
|
|
||||||
<Ionicons name='play-outline' size={20} color='white' />
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: "solid",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='text-sm'>Play</Text>
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
<OverviewText text={result.overview} className='mt-4' />
|
<OverviewText text={result.overview} className='mt-4' />
|
||||||
</View>
|
</View>
|
||||||
@@ -382,7 +344,7 @@ const Page: React.FC = () => {
|
|||||||
</DropdownMenu.Label>
|
</DropdownMenu.Label>
|
||||||
{Object.entries(IssueTypeName)
|
{Object.entries(IssueTypeName)
|
||||||
.reverse()
|
.reverse()
|
||||||
.map(([key, value], _idx) => (
|
.map(([key, value], idx) => (
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key={value}
|
key={value}
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useLocalSearchParams } from "expo-router";
|
|
||||||
import { orderBy, uniqBy } from "lodash";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||||
@@ -14,6 +8,12 @@ import type {
|
|||||||
MovieResult,
|
MovieResult,
|
||||||
TvResult,
|
TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useSegments } from "expo-router";
|
||||||
|
import { orderBy, uniqBy } from "lodash";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
@@ -107,7 +107,7 @@ export default function page() {
|
|||||||
MainContent={() => (
|
MainContent={() => (
|
||||||
<OverviewText text={data?.details?.biography} className='mt-4' />
|
<OverviewText text={data?.details?.biography} className='mt-4' />
|
||||||
)}
|
)}
|
||||||
renderItem={(item, _index) => (
|
renderItem={(item, index) => (
|
||||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type {
|
||||||
|
MaterialTopTabNavigationEventMap,
|
||||||
|
MaterialTopTabNavigationOptions,
|
||||||
|
} from "@react-navigation/material-top-tabs";
|
||||||
|
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
|
||||||
|
import type {
|
||||||
|
ParamListBase,
|
||||||
|
TabNavigationState,
|
||||||
|
} from "@react-navigation/native";
|
||||||
|
import { Stack, withLayoutContext } from "expo-router";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { HourHeader } from "@/components/livetv/HourHeader";
|
||||||
|
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
|
||||||
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dimensions,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
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: guideInfo } = useQuery({
|
||||||
|
queryKey: ["livetv", "guideInfo"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await getLiveTvApi(api!).getGuideInfo();
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,3 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import type React from "react";
|
|
||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, View } from "react-native";
|
|
||||||
import { AddToFavorites } from "@/components/AddToFavorites";
|
import { AddToFavorites } from "@/components/AddToFavorites";
|
||||||
import { DownloadItems } from "@/components/DownloadItem";
|
import { DownloadItems } from "@/components/DownloadItem";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
@@ -18,6 +8,16 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, View } from "react-native";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|||||||
@@ -1,3 +1,34 @@
|
|||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useEffect, useMemo } from "react";
|
||||||
|
import { FlatList, View, useWindowDimensions } from "react-native";
|
||||||
|
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
SortByOption,
|
||||||
|
SortOrderOption,
|
||||||
|
genreFilterAtom,
|
||||||
|
getSortByPreference,
|
||||||
|
getSortOrderPreference,
|
||||||
|
sortByAtom,
|
||||||
|
sortByPreferenceAtom,
|
||||||
|
sortOptions,
|
||||||
|
sortOrderAtom,
|
||||||
|
sortOrderOptions,
|
||||||
|
sortOrderPreferenceAtom,
|
||||||
|
tagsFilterAtom,
|
||||||
|
yearFilterAtom,
|
||||||
|
} from "@/utils/atoms/filters";
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
@@ -9,38 +40,8 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, { useCallback, useEffect, useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
genreFilterAtom,
|
|
||||||
getSortByPreference,
|
|
||||||
getSortOrderPreference,
|
|
||||||
SortByOption,
|
|
||||||
SortOrderOption,
|
|
||||||
sortByAtom,
|
|
||||||
sortByPreferenceAtom,
|
|
||||||
sortOptions,
|
|
||||||
sortOrderAtom,
|
|
||||||
sortOrderOptions,
|
|
||||||
sortOrderPreferenceAtom,
|
|
||||||
tagsFilterAtom,
|
|
||||||
yearFilterAtom,
|
|
||||||
} from "@/utils/atoms/filters";
|
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -432,6 +433,15 @@ const Page = () => {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (flatData.length === 0)
|
||||||
|
return (
|
||||||
|
<View className='h-full w-full flex justify-center items-center'>
|
||||||
|
<Text className='text-lg text-neutral-500'>
|
||||||
|
{t("library.no_items_found")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
key={orientation}
|
key={orientation}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
getUserViewsApi,
|
getUserViewsApi,
|
||||||
@@ -9,11 +14,6 @@ import { useEffect, useMemo } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { StyleSheet, View } from "react-native";
|
import { StyleSheet, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function index() {
|
export default function index() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Stack } from "expo-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
import {
|
import {
|
||||||
commonScreenOptions,
|
commonScreenOptions,
|
||||||
nestedTabPageScreenOptions,
|
nestedTabPageScreenOptions,
|
||||||
} from "@/components/stacks/NestedTabPageStack";
|
} from "@/components/stacks/NestedTabPageStack";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
export default function SearchLayout() {
|
export default function SearchLayout() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -1,30 +1,9 @@
|
|||||||
import type {
|
|
||||||
BaseItemDto,
|
|
||||||
BaseItemKind,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import axios from "axios";
|
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { useDebounce } from "use-debounce";
|
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
|
import { Tag } from "@/components/GenreTags";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { Tag } from "@/components/GenreTags";
|
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import {
|
import {
|
||||||
JellyseerrSearchSort,
|
JellyseerrSearchSort,
|
||||||
JellyserrIndexPage,
|
JellyserrIndexPage,
|
||||||
@@ -37,6 +16,27 @@ import { useJellyseerr } from "@/hooks/useJellyseerr";
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemKind,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import axios from "axios";
|
||||||
|
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
|
|
||||||
type SearchType = "Library" | "Discover";
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
@@ -331,7 +331,7 @@ export default function search() {
|
|||||||
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
|
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
header={t("search.movies")}
|
header={t("search.movies")}
|
||||||
items={movies}
|
ids={movies?.map((m) => m.Id!)}
|
||||||
renderItem={(item: BaseItemDto) => (
|
renderItem={(item: BaseItemDto) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
@@ -349,7 +349,7 @@ export default function search() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
items={series}
|
ids={series?.map((m) => m.Id!)}
|
||||||
header={t("search.series")}
|
header={t("search.series")}
|
||||||
renderItem={(item: BaseItemDto) => (
|
renderItem={(item: BaseItemDto) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
@@ -368,7 +368,7 @@ export default function search() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
items={episodes}
|
ids={episodes?.map((m) => m.Id!)}
|
||||||
header={t("search.episodes")}
|
header={t("search.episodes")}
|
||||||
renderItem={(item: BaseItemDto) => (
|
renderItem={(item: BaseItemDto) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
@@ -382,7 +382,7 @@ export default function search() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
items={collections}
|
ids={collections?.map((m) => m.Id!)}
|
||||||
header={t("search.collections")}
|
header={t("search.collections")}
|
||||||
renderItem={(item: BaseItemDto) => (
|
renderItem={(item: BaseItemDto) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
@@ -398,7 +398,7 @@ export default function search() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
items={actors}
|
ids={actors?.map((m) => m.Id!)}
|
||||||
header={t("search.actors")}
|
header={t("search.actors")}
|
||||||
renderItem={(item: BaseItemDto) => (
|
renderItem={(item: BaseItemDto) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
@@ -434,10 +434,7 @@ export default function search() {
|
|||||||
<View className='mt-4 flex flex-col items-center space-y-2'>
|
<View className='mt-4 flex flex-col items-center space-y-2'>
|
||||||
{exampleSearches.map((e) => (
|
{exampleSearches.map((e) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => setSearch(e)}
|
||||||
setSearch(e);
|
|
||||||
searchBarRef.current?.setText(e);
|
|
||||||
}}
|
|
||||||
key={e}
|
key={e}
|
||||||
className='mb-2'
|
className='mb-2'
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
import {
|
import React, { useCallback, useRef } from "react";
|
||||||
createNativeBottomTabNavigator,
|
|
||||||
type NativeBottomTabNavigationEventMap,
|
|
||||||
} from "@bottom-tabs/react-navigation";
|
|
||||||
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
const { Navigator } = createNativeBottomTabNavigator();
|
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type NativeBottomTabNavigationEventMap,
|
||||||
|
createNativeBottomTabNavigator,
|
||||||
|
} from "@bottom-tabs/react-navigation";
|
||||||
|
|
||||||
|
const { Navigator } = createNativeBottomTabNavigator();
|
||||||
import type { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
import type { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
||||||
|
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "@react-navigation/native";
|
} from "@react-navigation/native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
import { Colors } from "@/constants/Colors";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { eventBus } from "@/utils/eventBus";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
|
|
||||||
export const NativeTabs = withLayoutContext<
|
export const NativeTabs = withLayoutContext<
|
||||||
BottomTabNavigationOptions,
|
BottomTabNavigationOptions,
|
||||||
@@ -62,7 +64,7 @@ export default function TabLayout() {
|
|||||||
<NativeTabs.Screen redirect name='index' />
|
<NativeTabs.Screen redirect name='index' />
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
listeners={({ navigation }) => ({
|
listeners={({ navigation }) => ({
|
||||||
tabPress: (_e) => {
|
tabPress: (e) => {
|
||||||
eventBus.emit("scrollToTop");
|
eventBus.emit("scrollToTop");
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
@@ -81,7 +83,7 @@ export default function TabLayout() {
|
|||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
listeners={({ navigation }) => ({
|
listeners={({ navigation }) => ({
|
||||||
tabPress: (_e) => {
|
tabPress: (e) => {
|
||||||
eventBus.emit("searchTabPressed");
|
eventBus.emit("searchTabPressed");
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,7 +1,33 @@
|
|||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
|
import React, { useLayoutEffect } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
|
const [settings] = useSettings();
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
|
if (!settings.followDeviceOrientation && settings.defaultVideoOrientation) {
|
||||||
|
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
|
if (settings.followDeviceOrientation === true) {
|
||||||
|
ScreenOrientation.unlockAsync();
|
||||||
|
} else {
|
||||||
|
ScreenOrientation.lockAsync(
|
||||||
|
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SystemBars hidden />
|
<SystemBars hidden />
|
||||||
|
|||||||
@@ -1,3 +1,30 @@
|
|||||||
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Controls } from "@/components/video-player/controls/Controls";
|
||||||
|
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
|
import { MpvPlayerView, ProgressUpdatePayload, VlcPlayerView } from "@/modules";
|
||||||
|
// import type {
|
||||||
|
// PipStartedPayload,
|
||||||
|
// PlaybackStatePayload,
|
||||||
|
// ProgressUpdatePayload,
|
||||||
|
// VlcPlayerViewRef,
|
||||||
|
// } from "@/modules/VlcPlayer.types";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
MpvPlayerViewRef,
|
||||||
|
PipStartedPayload,
|
||||||
|
PlaybackStatePayload,
|
||||||
|
} from "@/modules/MpvPlayer.types";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
import native from "@/utils/profiles/native";
|
||||||
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
import {
|
import {
|
||||||
type BaseItemDto,
|
type BaseItemDto,
|
||||||
type MediaSourceInfo,
|
type MediaSourceInfo,
|
||||||
@@ -11,42 +38,25 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||||
import { router, useGlobalSearchParams, useNavigation } from "expo-router";
|
import { useGlobalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, View } from "react-native";
|
import { Alert, Platform, View } from "react-native";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
const downloadProvider = !Platform.isTV
|
||||||
import { Text } from "@/components/common/Text";
|
? require("@/providers/DownloadProvider")
|
||||||
import { Loader } from "@/components/Loader";
|
: null;
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
|
||||||
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
|
||||||
import { VlcPlayerView } from "@/modules";
|
|
||||||
import type {
|
|
||||||
PipStartedPayload,
|
|
||||||
PlaybackStatePayload,
|
|
||||||
ProgressUpdatePayload,
|
|
||||||
VlcPlayerViewRef,
|
|
||||||
} from "@/modules/VlcPlayer.types";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
import { 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() {
|
export default function page() {
|
||||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
const videoRef = useRef<MpvPlayerViewRef>(null);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -54,13 +64,8 @@ export default function page() {
|
|||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(() => {
|
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||||
// Load persisted state from storage
|
|
||||||
const saved = storage.getBoolean(IGNORE_SAFE_AREAS_KEY);
|
|
||||||
return saved ?? false;
|
|
||||||
});
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
const [isBuffering, setIsBuffering] = useState(true);
|
||||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||||
const [isPipStarted, setIsPipStarted] = useState(false);
|
const [isPipStarted, setIsPipStarted] = useState(false);
|
||||||
@@ -68,9 +73,10 @@ export default function page() {
|
|||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
const isSeeking = useSharedValue(false);
|
const isSeeking = useSharedValue(false);
|
||||||
const cacheProgress = useSharedValue(0);
|
const cacheProgress = useSharedValue(0);
|
||||||
const VolumeManager = require("react-native-volume-manager");
|
let getDownloadedItem = null;
|
||||||
|
if (!Platform.isTV) {
|
||||||
const getDownloadedItem = downloadProvider.useDownload();
|
getDownloadedItem = downloadProvider.useDownload();
|
||||||
|
}
|
||||||
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
@@ -81,11 +87,6 @@ export default function page() {
|
|||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Persist ignoreSafeAreas state whenever it changes
|
|
||||||
useEffect(() => {
|
|
||||||
storage.set(IGNORE_SAFE_AREAS_KEY, ignoreSafeAreas);
|
|
||||||
}, [ignoreSafeAreas]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
itemId,
|
itemId,
|
||||||
audioIndex: audioIndexStr,
|
audioIndex: audioIndexStr,
|
||||||
@@ -93,7 +94,6 @@ export default function page() {
|
|||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
bitrateValue: bitrateValueStr,
|
bitrateValue: bitrateValueStr,
|
||||||
offline: offlineStr,
|
offline: offlineStr,
|
||||||
playbackPosition: playbackPositionFromUrl,
|
|
||||||
} = useGlobalSearchParams<{
|
} = useGlobalSearchParams<{
|
||||||
itemId: string;
|
itemId: string;
|
||||||
audioIndex: string;
|
audioIndex: string;
|
||||||
@@ -101,8 +101,6 @@ export default function page() {
|
|||||||
mediaSourceId: string;
|
mediaSourceId: string;
|
||||||
bitrateValue: string;
|
bitrateValue: string;
|
||||||
offline: string;
|
offline: string;
|
||||||
/** Playback position in ticks. */
|
|
||||||
playbackPosition?: string;
|
|
||||||
}>();
|
}>();
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -124,20 +122,12 @@ export default function page() {
|
|||||||
isError: false,
|
isError: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 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, item]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchItemData = async () => {
|
const fetchItemData = async () => {
|
||||||
setItemStatus({ isLoading: true, isError: false });
|
setItemStatus({ isLoading: true, isError: false });
|
||||||
try {
|
try {
|
||||||
let fetchedItem: BaseItemDto | null = null;
|
let fetchedItem: BaseItemDto | null = null;
|
||||||
if (offline) {
|
if (offline && !Platform.isTV) {
|
||||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||||
if (data) fetchedItem = data.item as BaseItemDto;
|
if (data) fetchedItem = data.item as BaseItemDto;
|
||||||
} else {
|
} else {
|
||||||
@@ -148,10 +138,11 @@ export default function page() {
|
|||||||
fetchedItem = res.data;
|
fetchedItem = res.data;
|
||||||
}
|
}
|
||||||
setItem(fetchedItem);
|
setItem(fetchedItem);
|
||||||
setItemStatus({ isLoading: false, isError: false });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch item:", error);
|
console.error("Failed to fetch item:", error);
|
||||||
setItemStatus({ isLoading: false, isError: true });
|
setItemStatus({ isLoading: false, isError: true });
|
||||||
|
} finally {
|
||||||
|
setItemStatus({ isLoading: false, isError: false });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,11 +165,9 @@ export default function page() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStreamData = async () => {
|
const fetchStreamData = async () => {
|
||||||
setStreamStatus({ isLoading: true, isError: false });
|
|
||||||
const native = await generateDeviceProfile();
|
|
||||||
try {
|
try {
|
||||||
let result: Stream | null = null;
|
let result: Stream | null = null;
|
||||||
if (offline) {
|
if (offline && !Platform.isTV) {
|
||||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||||
if (!data?.mediaSource) return;
|
if (!data?.mediaSource) return;
|
||||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
const url = await getDownloadedFileUrl(data.item.Id!);
|
||||||
@@ -189,7 +178,7 @@ export default function page() {
|
|||||||
const res = await getStreamUrl({
|
const res = await getStreamUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
startTimeTicks: getInitialPlaybackTicks(),
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
audioStreamIndex: audioIndex,
|
audioStreamIndex: audioIndex,
|
||||||
maxStreamingBitrate: bitrateValue,
|
maxStreamingBitrate: bitrateValue,
|
||||||
@@ -209,10 +198,11 @@ export default function page() {
|
|||||||
result = { mediaSource, sessionId, url };
|
result = { mediaSource, sessionId, url };
|
||||||
}
|
}
|
||||||
setStream(result);
|
setStream(result);
|
||||||
setStreamStatus({ isLoading: false, isError: false });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch stream:", error);
|
console.error("Failed to fetch stream:", error);
|
||||||
setStreamStatus({ isLoading: false, isError: true });
|
setStreamStatus({ isLoading: false, isError: true });
|
||||||
|
} finally {
|
||||||
|
setStreamStatus({ isLoading: false, isError: false });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchStreamData();
|
fetchStreamData();
|
||||||
@@ -235,7 +225,7 @@ export default function page() {
|
|||||||
setIsPlaying(!isPlaying);
|
setIsPlaying(!isPlaying);
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
reportPlaybackProgress();
|
reportPlaybackStopped();
|
||||||
} else {
|
} else {
|
||||||
videoRef.current?.play();
|
videoRef.current?.play();
|
||||||
await getPlaystateApi(api!).reportPlaybackStart({
|
await getPlaystateApi(api!).reportPlaybackStart({
|
||||||
@@ -255,15 +245,7 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidateProgressCache();
|
revalidateProgressCache();
|
||||||
}, [
|
}, [api, item, mediaSourceId, stream]);
|
||||||
api,
|
|
||||||
item,
|
|
||||||
mediaSourceId,
|
|
||||||
stream,
|
|
||||||
progress,
|
|
||||||
offline,
|
|
||||||
revalidateProgressCache,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
reportPlaybackStopped();
|
reportPlaybackStopped();
|
||||||
@@ -289,7 +271,7 @@ export default function page() {
|
|||||||
isPaused: !isPlaying,
|
isPaused: !isPlaying,
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
playSessionId: stream.sessionId,
|
playSessionId: stream.sessionId,
|
||||||
isMuted: isMuted,
|
isMuted: false,
|
||||||
canSeek: true,
|
canSeek: true,
|
||||||
repeatMode: RepeatMode.RepeatNone,
|
repeatMode: RepeatMode.RepeatNone,
|
||||||
playbackOrder: PlaybackOrder.Default,
|
playbackOrder: PlaybackOrder.Default,
|
||||||
@@ -307,12 +289,8 @@ export default function page() {
|
|||||||
|
|
||||||
progress.set(currentTime);
|
progress.set(currentTime);
|
||||||
|
|
||||||
// Update the playback position in the URL.
|
|
||||||
router.setParams({
|
|
||||||
playbackPosition: msToTicks(currentTime).toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (offline) return;
|
if (offline) return;
|
||||||
|
|
||||||
if (!item?.Id || !stream) return;
|
if (!item?.Id || !stream) return;
|
||||||
|
|
||||||
reportPlaybackProgress();
|
reportPlaybackProgress();
|
||||||
@@ -352,80 +330,18 @@ export default function page() {
|
|||||||
progress,
|
progress,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Gets the initial playback position in seconds. */
|
|
||||||
const startPosition = useMemo(() => {
|
const startPosition = useMemo(() => {
|
||||||
if (offline) return 0;
|
if (offline) return 0;
|
||||||
return ticksToSeconds(getInitialPlaybackTicks());
|
return item?.UserData?.PlaybackPositionTicks
|
||||||
}, [offline, getInitialPlaybackTicks]);
|
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
||||||
|
: 0;
|
||||||
const volumeUpCb = useCallback(async () => {
|
}, [item]);
|
||||||
try {
|
|
||||||
const { volume: currentVolume } = await VolumeManager.getVolume();
|
|
||||||
const newVolume = Math.min(currentVolume + 0.1, 1.0);
|
|
||||||
|
|
||||||
await VolumeManager.setVolume(newVolume);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error adjusting volume:", error);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
const [previousVolume, setPreviousVolume] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const toggleMuteCb = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const { volume: currentVolume } = await VolumeManager.getVolume();
|
|
||||||
const currentVolumePercent = currentVolume * 100;
|
|
||||||
|
|
||||||
if (currentVolumePercent > 0) {
|
|
||||||
// Currently not muted, so mute
|
|
||||||
setPreviousVolume(currentVolumePercent);
|
|
||||||
await VolumeManager.setVolume(0);
|
|
||||||
setIsMuted(true);
|
|
||||||
} else {
|
|
||||||
// Currently muted, so restore previous volume
|
|
||||||
const volumeToRestore = previousVolume || 50; // Default to 50% if no previous volume
|
|
||||||
await VolumeManager.setVolume(volumeToRestore / 100);
|
|
||||||
setPreviousVolume(null);
|
|
||||||
setIsMuted(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error toggling mute:", error);
|
|
||||||
}
|
|
||||||
}, [previousVolume]);
|
|
||||||
const volumeDownCb = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const { volume: currentVolume } = await VolumeManager.getVolume();
|
|
||||||
const newVolume = Math.max(currentVolume - 0.1, 0); // Decrease by 10%
|
|
||||||
console.log(
|
|
||||||
"Volume Down",
|
|
||||||
Math.round(currentVolume * 100),
|
|
||||||
"→",
|
|
||||||
Math.round(newVolume * 100),
|
|
||||||
);
|
|
||||||
await VolumeManager.setVolume(newVolume);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error adjusting volume:", error);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setVolumeCb = useCallback(async (newVolume: number) => {
|
|
||||||
try {
|
|
||||||
const clampedVolume = Math.max(0, Math.min(newVolume, 100));
|
|
||||||
console.log("Setting volume to", clampedVolume);
|
|
||||||
await VolumeManager.setVolume(clampedVolume / 100);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error setting volume:", error);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
isPlaying: isPlaying,
|
isPlaying: isPlaying,
|
||||||
togglePlay: togglePlay,
|
togglePlay: togglePlay,
|
||||||
stopPlayback: stop,
|
stopPlayback: stop,
|
||||||
offline,
|
offline,
|
||||||
toggleMute: toggleMuteCb,
|
|
||||||
volumeUp: volumeUpCb,
|
|
||||||
volumeDown: volumeDownCb,
|
|
||||||
setVolume: setVolumeCb,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const onPlaybackStateChanged = useCallback(
|
const onPlaybackStateChanged = useCallback(
|
||||||
@@ -434,14 +350,14 @@ export default function page() {
|
|||||||
if (state === "Playing") {
|
if (state === "Playing") {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
reportPlaybackProgress();
|
reportPlaybackProgress();
|
||||||
await activateKeepAwakeAsync();
|
if (!Platform.isTV) await activateKeepAwakeAsync();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state === "Paused") {
|
if (state === "Paused") {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
reportPlaybackProgress();
|
reportPlaybackProgress();
|
||||||
await deactivateKeepAwake();
|
if (!Platform.isTV) await deactivateKeepAwake();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,20 +397,23 @@ export default function page() {
|
|||||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||||
|
|
||||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||||
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
const initOptions = [
|
||||||
if (
|
`--sub-text-scale=${settings.subtitleSize}`,
|
||||||
chosenSubtitleTrack &&
|
`--start=${startPosition}`,
|
||||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
];
|
||||||
) {
|
// if (
|
||||||
const finalIndex = notTranscoding
|
// chosenSubtitleTrack &&
|
||||||
? allSubs.indexOf(chosenSubtitleTrack)
|
// (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
||||||
: textSubs.indexOf(chosenSubtitleTrack);
|
// ) {
|
||||||
initOptions.push(`--sub-track=${finalIndex}`);
|
// const finalIndex = notTranscoding
|
||||||
}
|
// ? allSubs.indexOf(chosenSubtitleTrack)
|
||||||
|
// : textSubs.indexOf(chosenSubtitleTrack);
|
||||||
|
// initOptions.push(`--sub-track=${finalIndex}`);
|
||||||
|
// }
|
||||||
|
|
||||||
if (notTranscoding && chosenAudioTrack) {
|
// if (notTranscoding && chosenAudioTrack) {
|
||||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
// initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||||
}
|
// }
|
||||||
|
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
@@ -512,7 +431,7 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (itemStatus.isError || streamStatus.isError)
|
if (!item || !stream || itemStatus.isError || streamStatus.isError)
|
||||||
return (
|
return (
|
||||||
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
|
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
|
||||||
<Text className='text-white'>{t("player.error")}</Text>
|
<Text className='text-white'>{t("player.error")}</Text>
|
||||||
@@ -533,7 +452,7 @@ export default function page() {
|
|||||||
paddingRight: ignoreSafeAreas ? 0 : insets.right,
|
paddingRight: ignoreSafeAreas ? 0 : insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VlcPlayerView
|
<MpvPlayerView
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
source={{
|
source={{
|
||||||
uri: stream?.url || "",
|
uri: stream?.url || "",
|
||||||
@@ -561,7 +480,7 @@ export default function page() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{videoRef.current && !isPipStarted && isMounted === true && item ? (
|
{videoRef.current && !isPipStarted && isMounted === true ? (
|
||||||
<Controls
|
<Controls
|
||||||
mediaSource={stream?.mediaSource}
|
mediaSource={stream?.mediaSource}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -577,7 +496,6 @@ export default function page() {
|
|||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
ignoreSafeAreas={ignoreSafeAreas}
|
||||||
isVideoLoaded={isVideoLoaded}
|
isVideoLoaded={isVideoLoaded}
|
||||||
startPictureInPicture={videoRef?.current?.startPictureInPicture}
|
|
||||||
play={videoRef.current?.play}
|
play={videoRef.current?.play}
|
||||||
pause={videoRef.current?.pause}
|
pause={videoRef.current?.pause}
|
||||||
seek={videoRef.current?.seekTo}
|
seek={videoRef.current?.seekTo}
|
||||||
|
|||||||
106
app/_layout.tsx
106
app/_layout.tsx
@@ -1,15 +1,11 @@
|
|||||||
import "@/augmentations";
|
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 i18n from "@/i18n";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
import {
|
import {
|
||||||
|
JellyfinProvider,
|
||||||
apiAtom,
|
apiAtom,
|
||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
getTokenFromStorage,
|
getTokenFromStorage,
|
||||||
JellyfinProvider,
|
|
||||||
} from "@/providers/JellyfinProvider";
|
} from "@/providers/JellyfinProvider";
|
||||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
@@ -28,33 +24,35 @@ import {
|
|||||||
} from "@/utils/log";
|
} from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
||||||
|
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||||
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { Platform } from "react-native";
|
||||||
const BackGroundDownloader = !Platform.isTV
|
const BackGroundDownloader = !Platform.isTV
|
||||||
? require("@kesha-antonov/react-native-background-downloader")
|
? require("@kesha-antonov/react-native-background-downloader")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
const BackgroundFetch = !Platform.isTV
|
||||||
|
? require("expo-background-fetch")
|
||||||
|
: null;
|
||||||
import * as Device from "expo-device";
|
import * as Device from "expo-device";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
|
|
||||||
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||||
|
|
||||||
import { router, Stack, useSegments } from "expo-router";
|
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { Stack, router, useSegments } from "expo-router";
|
||||||
import * as TaskManager from "expo-task-manager";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
|
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
|
||||||
import { getLocales } from "expo-localization";
|
import { getLocales } from "expo-localization";
|
||||||
import { Provider as JotaiProvider } from "jotai";
|
import { Provider as JotaiProvider } from "jotai";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
import { Appearance, AppState } from "react-native";
|
import { AppState, Appearance } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { store } from "@/utils/store";
|
||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
import type { EventSubscription } from "expo-modules-core";
|
import type { EventSubscription } from "expo-modules-core";
|
||||||
import type {
|
import type {
|
||||||
@@ -64,8 +62,6 @@ import type {
|
|||||||
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Toaster } from "sonner-native";
|
import { Toaster } from "sonner-native";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { store } from "@/utils/store";
|
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
@@ -126,9 +122,7 @@ if (!Platform.isTV) {
|
|||||||
console.log("TaskManager ~ sessions trigger");
|
console.log("TaskManager ~ sessions trigger");
|
||||||
|
|
||||||
const api = store.get(apiAtom);
|
const api = store.get(apiAtom);
|
||||||
if (api === null || api === undefined) {
|
if (api === null || api === undefined) return;
|
||||||
return { value: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getSessionApi(api).getSessions({
|
const response = await getSessionApi(api).getSessions({
|
||||||
activeWithinSeconds: 360,
|
activeWithinSeconds: 360,
|
||||||
@@ -137,7 +131,7 @@ if (!Platform.isTV) {
|
|||||||
const result = response.data.filter((s) => s.NowPlayingItem);
|
const result = response.data.filter((s) => s.NowPlayingItem);
|
||||||
Notifications.setBadgeCountAsync(result.length);
|
Notifications.setBadgeCountAsync(result.length);
|
||||||
|
|
||||||
return { value: "success" };
|
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||||
});
|
});
|
||||||
|
|
||||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||||
@@ -147,18 +141,20 @@ if (!Platform.isTV) {
|
|||||||
|
|
||||||
const settingsData = storage.getString("settings");
|
const settingsData = storage.getString("settings");
|
||||||
|
|
||||||
if (!settingsData) return { value: null };
|
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||||
const url = settings?.optimizedVersionsServerUrl;
|
const url = settings?.optimizedVersionsServerUrl;
|
||||||
|
|
||||||
if (!settings?.autoDownload || !url) return { value: null };
|
if (!settings?.autoDownload || !url)
|
||||||
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const token = getTokenFromStorage();
|
const token = getTokenFromStorage();
|
||||||
const deviceId = getOrSetDeviceId();
|
const deviceId = getOrSetDeviceId();
|
||||||
const baseDirectory = FileSystem.documentDirectory;
|
const baseDirectory = FileSystem.documentDirectory;
|
||||||
|
|
||||||
if (!token || !deviceId || !baseDirectory) return { value: null };
|
if (!token || !deviceId || !baseDirectory)
|
||||||
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const jobs = await getAllJobsByDeviceId({
|
const jobs = await getAllJobsByDeviceId({
|
||||||
deviceId,
|
deviceId,
|
||||||
@@ -191,7 +187,7 @@ if (!Platform.isTV) {
|
|||||||
})
|
})
|
||||||
.done(() => {
|
.done(() => {
|
||||||
console.log("TaskManager ~ Download completed: ", job.id);
|
console.log("TaskManager ~ Download completed: ", job.id);
|
||||||
_saveDownloadedItemInfo(job.item);
|
saveDownloadedItemInfo(job.item);
|
||||||
BackGroundDownloader.completeHandler(job.id);
|
BackGroundDownloader.completeHandler(job.id);
|
||||||
cancelJobById({
|
cancelJobById({
|
||||||
authHeader: token,
|
authHeader: token,
|
||||||
@@ -229,7 +225,7 @@ if (!Platform.isTV) {
|
|||||||
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
||||||
|
|
||||||
// Be sure to return the successful result type!
|
// Be sure to return the successful result type!
|
||||||
return { value: "success" };
|
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,52 +414,42 @@ function Layout() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.isTV) {
|
if (Platform.isTV) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (segments.includes("direct-player" as never)) {
|
if (segments.includes("direct-player" as never)) {
|
||||||
if (
|
|
||||||
!settings.followDeviceOrientation &&
|
|
||||||
settings.defaultVideoOrientation
|
|
||||||
) {
|
|
||||||
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the user has auto rotate enabled, unlock the orientation
|
||||||
if (settings.followDeviceOrientation === true) {
|
if (settings.followDeviceOrientation === true) {
|
||||||
ScreenOrientation.unlockAsync();
|
ScreenOrientation.unlockAsync();
|
||||||
} else {
|
} else {
|
||||||
|
// If the user has auto rotate disabled, lock the orientation to portrait
|
||||||
ScreenOrientation.lockAsync(
|
ScreenOrientation.lockAsync(
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}, [settings.followDeviceOrientation, segments]);
|
||||||
settings.followDeviceOrientation,
|
|
||||||
settings.defaultVideoOrientation,
|
|
||||||
segments,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = AppState.addEventListener(
|
const subscription = AppState.addEventListener(
|
||||||
"change",
|
"change",
|
||||||
(nextAppState) => {
|
(nextAppState) => {
|
||||||
if (
|
if (
|
||||||
appState.current.match(/inactive|background/) &&
|
appState.current.match(/inactive|background/) &&
|
||||||
nextAppState === "active"
|
nextAppState === "active"
|
||||||
) {
|
) {
|
||||||
BackGroundDownloader.checkForExistingDownloads();
|
BackGroundDownloader.checkForExistingDownloads();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
BackGroundDownloader.checkForExistingDownloads();
|
BackGroundDownloader.checkForExistingDownloads();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
subscription.remove();
|
subscription.remove();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -529,7 +515,7 @@ function Layout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _saveDownloadedItemInfo(item: BaseItemDto) {
|
function saveDownloadedItemInfo(item: BaseItemDto) {
|
||||||
try {
|
try {
|
||||||
const downloadedItems = storage.getString("downloadedItems");
|
const downloadedItems = storage.getString("downloadedItems");
|
||||||
const items: BaseItemDto[] = downloadedItems
|
const items: BaseItemDto[] = downloadedItems
|
||||||
|
|||||||
260
app/login.tsx
260
app/login.tsx
@@ -1,29 +1,29 @@
|
|||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||||
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
|
import { Input } from "@/components/common/Input";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { t } from "i18next";
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Keyboard,
|
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { z } from "zod";
|
import { Keyboard } from "react-native";
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Input } from "@/components/common/Input";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
|
||||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
|
||||||
|
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { z } from "zod";
|
||||||
const CredentialsSchema = z.object({
|
const CredentialsSchema = z.object({
|
||||||
username: z.string().min(1, t("login.username_required")),
|
username: z.string().min(1, t("login.username_required")),
|
||||||
});
|
});
|
||||||
@@ -199,7 +199,7 @@ const Login: React.FC = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.error_title"),
|
t("login.error_title"),
|
||||||
t("login.failed_to_initiate_quick_connect"),
|
t("login.failed_to_initiate_quick_connect"),
|
||||||
@@ -213,127 +213,133 @@ const Login: React.FC = () => {
|
|||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
>
|
>
|
||||||
{api?.basePath ? (
|
{api?.basePath ? (
|
||||||
<View className='flex flex-col h-full relative items-center justify-center'>
|
<>
|
||||||
<View className='px-4 -mt-20 w-full'>
|
<View className='flex flex-col h-full relative items-center justify-center'>
|
||||||
<View className='flex flex-col space-y-2'>
|
<View className='px-4 -mt-20 w-full'>
|
||||||
<Text className='text-2xl font-bold -mb-2'>
|
<View className='flex flex-col space-y-2'>
|
||||||
{serverName ? (
|
<Text className='text-2xl font-bold -mb-2'>
|
||||||
<>
|
{serverName ? (
|
||||||
{`${t("login.login_to_title")} `}
|
<>
|
||||||
<Text className='text-purple-600'>{serverName}</Text>
|
{`${t("login.login_to_title")} `}
|
||||||
</>
|
<Text className='text-purple-600'>{serverName}</Text>
|
||||||
) : (
|
</>
|
||||||
t("login.login_title")
|
) : (
|
||||||
)}
|
t("login.login_title")
|
||||||
</Text>
|
)}
|
||||||
<Text className='text-xs text-neutral-400'>{api.basePath}</Text>
|
</Text>
|
||||||
<Input
|
<Text className='text-xs text-neutral-400'>
|
||||||
placeholder={t("login.username_placeholder")}
|
{api.basePath}
|
||||||
onChangeText={(text) =>
|
</Text>
|
||||||
setCredentials({ ...credentials, username: text })
|
<Input
|
||||||
}
|
placeholder={t("login.username_placeholder")}
|
||||||
value={credentials.username}
|
onChangeText={(text) =>
|
||||||
keyboardType='default'
|
setCredentials({ ...credentials, username: text })
|
||||||
returnKeyType='done'
|
}
|
||||||
autoCapitalize='none'
|
value={credentials.username}
|
||||||
// Changed from username to oneTimeCode because it is a known issue in RN
|
keyboardType='default'
|
||||||
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
|
returnKeyType='done'
|
||||||
textContentType='oneTimeCode'
|
autoCapitalize='none'
|
||||||
clearButtonMode='while-editing'
|
// Changed from username to oneTimeCode because it is a known issue in RN
|
||||||
maxLength={500}
|
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
|
||||||
/>
|
textContentType='oneTimeCode'
|
||||||
|
clearButtonMode='while-editing'
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("login.password_placeholder")}
|
placeholder={t("login.password_placeholder")}
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
setCredentials({ ...credentials, password: text })
|
setCredentials({ ...credentials, password: text })
|
||||||
}
|
}
|
||||||
value={credentials.password}
|
value={credentials.password}
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
keyboardType='default'
|
keyboardType='default'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
textContentType='password'
|
textContentType='password'
|
||||||
clearButtonMode='while-editing'
|
clearButtonMode='while-editing'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
<View className='flex flex-row items-center justify-between'>
|
<View className='flex flex-row items-center justify-between'>
|
||||||
<Button
|
<Button
|
||||||
onPress={handleLogin}
|
onPress={handleLogin}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
className='flex-1 mr-2'
|
className='flex-1 mr-2'
|
||||||
>
|
>
|
||||||
{t("login.login_button")}
|
{t("login.login_button")}
|
||||||
</Button>
|
</Button>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handleQuickConnect}
|
onPress={handleQuickConnect}
|
||||||
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
|
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
|
||||||
>
|
>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name='cellphone-lock'
|
name='cellphone-lock'
|
||||||
size={24}
|
size={24}
|
||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View className='flex flex-col h-full items-center justify-center w-full'>
|
|
||||||
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
|
||||||
<Image
|
|
||||||
style={{
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
marginLeft: -23,
|
|
||||||
marginBottom: -20,
|
|
||||||
}}
|
|
||||||
source={require("@/assets/images/StreamyFinFinal.png")}
|
|
||||||
/>
|
|
||||||
<Text className='text-3xl font-bold'>Streamyfin</Text>
|
|
||||||
<Text className='text-neutral-500'>
|
|
||||||
{t("server.enter_url_to_jellyfin_server")}
|
|
||||||
</Text>
|
|
||||||
<Input
|
|
||||||
aria-label='Server URL'
|
|
||||||
placeholder={t("server.server_url_placeholder")}
|
|
||||||
onChangeText={setServerURL}
|
|
||||||
value={serverURL}
|
|
||||||
keyboardType='url'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
textContentType='URL'
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
loading={loadingServerCheck}
|
|
||||||
disabled={loadingServerCheck}
|
|
||||||
onPress={async () => {
|
|
||||||
await handleConnect(serverURL);
|
|
||||||
}}
|
|
||||||
className='w-full grow'
|
|
||||||
>
|
|
||||||
{t("server.connect_button")}
|
|
||||||
</Button>
|
|
||||||
<JellyfinServerDiscovery
|
|
||||||
onServerSelect={async (server) => {
|
|
||||||
setServerURL(server.address);
|
|
||||||
if (server.serverName) {
|
|
||||||
setServerName(server.serverName);
|
|
||||||
}
|
|
||||||
await handleConnect(server.address);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<PreviousServersList
|
|
||||||
onServerSelect={async (s) => {
|
|
||||||
await handleConnect(s.address);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View className='flex flex-col h-full items-center justify-center w-full'>
|
||||||
|
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
||||||
|
<Image
|
||||||
|
style={{
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
marginLeft: -23,
|
||||||
|
marginBottom: -20,
|
||||||
|
}}
|
||||||
|
source={require("@/assets/images/StreamyFinFinal.png")}
|
||||||
|
/>
|
||||||
|
<Text className='text-3xl font-bold'>Streamyfin</Text>
|
||||||
|
<Text className='text-neutral-500'>
|
||||||
|
{t("server.enter_url_to_jellyfin_server")}
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
aria-label='Server URL'
|
||||||
|
placeholder={t("server.server_url_placeholder")}
|
||||||
|
onChangeText={setServerURL}
|
||||||
|
value={serverURL}
|
||||||
|
keyboardType='url'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
textContentType='URL'
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
loading={loadingServerCheck}
|
||||||
|
disabled={loadingServerCheck}
|
||||||
|
onPress={async () => {
|
||||||
|
await handleConnect(serverURL);
|
||||||
|
}}
|
||||||
|
className='w-full grow'
|
||||||
|
>
|
||||||
|
{t("server.connect_button")}
|
||||||
|
</Button>
|
||||||
|
<JellyfinServerDiscovery
|
||||||
|
onServerSelect={async (server) => {
|
||||||
|
setServerURL(server.address);
|
||||||
|
if (server.serverName) {
|
||||||
|
setServerName(server.serverName);
|
||||||
|
}
|
||||||
|
await handleConnect(server.address);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PreviousServersList
|
||||||
|
onServerSelect={async (s) => {
|
||||||
|
await handleConnect(s.address);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 305 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 136 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 142 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 326 KiB |
@@ -1,6 +1,6 @@
|
|||||||
import { Api, AUTHORIZATION_HEADER } from "@jellyfin/sdk";
|
|
||||||
import type { AxiosRequestConfig, AxiosResponse } from "axios";
|
|
||||||
import type { StreamyfinPluginConfig } from "@/utils/atoms/settings";
|
import type { StreamyfinPluginConfig } from "@/utils/atoms/settings";
|
||||||
|
import { AUTHORIZATION_HEADER, Api } from "@jellyfin/sdk";
|
||||||
|
import type { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||||
|
|
||||||
declare module "@jellyfin/sdk" {
|
declare module "@jellyfin/sdk" {
|
||||||
interface Api {
|
interface Api {
|
||||||
|
|||||||
20
biome.json
20
biome.json
@@ -1,14 +1,16 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.0.5/schema.json",
|
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||||
|
"organizeImports": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"ignore": [
|
||||||
"**/*",
|
"node_modules",
|
||||||
"!node_modules/**",
|
"ios",
|
||||||
"!ios/**",
|
"android",
|
||||||
"!android/**",
|
"Streamyfin.app",
|
||||||
"!Streamyfin.app/**",
|
"utils/jellyseerr",
|
||||||
"!utils/jellyseerr/**",
|
".expo"
|
||||||
"!.expo/**"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { RoundButton } from "@/components/RoundButton";
|
|
||||||
import { useFavorite } from "@/hooks/useFavorite";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { Platform, 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 { useTranslation } from "react-i18next";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
@@ -19,6 +17,7 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
const audioStreams = useMemo(
|
const audioStreams = useMemo(
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||||
[source],
|
[source],
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { TouchableOpacity, View } from "react-native";
|
import { Platform, 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 { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
@@ -60,6 +58,7 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
inverted,
|
inverted,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
if (inverted)
|
if (inverted)
|
||||||
return BITRATES.sort(
|
return BITRATES.sort(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { type PropsWithChildren, type ReactNode, useMemo } from "react";
|
import { type PropsWithChildren, type ReactNode, useMemo } from "react";
|
||||||
import { Text, TouchableOpacity, View } from "react-native";
|
import { Platform, Text, TouchableOpacity, View } from "react-native";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { useCallback, useEffect } from "react";
|
import React, { useCallback, useEffect } from "react";
|
||||||
import { Platform, type ViewProps } from "react-native";
|
import { Platform, TouchableOpacity, type ViewProps } from "react-native";
|
||||||
import GoogleCast, {
|
import GoogleCast, {
|
||||||
CastButton,
|
CastButton,
|
||||||
CastContext,
|
CastContext,
|
||||||
|
|||||||
0
components/Chromecast.tv.tsx
Normal file
0
components/Chromecast.tv.tsx
Normal file
0
components/ContextMenu.tv.ts
Normal file
0
components/ContextMenu.tv.ts
Normal file
@@ -1,11 +1,11 @@
|
|||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import type React from "react";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import type React from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { WatchedIndicator } from "./WatchedIndicator";
|
import { WatchedIndicator } from "./WatchedIndicator";
|
||||||
|
|
||||||
type ContinueWatchingPosterProps = {
|
type ContinueWatchingPosterProps = {
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
//import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
||||||
|
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
||||||
|
import download from "@/utils/profiles/download";
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
@@ -16,23 +25,15 @@ import type React from "react";
|
|||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { Alert, Platform, View, type ViewProps } from "react-native";
|
import { Alert, Platform, View, type ViewProps } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { queueAtom } from "@/utils/atoms/queue";
|
|
||||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
|
||||||
import download from "@/utils/profiles/download";
|
|
||||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||||
import { type Bitrate, BitrateSelector } from "./BitrateSelector";
|
import { type Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Text } from "./common/Text";
|
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import ProgressCircle from "./ProgressCircle";
|
import ProgressCircle from "./ProgressCircle";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface DownloadProps extends ViewProps {
|
interface DownloadProps extends ViewProps {
|
||||||
items: BaseItemDto[];
|
items: BaseItemDto[];
|
||||||
@@ -88,7 +89,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
bottomSheetModalRef.current?.present();
|
bottomSheetModalRef.current?.present();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback((_index: number) => {}, []);
|
const handleSheetChanges = useCallback((index: number) => {}, []);
|
||||||
|
|
||||||
const closeModal = useCallback(() => {
|
const closeModal = useCallback(() => {
|
||||||
bottomSheetModalRef.current?.dismiss();
|
bottomSheetModalRef.current?.dismiss();
|
||||||
@@ -151,7 +152,18 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
}
|
}
|
||||||
closeModal();
|
closeModal();
|
||||||
|
|
||||||
initiateDownload(...itemsNotDownloaded);
|
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
|
||||||
|
else {
|
||||||
|
queueActions.enqueue(
|
||||||
|
queue,
|
||||||
|
setQueue,
|
||||||
|
...itemsNotDownloaded.map((item) => ({
|
||||||
|
id: item.Id!,
|
||||||
|
execute: async () => await initiateDownload(item),
|
||||||
|
item,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
||||||
@@ -191,6 +203,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
mediaSource = defaults.mediaSource;
|
mediaSource = defaults.mediaSource;
|
||||||
audioIndex = defaults.audioIndex;
|
audioIndex = defaults.audioIndex;
|
||||||
subtitleIndex = defaults.subtitleIndex;
|
subtitleIndex = defaults.subtitleIndex;
|
||||||
|
// Keep using the selected bitrate for consistency across all downloads
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
const res = await getStreamUrl({
|
||||||
@@ -203,8 +216,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
mediaSourceId: mediaSource?.Id,
|
mediaSourceId: mediaSource?.Id,
|
||||||
subtitleStreamIndex: subtitleIndex,
|
subtitleStreamIndex: subtitleIndex,
|
||||||
deviceProfile: download,
|
deviceProfile: download,
|
||||||
download: true,
|
|
||||||
// deviceId: mediaSource?.Id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res) {
|
if (!res) {
|
||||||
@@ -219,8 +230,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
|
|
||||||
if (!url || !source) throw new Error("No url");
|
if (!url || !source) throw new Error("No url");
|
||||||
|
|
||||||
saveDownloadItemInfoToDiskTmp(item, source, url);
|
if (usingOptimizedServer) {
|
||||||
await startBackgroundDownload(url, item, source, maxBitrate);
|
saveDownloadItemInfoToDiskTmp(item, source, url);
|
||||||
|
await startBackgroundDownload(url, item, source);
|
||||||
|
} else {
|
||||||
|
//await startRemuxing(item, url, source);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -234,6 +249,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
maxBitrate,
|
maxBitrate,
|
||||||
usingOptimizedServer,
|
usingOptimizedServer,
|
||||||
startBackgroundDownload,
|
startBackgroundDownload,
|
||||||
|
//startRemuxing,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { tc } from "@/utils/textTools";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
import type {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||||
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||||
import { ItemImage } from "@/components/common/ItemImage";
|
|
||||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
@@ -19,6 +8,7 @@ import { PlayButton } from "@/components/PlayButton";
|
|||||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||||
import { SimilarItems } from "@/components/SimilarItems";
|
import { SimilarItems } from "@/components/SimilarItems";
|
||||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||||
@@ -26,17 +16,25 @@ import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
|||||||
import { useImageColors } from "@/hooks/useImageColors";
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
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 { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { AddToFavorites } from "./AddToFavorites";
|
import { AddToFavorites } from "./AddToFavorites";
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||||
|
|
||||||
const Chromecast = require("./Chromecast");
|
|
||||||
|
|
||||||
export type SelectedOptions = {
|
export type SelectedOptions = {
|
||||||
bitrate: Bitrate;
|
bitrate: Bitrate;
|
||||||
@@ -52,8 +50,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
useImageColors({ item });
|
useImageColors({ item });
|
||||||
|
|
||||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||||
@@ -85,27 +81,31 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
if (!Platform.isTV) {
|
||||||
navigation.setOptions({
|
useEffect(() => {
|
||||||
headerRight: () =>
|
navigation.setOptions({
|
||||||
item && (
|
headerRight: () =>
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
item && (
|
||||||
<Chromecast.Chromecast background='blur' width={22} height={22} />
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
{item.Type !== "Program" && (
|
<Chromecast.Chromecast
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
background='blur'
|
||||||
<DownloadSingleItem item={item} size='large' />
|
width={22}
|
||||||
{user?.Policy?.IsAdministrator && (
|
height={22}
|
||||||
<PlayInRemoteSessionButton item={item} size='large' />
|
/>
|
||||||
)}
|
{item.Type !== "Program" && (
|
||||||
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
<PlayedStatus items={[item]} size='large' />
|
{!Platform.isTV && (
|
||||||
<AddToFavorites item={item} />
|
<DownloadSingleItem item={item} size='large' />
|
||||||
</View>
|
)}
|
||||||
)}
|
<PlayedStatus items={[item]} size='large' />
|
||||||
</View>
|
<AddToFavorites item={item} />
|
||||||
),
|
</View>
|
||||||
});
|
)}
|
||||||
}, [item]);
|
</View>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [item]);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||||
@@ -166,7 +166,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
<View className='flex flex-col bg-transparent shrink'>
|
<View className='flex flex-col bg-transparent shrink'>
|
||||||
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
||||||
<ItemHeader item={item} className='mb-4' />
|
<ItemHeader item={item} className='mb-4' />
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && !Platform.isTV && (
|
||||||
<View className='flex flex-row items-center justify-start w-full h-16'>
|
<View className='flex flex-row items-center justify-start w-full h-16'>
|
||||||
<BitrateSelector
|
<BitrateSelector
|
||||||
className='mr-1'
|
className='mr-1'
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { GenreTags } from "./GenreTags";
|
import { GenreTags } from "./GenreTags";
|
||||||
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
|
||||||
import { Ratings } from "./Ratings";
|
import { Ratings } from "./Ratings";
|
||||||
|
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
||||||
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
||||||
import { ItemActions } from "./series/SeriesActions";
|
import { ItemActions } from "./series/SeriesActions";
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { formatBitrate } from "@/utils/bitrate";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
type BottomSheetBackdropProps,
|
type BottomSheetBackdropProps,
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
BottomSheetScrollView,
|
BottomSheetScrollView,
|
||||||
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import type {
|
import type {
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
@@ -13,8 +15,8 @@ import type React from "react";
|
|||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import { formatBitrate } from "@/utils/bitrate";
|
|
||||||
import { Badge } from "./Badge";
|
import { Badge } from "./Badge";
|
||||||
|
import { Button } from "./Button";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -101,7 +103,7 @@ const SubtitleStreamInfo = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-col'>
|
<View className='flex flex-col'>
|
||||||
{subtitleStreams.map((stream, _index) => (
|
{subtitleStreams.map((stream, index) => (
|
||||||
<View key={stream.Index} className='flex flex-col'>
|
<View key={stream.Index} className='flex flex-col'>
|
||||||
<Text className='text-xs mb-3 text-neutral-400'>
|
<Text className='text-xs mb-3 text-neutral-400'>
|
||||||
{stream.DisplayTitle}
|
{stream.DisplayTitle}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text, View } from "react-native";
|
import { Text, TouchableOpacity, View } from "react-native";
|
||||||
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
|
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { ListGroup } from "./list/ListGroup";
|
import { ListGroup } from "./list/ListGroup";
|
||||||
import { ListItem } from "./list/ListItem";
|
import { ListItem } from "./list/ListItem";
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
type ActivityIndicatorProps,
|
type ActivityIndicatorProps,
|
||||||
Platform,
|
Platform,
|
||||||
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
|
||||||
interface Props extends ActivityIndicatorProps {}
|
interface Props extends ActivityIndicatorProps {}
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import type {
|
|||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -5,13 +12,6 @@ import { useAtom } from "jotai";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
actorId: string;
|
actorId: string;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { tc } from "@/utils/textTools";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { tc } from "@/utils/textTools";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
text?: string | null;
|
text?: string | null;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import type { PropsWithChildren, ReactElement } from "react";
|
import type { PropsWithChildren, ReactElement } from "react";
|
||||||
import { type NativeScrollEvent, View, type ViewProps } from "react-native";
|
import {
|
||||||
|
type NativeScrollEvent,
|
||||||
|
NativeSyntheticEvent,
|
||||||
|
View,
|
||||||
|
type ViewProps,
|
||||||
|
} from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
interpolate,
|
interpolate,
|
||||||
useAnimatedRef,
|
useAnimatedRef,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
blurAmount?: number;
|
blurAmount?: number;
|
||||||
blurType?: "light" | "dark" | "xlight";
|
blurType?: "light" | "dark" | "xlight";
|
||||||
|
|||||||
@@ -1,3 +1,14 @@
|
|||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import { chromecast } from "@/utils/profiles/chromecast";
|
||||||
|
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||||
|
import ios from "@/utils/profiles/ios";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
@@ -5,6 +16,7 @@ import { useRouter } from "expo-router";
|
|||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, Pressable } from "react-native";
|
||||||
import { Alert, TouchableOpacity, View } from "react-native";
|
import { Alert, TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
CastButton,
|
CastButton,
|
||||||
@@ -22,16 +34,6 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { chromecast } from "@/utils/profiles/chromecast";
|
|
||||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
|
||||||
import type { Button } from "./Button";
|
import type { Button } from "./Button";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
|
|
||||||
@@ -65,14 +67,11 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const startColor = useSharedValue(colorAtom);
|
const startColor = useSharedValue(colorAtom);
|
||||||
const widthProgress = useSharedValue(0);
|
const widthProgress = useSharedValue(0);
|
||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
(q: string) => {
|
(q: string) => {
|
||||||
if (settings.maxAutoPlayEpisodeCount.value !== -1) {
|
|
||||||
updateSettings({ autoPlayEpisodeCount: 0 });
|
|
||||||
}
|
|
||||||
router.push(`/player/direct-player?${q}`);
|
router.push(`/player/direct-player?${q}`);
|
||||||
},
|
},
|
||||||
[router],
|
[router],
|
||||||
|
|||||||
226
components/PlayButton.tv.tsx
Normal file
226
components/PlayButton.tv.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
|
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { Alert, TouchableOpacity, View } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
interpolate,
|
||||||
|
interpolateColor,
|
||||||
|
useAnimatedReaction,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useDerivedValue,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
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 { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
|
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,194 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import {
|
|
||||||
type BaseItemDto,
|
|
||||||
PlayCommand,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import {
|
|
||||||
FlatList,
|
|
||||||
Modal,
|
|
||||||
StyleSheet,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useAllSessions, type useSessionsProps } from "@/hooks/useSessions";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { Text } from "./common/Text";
|
|
||||||
import { Loader } from "./Loader";
|
|
||||||
import { RoundButton } from "./RoundButton";
|
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
|
||||||
item: BaseItemDto;
|
|
||||||
size?: "default" | "large";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|
||||||
item,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
|
||||||
const handlePlayInSession = async (sessionId: string) => {
|
|
||||||
if (!api || !item.Id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(`Playing ${item.Name} in session ${sessionId}`);
|
|
||||||
getSessionApi(api).play({
|
|
||||||
sessionId,
|
|
||||||
itemIds: [item.Id],
|
|
||||||
playCommand: PlayCommand.PlayNow,
|
|
||||||
});
|
|
||||||
|
|
||||||
setModalVisible(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error playing in remote session:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View {...props}>
|
|
||||||
<RoundButton
|
|
||||||
icon='play-circle-outline'
|
|
||||||
onPress={() => setModalVisible(true)}
|
|
||||||
size={props.size}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
animationType='slide'
|
|
||||||
transparent={true}
|
|
||||||
visible={modalVisible}
|
|
||||||
onRequestClose={() => setModalVisible(false)}
|
|
||||||
>
|
|
||||||
<View style={styles.centeredView}>
|
|
||||||
<View style={styles.modalView}>
|
|
||||||
<View style={styles.modalHeader}>
|
|
||||||
<Text style={styles.modalTitle}>Select Session</Text>
|
|
||||||
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
|
||||||
<Ionicons name='close' size={24} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.modalContent}>
|
|
||||||
{isLoading ? (
|
|
||||||
<View style={styles.loadingContainer}>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
) : !sessions || sessions.length === 0 ? (
|
|
||||||
<Text style={styles.noSessionsText}>
|
|
||||||
No active sessions found
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<FlatList
|
|
||||||
data={sessions}
|
|
||||||
keyExtractor={(session) => session.Id || "unknown"}
|
|
||||||
renderItem={({ item: session }) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.sessionItem}
|
|
||||||
onPress={() => handlePlayInSession(session.Id || "")}
|
|
||||||
>
|
|
||||||
<View style={styles.sessionInfo}>
|
|
||||||
<Text style={styles.sessionName}>
|
|
||||||
{session.DeviceName}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.sessionDetails}>
|
|
||||||
{session.UserName} • {session.Client}
|
|
||||||
</Text>
|
|
||||||
{session.NowPlayingItem && (
|
|
||||||
<Text style={styles.nowPlaying} numberOfLines={1}>
|
|
||||||
Now playing:{" "}
|
|
||||||
{session.NowPlayingItem.SeriesName
|
|
||||||
? `${session.NowPlayingItem.SeriesName} :`
|
|
||||||
: ""}
|
|
||||||
{session.NowPlayingItem.Name}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<Ionicons name='play-sharp' size={20} color='#888' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
contentContainerStyle={styles.listContent}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
centeredView: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
|
||||||
},
|
|
||||||
modalView: {
|
|
||||||
width: "90%",
|
|
||||||
maxHeight: "80%",
|
|
||||||
backgroundColor: "#1c1c1c",
|
|
||||||
borderRadius: 20,
|
|
||||||
overflow: "hidden",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
},
|
|
||||||
modalHeader: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 12,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#333",
|
|
||||||
},
|
|
||||||
modalContent: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
modalTitle: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
loadingContainer: {
|
|
||||||
padding: 40,
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
noSessionsText: {
|
|
||||||
padding: 40,
|
|
||||||
textAlign: "center",
|
|
||||||
color: "#888",
|
|
||||||
},
|
|
||||||
listContent: {
|
|
||||||
paddingVertical: 8,
|
|
||||||
},
|
|
||||||
sessionItem: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
paddingVertical: 12,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#333",
|
|
||||||
},
|
|
||||||
sessionInfo: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
sessionName: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: "500",
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
sessionDetails: {
|
|
||||||
fontSize: 13,
|
|
||||||
opacity: 0.7,
|
|
||||||
marginBottom: 2,
|
|
||||||
},
|
|
||||||
nowPlaying: {
|
|
||||||
fontSize: 12,
|
|
||||||
opacity: 0.5,
|
|
||||||
fontStyle: "italic",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
@@ -13,7 +13,7 @@ interface Props extends ViewProps {
|
|||||||
export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const _invalidateQueries = () => {
|
const invalidateQueries = () => {
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["item", item.Id],
|
queryKey: ["item", item.Id],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import { StyleSheet, View } from "react-native";
|
||||||
import { AnimatedCircularProgress } from "react-native-circular-progress";
|
import { AnimatedCircularProgress } from "react-native-circular-progress";
|
||||||
|
|
||||||
type ProgressCircleProps = {
|
type ProgressCircleProps = {
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { View, type ViewProps } from "react-native";
|
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
@@ -12,6 +6,12 @@ import type {
|
|||||||
TvResult,
|
TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { View, type ViewProps } from "react-native";
|
||||||
import { Badge } from "./Badge";
|
import { Badge } from "./Badge";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
@@ -6,7 +7,6 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
type TouchableOpacityProps,
|
type TouchableOpacityProps,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { router } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View, type ViewProps } from "react-native";
|
import {
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
ScrollView,
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
type ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { ItemCardText } from "./ItemCardText";
|
||||||
|
import { Loader } from "./Loader";
|
||||||
import { HorizontalScroll } from "./common/HorrizontalScroll";
|
import { HorizontalScroll } from "./common/HorrizontalScroll";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { TouchableItemRouter } from "./common/TouchableItemRouter";
|
import { TouchableItemRouter } from "./common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "./ItemCardText";
|
|
||||||
|
|
||||||
interface SimilarItemsProps extends ViewProps {
|
interface SimilarItemsProps extends ViewProps {
|
||||||
itemId?: string | null;
|
itemId?: string | null;
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
|
import { tc } from "@/utils/textTools";
|
||||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { tc } from "@/utils/textTools";
|
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as React from "react";
|
||||||
import renderer from "react-test-renderer";
|
import renderer from "react-test-renderer";
|
||||||
|
|
||||||
import { ThemedText } from "../ThemedText";
|
import { ThemedText } from "../ThemedText";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { View, type ViewProps } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { View, type ViewProps } from "react-native";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { View, type ViewProps } from "react-native";
|
import { useMemo } from "react";
|
||||||
|
import { StyleSheet, View, type ViewProps } from "react-native";
|
||||||
|
|
||||||
const _getItemStyle = (index: number, numColumns: number) => {
|
const getItemStyle = (index: number, numColumns: number) => {
|
||||||
const alignItems = (() => {
|
const alignItems = (() => {
|
||||||
if (numColumns < 2 || index % numColumns === 0) return "flex-start";
|
if (numColumns < 2 || index % numColumns === 0) return "flex-start";
|
||||||
if ((index + 1) % numColumns === 0) return "flex-end";
|
if ((index + 1) % numColumns === 0) return "flex-end";
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import {
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import React, {
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useEffect,
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
|
|
||||||
interface Props<T> {
|
interface Props<T> {
|
||||||
data: T[];
|
data: T[];
|
||||||
@@ -59,7 +58,7 @@ const Dropdown = <T,>({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
title
|
<>{title}</>
|
||||||
)}
|
)}
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
@@ -72,7 +71,7 @@ const Dropdown = <T,>({
|
|||||||
sideOffset={0}
|
sideOffset={0}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>{label}</DropdownMenu.Label>
|
<DropdownMenu.Label>{label}</DropdownMenu.Label>
|
||||||
{data.map((item, _idx) =>
|
{data.map((item, idx) =>
|
||||||
multiple ? (
|
multiple ? (
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
value={
|
value={
|
||||||
@@ -81,10 +80,7 @@ const Dropdown = <T,>({
|
|||||||
: "off"
|
: "off"
|
||||||
}
|
}
|
||||||
key={keyExtractor(item)}
|
key={keyExtractor(item)}
|
||||||
onValueChange={(
|
onValueChange={(next: "on" | "off", previous: "on" | "off") => {
|
||||||
next: "on" | "off",
|
|
||||||
_previous: "on" | "off",
|
|
||||||
) => {
|
|
||||||
setSelected((p) => {
|
setSelected((p) => {
|
||||||
const prev = p || [];
|
const prev = p || [];
|
||||||
if (next === "on") {
|
if (next === "on") {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BlurView, type BlurViewProps } from "expo-blur";
|
import { BlurView, type BlurViewProps } from "expo-blur";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
@@ -5,6 +6,8 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
type TouchableOpacityProps,
|
type TouchableOpacityProps,
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
|
||||||
interface Props extends BlurViewProps {
|
interface Props extends BlurViewProps {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
@@ -13,7 +14,6 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { Text } from "./Text";
|
import { Text } from "./Text";
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user