Compare commits

...

60 Commits

Author SHA1 Message Date
Fredrik Burmester
edd26e68c7 chore: refactor controls 2025-08-20 13:41:45 +02:00
Fredrik Burmester
7cab50750f chore: version
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Failing after 6s
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Failing after 4s
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Failing after 5s
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been skipped
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been skipped
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Failing after 4s
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Failing after 4s
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Successful in 4s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Failing after 14s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Failing after 14s
2025-08-20 10:30:57 +02:00
Fredrik Burmester
d795e82581 fix: trickplay and re-rendering issues 2025-08-20 09:59:03 +02:00
Fredrik Burmester
e7161bc9ab fix: revert fade in controls 2025-08-20 08:21:01 +02:00
Fredrik Burmester
8e74363f32 Revert "chore: refactor controls (#946)"
This reverts commit 8389404975.
2025-08-20 08:18:12 +02:00
Alex
1cb28788d6 Fix selecting bit rate on whole series downloads (#956)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-08-20 00:12:51 +10:00
renovate[bot]
ff9f855d4c chore(deps): update amannn/action-semantic-pull-request action to v6.1.0 (#953) 2025-08-19 13:37:47 +02:00
Fredrik Burmester
13df2d1077 chore: version 2025-08-19 10:01:34 +02:00
Fredrik Burmester
8389404975 chore: refactor controls (#946)
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-08-19 09:02:56 +02:00
Fredrik Burmester
cd920e2d84 fix: small design change 2025-08-19 08:10:54 +02:00
Gauvain
92a11c18e0 docs: add new contributors to README (#951) 2025-08-19 04:25:49 +02:00
Gauvain
e05f10fe42 ci: add actions language to CodeQL analysis matrix
Expands security scanning to include GitHub Actions workflows alongside existing JavaScript/TypeScript analysis for more comprehensive code security coverage
2025-08-19 01:09:23 +02:00
renovate[bot]
2540ae22ce chore(deps): update actions/dependency-review-action action to v4.7.2 (#950)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 01:08:50 +02:00
Gauvain
f490957091 ci: add iOS 18.0 SDK installation step (#949) 2025-08-19 01:06:28 +02:00
renovate[bot]
a146fc8810 chore(deps): update dependency @biomejs/biome to v2.2.0 (#934)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Uruk <contact@uruk.dev>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-08-18 23:00:33 +02:00
renovate[bot]
100d7e0830 chore(deps): update github/codeql-action action to v3.29.10 (#948)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-18 21:18:54 +02:00
Fredrik Burmester
ebcdd5bbf7 feat: show when the stream ends, not only remaining time (#944) 2025-08-18 14:57:02 +02:00
lance chant
18b33884e6 fix: settings storage calc (#943)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2025-08-18 14:53:58 +02:00
Fredrik Burmester
9410239c48 feat: scale factor and aspect ratio (#942) 2025-08-18 14:24:45 +02:00
Fredrik Burmester
4fed25a3ab chore: version 2025-08-18 09:17:24 +02:00
Fredrik Burmester
a8810cae8a Merge branch 'feat/fade-in-controls' into develop 2025-08-18 09:16:38 +02:00
Fredrik Burmester
aff009de92 chore: version 2025-08-18 07:48:57 +02:00
Alex
1924efbef2 Fix more bugs (#939)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-08-17 15:25:51 +10:00
Alex
3b53d76a18 Hotfix/offline playback remaining bugs (#937)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-08-16 18:11:55 +10:00
Fredrik Burmester
b7221e5599 chore: version bump 2025-08-15 21:45:37 +02:00
Fredrik Burmester
5384c34b27 feat: infinite scrolling in favorites tab (#929)
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-08-15 21:34:36 +02:00
Alex
ca92f61900 refactor: Feature/offline mode rework (#859)
Co-authored-by: lostb1t <coding-mosses0z@icloud.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
Co-authored-by: Gauvino <uruknarb20@gmail.com>
Co-authored-by: storm1er <le.storm1er@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Chris <182387676+whoopsi-daisy@users.noreply.github.com>
Co-authored-by: arch-fan <55891793+arch-fan@users.noreply.github.com>
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-08-15 21:34:22 +02:00
Uruk
4fba558c33 refactor: remove gradle optimization properties
Removes daemon, parallel processing, and configure-on-demand gradle properties to simplify configuration and potentially avoid build conflicts.

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

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

Reduces workflow complexity and potential cache conflicts while maintaining build performance.
2025-08-15 04:44:19 +02:00
Uruk
1e399297bd ci: remove Expo CLI cache step from iOS build workflow
Eliminates unnecessary caching of Expo CLI in the iOS build pipeline to streamline the workflow and reduce potential cache-related issues.
2025-08-15 04:30:43 +02:00
Gauvain
feaf82fa3f ci: remove CocoaPods cache and update EAS to latest
Removes CocoaPods caching step which may cause build inconsistencies and updates EAS CLI to use latest version instead of pinned version for improved tooling and bug fixes
2025-08-15 04:21:25 +02:00
Gauvain
781d199546 refactor: simplify renovate configuration and revert kotlin (#933)
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2025-08-14 16:07:49 +02:00
liamwibo
3013251285 fix(readme): change discord invite link to discord badge since old link expired (#913)
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2025-08-14 14:14:18 +02:00
Gauvain
0e1ed71dc1 refactor: biome update and fix renovate and ci (#932) 2025-08-14 10:43:01 +02:00
renovate[bot]
5a781ba62c chore(deps): update amannn/action-semantic-pull-request action to v6 (#931)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-13 23:14:26 +02:00
renovate[bot]
0cea614423 chore(deps): update github/codeql-action action to v3.29.9 (#930)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-13 22:15:32 +02:00
Gauvain
24d006742b Merge branch 'develop' into feat/fade-in-controls 2025-08-13 20:32:53 +02:00
Gauvain
c7f0c2ec83 refactor(ci): Improves CI build performance with enhanced caching (#923)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-13 15:43:22 +02:00
Fredrik Burmester
c34c7fbe83 feat: fade in the controls (instead of on/off toggle) 2025-08-13 15:27:47 +02:00
renovate[bot]
57bbb59874 chore(deps): update actions/checkout action to v5 (#926)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 16:51:54 +02:00
renovate[bot]
e90d2e2244 chore(deps): update dependency @react-native-community/cli to v20 (#924)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 14:05:51 +02:00
renovate[bot]
917dabc4be chore(deps): update actions/checkout action to v4.3.0 (#925)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 14:05:17 +02:00
renovate[bot]
bc2defc8ef chore(deps): update dependency @biomejs/biome to v2.1.4 (#921)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 00:46:20 +02:00
Gauvain
3ce1480e10 fix: Adds Biome version management to Renovate config (#920) 2025-08-11 00:18:19 +02:00
renovate[bot]
9597b40726 chore(deps): update github/codeql-action action to v3.29.8 (#917)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-10 22:51:29 +02:00
Gauvain
1e6408d5be chore: resolve final biome warning with explicit type annotations (#908) 2025-08-08 14:38:00 +02:00
Gauvain
c2f6897f47 fix(ci): Disables fail-fast for CI build matrices (#910) 2025-08-08 14:37:43 +02:00
Jaakko Rantamäki
eaf3682384 fix: Android adaptive and themed icons (#762) 2025-08-08 10:30:00 +02:00
renovate[bot]
f3c7b636a8 chore(deps): update ci dependencies (#911) 2025-08-07 21:09:41 +02:00
Gauvain
64d34a9354 feat: Adds separate Android TV and iOS TV build workflows (#907) 2025-08-07 16:08:40 +02:00
Edmond
2a2ecf0526 feat: Add new translation for Traditional Chinese (zh-TW) (#796) 2025-08-07 13:26:02 +02:00
Ferran
a77c7e8e3c feat(lang): add Catalan localization support (#873)
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-08-07 13:19:48 +02:00
Gauvain
88791eccf9 fix: Adds conditional check to validate PR title job (#901) 2025-08-07 13:01:32 +02:00
Gauvain
515f7ea26d fix: only run iOS build if it’s on a branch of the repo (#872) 2025-08-07 13:01:22 +02:00
Nguyen Quang Huy
e83bbf3121 feat: Added Vietnamese translation (#834) 2025-08-07 13:01:01 +02:00
lance chant
89b34eddc1 fix: tv playback (#820)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Signed-off-by: lancechant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: Uruk <contact@uruk.dev>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-08-07 10:12:40 +02:00
Gauvain
89fd7f0e34 fix: add expo-doctor, fixed a warning (#895) 2025-08-06 21:46:16 +02:00
Gauvain
ab9ae5b620 fix(deps): update biome (#894) 2025-08-06 21:45:58 +02:00
retardgerman
a9c519971e fix: loading conditionals (#753) (#805) 2025-08-05 11:23:14 +02:00
182 changed files with 7915 additions and 6004 deletions

View File

@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(bun install:*)",
"Bash(bunx expo prebuild:*)",
"Bash(bunx expo run:*)",
"Bash(npx expo prebuild:*)",
"Bash(npx expo run:*)",
"Bash(xcodebuild:*)"
],
"deny": []
}
}

View File

@@ -0,0 +1,7 @@
---
description: Don't write code directly in the ios folder.
globs:
alwaysApply: true
---
We never write code directly in the ios folder. This code is generated by expo plugins.

View File

@@ -1,4 +1,4 @@
name: 🤖 Android APK Build
name: 🤖 Android APK Build (Phone + TV)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -12,58 +12,82 @@ on:
branches: [develop, master]
jobs:
build:
build-android:
runs-on: ubuntu-24.04
name: 🏗️ Build Android APK
permissions:
contents: read
strategy:
fail-fast: false
matrix:
target: [phone, tv]
steps:
- name: 📥 Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
submodules: recursive
fetch-depth: 0
submodules: recursive
show-progress: false
- 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'
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache-
${{ runner.os }}-${{ runner.arch }}-bun-develop
${{ runner.os }}-bun-develop
- name: 📦 Install dependencies
- name: 💾 Cache node_modules
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: node_modules
key: ${{ runner.os }}-${{ runner.arch }}-modules-latest-develop-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-modules-latest-develop
${{ runner.os }}-${{ runner.arch }}-modules-develop
${{ runner.os }}-modules-develop
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 💾 Cache Android dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
- name: 💾 Cache Gradle global
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
android/.gradle
key: ${{ runner.os }}-android-deps-${{ hashFiles('android/**/build.gradle') }}
restore-keys: |
${{ runner.os }}-android-deps-
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-gradle-develop
- name: 🛠️ Generate project files
run: bun run prebuild
run: |
if [ "${{ matrix.target }}" = "tv" ]; then
bun run prebuild:tv
else
bun run prebuild
fi
- name: 🚀 Build APK via Bun
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-android-gradle-develop
- name: 🚀 Build APK
env:
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
run: bun run build:android:local
- name: 📅 Set date tag
@@ -72,8 +96,7 @@ jobs:
- name: 📤 Upload APK artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: streamyfin-apk-${{ env.DATE_TAG }}
name: streamyfin-android-${{ matrix.target }}-apk-${{ env.DATE_TAG }}
path: |
android/app/build/outputs/apk/release/*.apk
android/app/build/outputs/bundle/release/*.aab
retention-days: 7

View File

@@ -1,4 +1,4 @@
name: 🤖 iOS IPA Build
name: 🤖 iOS IPA Build (Phone + TV)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -12,51 +12,71 @@ on:
branches: [develop, master]
jobs:
build:
build-ios:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'
runs-on: macos-15
name: 🏗️ Build iOS IPA
permissions:
contents: read
strategy:
fail-fast: false
matrix:
target: [phone, tv]
steps:
- name: 📥 Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: 📥 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
submodules: recursive
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: '1.2.17'
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache-
${{ runner.os }}-bun-cache
- name: 📦 Install & Prepare
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 🛠️ Generate project files
run: bun run prebuild
run: |
if [ "${{ matrix.target }}" = "tv" ]; then
bun run prebuild:tv
else
bun run prebuild
fi
- name: 🏗 Setup EAS
- name: 🏗 Setup EAS
uses: expo/expo-github-action@main
with:
eas-version: 16.7.1
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: 🏗 Build iOS app
- name: Ensure iOS/tvOS SDKs installed
run: |
eas build -p ios --local --non-interactive
if [ "${{ matrix.target }}" = "tv" ]; then
xcodebuild -downloadPlatform tvOS
else
xcodebuild -downloadPlatform iOS
fi
- name: 🚀 Build iOS app
env:
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
run: eas build -p ios --local --non-interactive
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
@@ -64,7 +84,6 @@ jobs:
- name: 📤 Upload IPA artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: streamyfin-ipa-${{ env.DATE_TAG }}
path: |
build-*.ipa
name: streamyfin-ios-${{ matrix.target }}-ipa-${{ env.DATE_TAG }}
path: build-*.ipa
retention-days: 7

View File

@@ -19,7 +19,7 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
@@ -29,10 +29,10 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: '1.2.17'
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
~/.bun/install/cache

View File

@@ -20,24 +20,24 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'javascript-typescript' ]
language: [ 'javascript-typescript', 'actions' ]
steps:
- name: 📥 Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
fetch-depth: 0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
uses: github/codeql-action/init@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
uses: github/codeql-action/autobuild@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
uses: github/codeql-action/analyze@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10

View File

@@ -1,10 +1,12 @@
name: 🚦 Security & Quality Gate
on:
pull_request_target:
pull_request:
types: [opened, edited, synchronize, reopened]
branches: [develop, master]
workflow_dispatch:
push:
branches: [develop]
permissions:
contents: read
@@ -12,12 +14,13 @@ permissions:
jobs:
validate_pr_title:
name: "📝 Validate PR Title"
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
permissions:
pull-requests: write
contents: read
steps:
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3
- uses: amannn/action-semantic-pull-request@7f33ba792281b034f64e96f4c0b5496782dd3b37 # v6.1.0
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -48,19 +51,41 @@ jobs:
contents: read
steps:
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: Dependency Review
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
uses: actions/dependency-review-action@bc41886e18ea39df68b1b1245f4184881938e050 # v4.7.2
with:
fail-on-severity: high
deny-licenses: GPL-3.0, AGPL-3.0
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
expo-doctor:
name: 🚑 Expo Doctor Check
runs-on: ubuntu-24.04
steps:
- name: 🛒 Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha }}
submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: latest
- name: 📦 Install dependencies (bun)
run: bun install --frozen-lockfile
- name: 🚑 Run Expo Doctor
run: bun expo-doctor
code_quality:
name: "🔍 Lint & Test (${{ matrix.command }})"
runs-on: ubuntu-latest
@@ -70,9 +95,10 @@ jobs:
command:
- "lint"
- "check"
- "format"
steps:
- name: "📥 Checkout PR code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha }}
submodules: recursive
@@ -81,12 +107,12 @@ jobs:
- name: "🟢 Setup Node.js"
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '22.x'
node-version: '24.x'
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: '1.2.17'
bun-version: latest
- name: "📦 Install dependencies"
run: bun install --frozen-lockfile

View File

@@ -120,7 +120,7 @@ Key points of the MPL-2.0:
## 🌐 Connect with Us
Join our Discord: [https://discord.gg/aJvAYeycyY](https://discord.gg/aJvAYeycyY)
Join our Discord: [![](https://dcbadge.limes.pink/api/server/https://discord.gg/BuGG9ZNhaE)](https://discord.gg/BuGG9ZNhaE)
Need support or have questions:
@@ -181,6 +181,12 @@ Thanks to the following contributors for their significant contributions:
<br /><sub><b>@topiga</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/lancechant">
<img src="https://github.com/lancechant.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@lancechant</b></sub>
</a>
</td>
</tr>
<tr>
<td align="center">
@@ -213,6 +219,12 @@ Thanks to the following contributors for their significant contributions:
<br /><sub><b>@whoopsi-daisy</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Gauvino">
<img src="https://github.com/Gauvino.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@Gauvino</b></sub>
</a>
</td>
</tr>
</table>
</div>

View File

@@ -4,6 +4,9 @@ module.exports = ({ config }) => {
"react-native-google-cast",
{ useDefaultExpandedMediaControls: true },
]);
// Add the background downloader plugin only for non-TV builds
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
}
return {
android: {

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.29.6",
"version": "0.32.1",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -29,18 +29,19 @@
"supportsTablet": true,
"bundleIdentifier": "com.fredrikburmester.streamyfin",
"icon": {
"dark": "./assets/images/icon-plain.png",
"dark": "./assets/images/icon-ios-plain.png",
"light": "./assets/images/icon-ios-light.png",
"tinted": "./assets/images/icon-ios-tinted.png"
}
},
"appleTeamId": "MWD5K362T8"
},
"android": {
"jsEngine": "hermes",
"versionCode": 57,
"versionCode": 62,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-plain.png",
"monochromeImage": "./assets/images/icon-mono.png",
"backgroundColor": "#464646"
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",
"backgroundColor": "#2E2E2E"
},
"package": "com.fredrikburmester.streamyfin",
"permissions": [
@@ -72,10 +73,7 @@
{
"ios": {
"deploymentTarget": "15.6",
"extraPods": [
{ "name": "SDWebImage", "modular_headers": true },
{ "name": "SDWebImageSVGCoder", "modular_headers": true }
]
"useFrameworks": "static"
},
"android": {
"compileSdkVersion": 35,
@@ -120,12 +118,11 @@
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
["./plugins/withRNBackgroundDownloader.js"],
[
"expo-splash-screen",
{
"backgroundColor": "#2e2e2e",
"image": "./assets/images/StreamyFinFinal.png",
"image": "./assets/images/icon-ios-plain.png",
"imageWidth": 100
}
],
@@ -136,12 +133,7 @@
"color": "#9333EA"
}
],
[
"react-native-google-cast",
{
"useDefaultExpandedMediaControls": true
}
],
"./plugins/with-runtime-framework-headers.js",
"react-native-bottom-tabs"
],
"experiments": {

View File

@@ -10,7 +10,7 @@ export default function SearchLayout() {
<Stack.Screen
name='index'
options={{
headerShown: true,
headerShown: !Platform.isTV,
headerLargeTitle: true,
headerTitle: t("tabs.favorites"),
headerLargeStyle: {

View File

@@ -20,7 +20,7 @@ export default function IndexLayout() {
<Stack.Screen
name='index'
options={{
headerShown: true,
headerShown: !Platform.isTV,
headerLargeTitle: true,
headerTitle: t("tabs.home"),
headerBlurEffect: "prominent",
@@ -66,12 +66,6 @@ export default function IndexLayout() {
title: t("home.settings.settings_title"),
}}
/>
<Stack.Screen
name='settings/optimized-server/page'
options={{
title: "",
}}
/>
<Stack.Screen
name='settings/marlin-search/page'
options={{

View File

@@ -23,12 +23,12 @@ export default function page() {
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
{},
);
const { downloadedFiles, deleteItems } = useDownload();
const { getDownloadedItems, deleteItems } = useDownload();
const series = useMemo(() => {
try {
return (
downloadedFiles
getDownloadedItems()
?.filter((f) => f.item.SeriesId === seriesId)
?.sort(
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
@@ -37,7 +37,37 @@ export default function page() {
} catch {
return [];
}
}, [downloadedFiles]);
}, [getDownloadedItems]);
// Group episodes by season in a single pass
const seasonGroups = useMemo(() => {
const groups: Record<number, BaseItemDto[]> = {};
series.forEach((episode) => {
const seasonNumber = episode.item.ParentIndexNumber;
if (seasonNumber !== undefined && seasonNumber !== null) {
if (!groups[seasonNumber]) {
groups[seasonNumber] = [];
}
groups[seasonNumber].push(episode.item);
}
});
// Sort episodes within each season
Object.values(groups).forEach((episodes) => {
episodes.sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0));
});
return groups;
}, [series]);
// Get unique seasons (just the season numbers, sorted)
const uniqueSeasons = useMemo(() => {
const seasonNumbers = Object.keys(seasonGroups)
.map(Number)
.sort((a, b) => a - b);
return seasonNumbers.map((seasonNum) => seasonGroups[seasonNum][0]); // First episode of each season
}, [seasonGroups]);
const seasonIndex =
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
@@ -45,20 +75,8 @@ export default function page() {
"";
const groupBySeason = useMemo<BaseItemDto[]>(() => {
const seasons: Record<string, BaseItemDto[]> = {};
series?.forEach((episode) => {
if (!seasons[episode.item.ParentIndexNumber!]) {
seasons[episode.item.ParentIndexNumber!] = [];
}
seasons[episode.item.ParentIndexNumber!].push(episode.item);
});
return (
seasons[seasonIndex]?.sort((a, b) => a.IndexNumber! - b.IndexNumber!) ??
[]
);
}, [series, seasonIndex]);
return seasonGroups[Number(seasonIndex)] ?? [];
}, [seasonGroups, seasonIndex]);
const initialSeasonIndex = useMemo(
() =>
@@ -102,7 +120,7 @@ export default function page() {
<View className='flex flex-row items-center justify-start my-2 px-4'>
<SeasonDropdown
item={series[0].item}
seasons={series.map((s) => s.item)}
seasons={uniqueSeasons}
state={seasonIndexState}
initialSeasonIndex={initialSeasonIndex!}
onSelect={(season) => {

View File

@@ -10,33 +10,34 @@ import { useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { type DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { useDownload } from "@/providers/DownloadProvider";
import { type DownloadedItem } from "@/providers/Downloads/types";
import { queueAtom } from "@/utils/atoms/queue";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { writeToLog } from "@/utils/log";
export default function page() {
const navigation = useNavigation();
const { t } = useTranslation();
const [queue, setQueue] = useAtom(queueAtom);
const { removeProcess, downloadedFiles, deleteFileByType, deleteAllFiles } =
useDownload();
const {
removeProcess,
getDownloadedItems,
deleteFileByType,
deleteAllFiles,
} = useDownload();
const router = useRouter();
const [settings] = useSettings();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [showMigration, setShowMigration] = useState(false);
const insets = useSafeAreaInsets();
const migration_20241124 = () => {
Alert.alert(
t("home.downloads.new_app_version_requires_re_download"),
@@ -44,7 +45,10 @@ export default function page() {
[
{
text: t("home.downloads.back"),
onPress: () => setShowMigration(false) || router.back(),
onPress: () => {
setShowMigration(false);
router.back();
},
},
{
text: t("home.downloads.delete"),
@@ -58,6 +62,8 @@ export default function page() {
);
};
const downloadedFiles = getDownloadedItems();
const movies = useMemo(() => {
try {
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
@@ -127,16 +133,10 @@ export default function page() {
return (
<>
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 100,
}}
>
<View className='py-4'>
<View className='mb-4 flex flex-col space-y-4 px-4'>
{settings?.downloadMethod === DownloadMethod.Remux && (
<View style={{ flex: 1 }}>
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
<View className='py-4'>
<View className='mb-4 flex flex-col space-y-4 px-4'>
<View className='bg-neutral-900 p-4 rounded-2xl'>
<Text className='text-lg font-bold'>
{t("home.downloads.queue")}
@@ -180,70 +180,74 @@ export default function page() {
</Text>
)}
</View>
)}
<ActiveDownloads />
</View>
{movies.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.movies")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{movies?.map((item) => (
<View className='mb-2 last:mb-0' key={item.item.Id}>
<MovieCard item={item.item} />
</View>
))}
</View>
</ScrollView>
<ActiveDownloads />
</View>
)}
{groupedBySeries.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.tvseries")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{groupedBySeries?.length}
{movies.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.movies")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{movies?.map((item) => (
<TouchableItemRouter
item={item.item}
isOffline
key={item.item.Id}
>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
</View>
</ScrollView>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{groupedBySeries?.map((items) => (
<View
className='mb-2 last:mb-0'
key={items[0].item.SeriesId}
>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
</View>
))}
)}
{groupedBySeries.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.tvseries")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{groupedBySeries?.length}
</Text>
</View>
</View>
</ScrollView>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className='flex px-4'>
<Text className='opacity-50'>
{t("home.downloads.no_downloaded_items")}
</Text>
</View>
)}
</View>
</ScrollView>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{groupedBySeries?.map((items) => (
<View
className='mb-2 last:mb-0'
key={items[0].item.SeriesId}
>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
</View>
))}
</View>
</ScrollView>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className='flex px-4'>
<Text className='opacity-50'>
{t("home.downloads.no_downloaded_items")}
</Text>
</View>
)}
</View>
</ScrollView>
</View>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing

View File

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

View File

@@ -434,8 +434,6 @@ const TranscodingStreamView = ({
isTranscoding,
properties,
transcodeProperties,
value,
transcodeValue,
}: TranscodingStreamViewProps) => {
return (
<View className='flex flex-col pt-2 first:pt-0'>

View File

@@ -2,7 +2,7 @@ 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 { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
@@ -73,13 +73,13 @@ export default function settings() {
<OtherSettings />
<DownloadSettings />
{!Platform.isTV && <DownloadSettings />}
<PluginSettings />
<AppLanguageSelector />
<ChromecastSettings />
{!Platform.isTV && <ChromecastSettings />}
<ListGroup title={"Intro"}>
<ListItem
@@ -112,7 +112,7 @@ export default function settings() {
</ListGroup>
</View>
<StorageSettings />
{!Platform.isTV && <StorageSettings />}
</View>
</ScrollView>
);

View File

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

View File

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

View File

@@ -112,7 +112,7 @@ const page: React.FC = () => {
recursive: true,
genres: selectedGenres,
tags: selectedTags,
years: selectedYears.map((year) => Number.parseInt(year)),
years: selectedYears.map((year) => Number.parseInt(year, 10)),
includeItemTypes: ["Movie", "Series"],
});

View File

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

View File

@@ -17,7 +17,7 @@ export default function page() {
const local = useLocalSearchParams();
const { jellyseerrApi } = useJellyseerr();
const { companyId, name, image, type } = local as unknown as {
const { companyId, image, type } = local as unknown as {
companyId: string;
name: string;
image: string;

View File

@@ -221,11 +221,7 @@ const Page: React.FC = () => {
| TvDetails
}
/>
<Text
uiTextView
selectable
className='font-bold text-2xl mb-1'
>
<Text selectable className='font-bold text-2xl mb-1'>
{mediaTitle}
</Text>
<Text className='opacity-50'>{releaseYear}</Text>
@@ -256,26 +252,28 @@ const Page: React.FC = () => {
) : (
details?.mediaInfo?.jellyfinMediaId && (
<View className='flex flex-row space-x-2 mt-4'>
<Button
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
color='transparent'
onPress={() => bottomSheetModalRef?.current?.present()}
iconLeft={
<Ionicons
name='warning-outline'
size={20}
color='white'
/>
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>
{t("jellyseerr.report_issue_button")}
</Text>
</Button>
{!Platform.isTV && (
<Button
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
color='transparent'
onPress={() => bottomSheetModalRef?.current?.present()}
iconLeft={
<Ionicons
name='warning-outline'
size={20}
color='white'
/>
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>
{t("jellyseerr.report_issue_button")}
</Text>
</Button>
)}
<Button
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
onPress={() => {
@@ -333,92 +331,95 @@ const Page: React.FC = () => {
}}
onDismiss={() => _setRequestBody(undefined)}
/>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={renderBackdrop}
>
<BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View>
<Text className='font-bold text-2xl text-neutral-100'>
{t("jellyseerr.whats_wrong")}
</Text>
</View>
<View className='flex flex-col space-y-2 items-start'>
<View className='flex flex-col'>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.issue_type")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
{!Platform.isTV && (
// This is till it's fixed because the menu isn't selectable on TV
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={renderBackdrop}
>
<BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View>
<Text className='font-bold text-2xl text-neutral-100'>
{t("jellyseerr.whats_wrong")}
</Text>
</View>
<View className='flex flex-col space-y-2 items-start'>
<View className='flex flex-col'>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.issue_type")}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side='bottom'
align='center'
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>
{t("jellyseerr.types")}
</DropdownMenu.Label>
{Object.entries(IssueTypeName)
.reverse()
.map(([key, value], _idx) => (
<DropdownMenu.Item
key={value}
onSelect={() =>
setIssueType(key as unknown as IssueType)
}
>
<DropdownMenu.ItemTitle>
{value}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side='bottom'
align='center'
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>
{t("jellyseerr.types")}
</DropdownMenu.Label>
{Object.entries(IssueTypeName)
.reverse()
.map(([key, value], _idx) => (
<DropdownMenu.Item
key={value}
onSelect={() =>
setIssueType(key as unknown as IssueType)
}
>
<DropdownMenu.ItemTitle>
{value}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
<BottomSheetTextInput
multiline
maxLength={254}
style={{ color: "white" }}
clearButtonMode='always'
placeholder={t("jellyseerr.describe_the_issue")}
placeholderTextColor='#9CA3AF'
// Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668
defaultValue={issueMessage}
onChangeText={setIssueMessage}
/>
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
<BottomSheetTextInput
multiline
maxLength={254}
style={{ color: "white" }}
clearButtonMode='always'
placeholder={t("jellyseerr.describe_the_issue")}
placeholderTextColor='#9CA3AF'
// Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668
defaultValue={issueMessage}
onChangeText={setIssueMessage}
/>
</View>
</View>
<Button className='mt-auto' onPress={submitIssue} color='purple'>
{t("jellyseerr.submit_button")}
</Button>
</View>
<Button className='mt-auto' onPress={submitIssue} color='purple'>
{t("jellyseerr.submit_button")}
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
</BottomSheetView>
</BottomSheetModal>
)}
</View>
);
};

View File

@@ -21,14 +21,13 @@ export default function page() {
const {
jellyseerrApi,
jellyseerrUser,
jellyseerrRegion: region,
jellyseerrLocale: locale,
} = useJellyseerr();
const { personId } = local as { personId: string };
const { data, isLoading, isFetching } = useQuery({
const { data } = useQuery({
queryKey: ["jellyseerr", "person", personId],
queryFn: async () => ({
details: await jellyseerrApi?.personDetails(personId),

View File

@@ -1,8 +1,8 @@
import type {
import {
createMaterialTopTabNavigator,
MaterialTopTabNavigationEventMap,
MaterialTopTabNavigationOptions,
} from "@react-navigation/material-top-tabs";
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
import type {
ParamListBase,
TabNavigationState,

View File

@@ -24,14 +24,6 @@ export default function page() {
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 () => {

View File

@@ -69,10 +69,18 @@ const page: React.FC = () => {
seriesId: item?.Id!,
userId: user?.Id!,
enableUserData: true,
fields: ["MediaSources", "MediaStreams", "Overview"],
// Note: Including trick play is necessary to enable trick play downloads
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
});
return res?.data.Items || [];
},
select: (data) =>
// This needs to be sorted by parent index number and then index number, that way we can download the episodes in the correct order.
[...(data || [])].sort(
(a, b) =>
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
),
staleTime: 60,
enabled: !!api && !!user?.Id && !!item?.Id,
});
@@ -136,7 +144,7 @@ const page: React.FC = () => {
resizeMode: "contain",
}}
/>
) : null
) : undefined
}
>
<View className='flex flex-col pt-4'>

View File

@@ -168,7 +168,7 @@ const Page = () => {
fields: ["PrimaryImageAspectRatio", "SortName"],
genres: selectedGenres,
tags: selectedTags,
years: selectedYears.map((year) => Number.parseInt(year)),
years: selectedYears.map((year) => Number.parseInt(year, 10)),
includeItemTypes: itemType ? [itemType] : undefined,
});

View File

@@ -20,7 +20,7 @@ export default function IndexLayout() {
<Stack.Screen
name='index'
options={{
headerShown: true,
headerShown: !Platform.isTV,
headerLargeTitle: true,
headerTitle: t("tabs.library"),
headerBlurEffect: "prominent",
@@ -200,7 +200,7 @@ export default function IndexLayout() {
name='[libraryId]'
options={{
title: "",
headerShown: true,
headerShown: !Platform.isTV,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
@@ -213,7 +213,7 @@ export default function IndexLayout() {
name='collections/[collectionId]'
options={{
title: "",
headerShown: true,
headerShown: !Platform.isTV,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,

View File

@@ -13,7 +13,7 @@ export default function SearchLayout() {
<Stack.Screen
name='index'
options={{
headerShown: true,
headerShown: !Platform.isTV,
headerLargeTitle: true,
headerTitle: t("tabs.search"),
headerLargeStyle: {
@@ -31,7 +31,7 @@ export default function SearchLayout() {
name='collections/[collectionId]'
options={{
title: "",
headerShown: true,
headerShown: !Platform.isTV,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,

View File

@@ -10,6 +10,7 @@ import { useAtom } from "jotai";
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
@@ -20,6 +21,7 @@ import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
@@ -57,6 +59,9 @@ export default function search() {
const { t } = useTranslation();
const searchFilterId = useId();
const orderFilterId = useId();
const { q } = params as { q: string };
const [searchType, setSearchType] = useState<SearchType>("Library");
@@ -257,6 +262,26 @@ export default function search() {
paddingRight: insets.right,
}}
>
{/* <View
className='flex flex-col'
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}}
> */}
{Platform.isTV && (
<Input
placeholder={t("search.search")}
onChangeText={(text) => {
router.setParams({ q: "" });
setSearch(text);
}}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
clearButtonMode='while-editing'
maxLength={500}
/>
)}
<View
className='flex flex-col'
style={{
@@ -292,7 +317,7 @@ export default function search() {
debouncedSearch.length > 0 && (
<View className='flex flex-row justify-end items-center space-x-1'>
<FilterButton
id='search'
id={searchFilterId}
queryKey='jellyseerr_search'
queryFn={async () =>
Object.keys(JellyseerrSearchSort).filter((v) =>
@@ -308,7 +333,7 @@ export default function search() {
showSearch={false}
/>
<FilterButton
id='order'
id={orderFilterId}
queryKey='jellysearr_search'
queryFn={async () => ["asc", "desc"]}
set={(value) => setJellyseerrSortOrder(value[0])}

View File

@@ -59,7 +59,7 @@ export default function TabLayout() {
>
<NativeTabs.Screen redirect name='index' />
<NativeTabs.Screen
listeners={({ navigation }) => ({
listeners={(_e) => ({
tabPress: (_e) => {
eventBus.emit("scrollToTop");
},
@@ -69,7 +69,7 @@ export default function TabLayout() {
title: t("tabs.home"),
tabBarIcon:
Platform.OS === "android"
? ({ focused }) => require("@/assets/icons/house.fill.png")
? (_e) => require("@/assets/icons/house.fill.png")
: ({ focused }) =>
focused
? { sfSymbol: "house.fill" }
@@ -77,7 +77,7 @@ export default function TabLayout() {
}}
/>
<NativeTabs.Screen
listeners={({ navigation }) => ({
listeners={(_e) => ({
tabPress: (_e) => {
eventBus.emit("searchTabPressed");
},
@@ -87,7 +87,7 @@ export default function TabLayout() {
title: t("tabs.search"),
tabBarIcon:
Platform.OS === "android"
? ({ focused }) => require("@/assets/icons/magnifyingglass.png")
? (_e) => require("@/assets/icons/magnifyingglass.png")
: ({ focused }) =>
focused
? { sfSymbol: "magnifyingglass" }
@@ -116,7 +116,7 @@ export default function TabLayout() {
title: t("tabs.library"),
tabBarIcon:
Platform.OS === "android"
? ({ focused }) => require("@/assets/icons/server.rack.png")
? (_e) => require("@/assets/icons/server.rack.png")
: ({ focused }) =>
focused
? { sfSymbol: "rectangle.stack.fill" }
@@ -130,7 +130,7 @@ export default function TabLayout() {
tabBarItemHidden: !settings?.showCustomMenuLinks,
tabBarIcon:
Platform.OS === "android"
? ({ focused }) => require("@/assets/icons/list.png")
? (_e) => require("@/assets/icons/list.png")
: ({ focused }) =>
focused
? { sfSymbol: "list.dash.fill" }

View File

@@ -2,7 +2,6 @@ import {
type BaseItemDto,
type MediaSourceInfo,
PlaybackOrder,
type PlaybackProgressInfo,
PlaybackStartInfo,
RepeatMode,
} from "@jellyfin/sdk/lib/generated-client";
@@ -16,14 +15,14 @@ import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useHaptic } from "@/hooks/useHaptic";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules";
@@ -33,20 +32,15 @@ import type {
ProgressUpdatePayload,
VlcPlayerViewRef,
} from "@/modules/VlcPlayer.types";
import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import generateDeviceProfile from "@/utils/profiles/native";
import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
const downloadProvider = !Platform.isTV
? require("@/providers/DownloadProvider")
: { useDownload: () => null };
const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas";
export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom);
@@ -56,11 +50,12 @@ export default function page() {
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(() => {
// Load persisted state from storage
const saved = storage.getBoolean(IGNORE_SAFE_AREAS_KEY);
return saved ?? false;
});
const [aspectRatio, setAspectRatio] = useState<
"default" | "16:9" | "4:3" | "1:1" | "21:9"
>("default");
const [scaleFactor, setScaleFactor] = useState<
1.0 | 1.1 | 1.2 | 1.3 | 1.4 | 1.5 | 1.6 | 1.7 | 1.8 | 1.9 | 2.0
>(1.0);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
@@ -74,7 +69,7 @@ export default function page() {
? null
: require("react-native-volume-manager");
const getDownloadedItem = downloadProvider.useDownload();
const downloadUtils = useDownload();
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
@@ -85,11 +80,6 @@ export default function page() {
lightHapticFeedback();
}, []);
// Persist ignoreSafeAreas state whenever it changes
useEffect(() => {
storage.set(IGNORE_SAFE_AREAS_KEY, ignoreSafeAreas);
}, [ignoreSafeAreas]);
const {
itemId,
audioIndex: audioIndexStr,
@@ -108,9 +98,10 @@ export default function page() {
/** Playback position in ticks. */
playbackPosition?: string;
}>();
const [settings] = useSettings();
const insets = useSafeAreaInsets();
const [_settings] = useSettings();
const offline = offlineStr === "true";
const playbackManager = usePlaybackManager();
const audioIndex = audioIndexStr
? Number.parseInt(audioIndexStr, 10)
@@ -123,18 +114,21 @@ export default function page() {
: BITRATES[0].value;
const [item, setItem] = useState<BaseItemDto | null>(null);
const [downloadedItem, setDownloadedItem] = useState<DownloadedItem | null>(
null,
);
const [itemStatus, setItemStatus] = useState({
isLoading: true,
isError: false,
});
/** Gets the initial playback position from the URL or the item's user data. */
/** Gets the initial playback position from the URL. */
const getInitialPlaybackTicks = useCallback((): number => {
if (playbackPositionFromUrl) {
return Number.parseInt(playbackPositionFromUrl, 10);
}
return item?.UserData?.PlaybackPositionTicks ?? 0;
}, [playbackPositionFromUrl, item]);
}, [playbackPositionFromUrl]);
useEffect(() => {
const fetchItemData = async () => {
@@ -142,8 +136,11 @@ export default function page() {
try {
let fetchedItem: BaseItemDto | null = null;
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (data) fetchedItem = data.item as BaseItemDto;
const data = downloadUtils.getDownloadedItemById(itemId);
if (data) {
fetchedItem = data.item as BaseItemDto;
setDownloadedItem(data);
}
} else {
const res = await getUserLibraryApi(api!).getItem({
itemId,
@@ -179,17 +176,20 @@ export default function page() {
useEffect(() => {
const fetchStreamData = async () => {
setStreamStatus({ isLoading: true, isError: false });
const native = await generateDeviceProfile();
try {
let result: Stream | null = null;
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (!data?.mediaSource) return;
const url = await getDownloadedFileUrl(data.item.Id!);
if (offline && downloadedItem && downloadedItem.mediaSource) {
const url = downloadedItem.videoFilePath;
if (item) {
result = { mediaSource: data.mediaSource, sessionId: "", url };
result = {
mediaSource: downloadedItem.mediaSource,
sessionId: "",
url: url,
};
}
} else {
const native = generateDeviceProfile();
const transcoding = generateDeviceProfile({ transcode: true });
const res = await getStreamUrl({
api,
item,
@@ -199,7 +199,7 @@ export default function page() {
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: native,
deviceProfile: bitrateValue ? transcoding : native,
});
if (!res) return;
const { mediaSource, sessionId, url } = res;
@@ -220,26 +220,39 @@ export default function page() {
}
};
fetchStreamData();
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
}, [
itemId,
mediaSourceId,
bitrateValue,
api,
item,
user?.Id,
downloadedItem,
]);
useEffect(() => {
if (!stream) return;
if (!stream || !api) return;
const reportPlaybackStart = async () => {
await getPlaystateApi(api!).reportPlaybackStart({
await getPlaystateApi(api).reportPlaybackStart({
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
});
};
reportPlaybackStart();
}, [stream]);
}, [stream, api]);
const togglePlay = async () => {
lightHapticFeedback();
setIsPlaying(!isPlaying);
if (isPlaying) {
await videoRef.current?.pause();
reportPlaybackProgress();
playbackManager.reportPlaybackProgress(
item?.Id!,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
} else {
videoRef.current?.play();
await getPlaystateApi(api!).reportPlaybackStart({
@@ -249,7 +262,6 @@ export default function page() {
};
const reportPlaybackStopped = useCallback(async () => {
if (offline) return;
const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!,
@@ -257,8 +269,6 @@ export default function page() {
positionTicks: currentTimeInTicks,
playSessionId: stream?.sessionId!,
});
revalidateProgressCache();
}, [
api,
item,
@@ -270,10 +280,15 @@ export default function page() {
]);
const stop = useCallback(() => {
// Update URL with final playback position before stopping
router.setParams({
playbackPosition: msToTicks(progress.get()).toString(),
});
reportPlaybackStopped();
setIsPlaybackStopped(true);
videoRef.current?.stop();
}, [videoRef, reportPlaybackStopped]);
revalidateProgressCache();
}, [videoRef, reportPlaybackStopped, progress]);
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
@@ -282,7 +297,7 @@ export default function page() {
};
}, [navigation, stop]);
const currentPlayStateInfo = () => {
const currentPlayStateInfo = useCallback(() => {
if (!stream) return;
return {
itemId: item?.Id!,
@@ -298,7 +313,32 @@ export default function page() {
repeatMode: RepeatMode.RepeatNone,
playbackOrder: PlaybackOrder.Default,
};
};
}, [
stream,
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
progress,
isPlaying,
isMuted,
]);
const lastUrlUpdateTime = useSharedValue(0);
const wasJustSeeking = useSharedValue(false);
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second
// Track when seeking ends to update URL immediately
useAnimatedReaction(
() => isSeeking.get(),
(currentSeeking, previousSeeking) => {
if (previousSeeking && !currentSeeking) {
// Seeking just ended
wasJustSeeking.value = true;
}
},
[],
);
const onProgress = useCallback(
async (data: ProgressUpdatePayload) => {
@@ -311,15 +351,31 @@ export default function page() {
progress.set(currentTime);
// Update the playback position in the URL.
router.setParams({
playbackPosition: msToTicks(currentTime).toString(),
});
// Update URL immediately after seeking, or every 30 seconds during normal playback
const now = Date.now();
const shouldUpdateUrl = wasJustSeeking.get();
wasJustSeeking.value = false;
if (offline) return;
if (!item?.Id || !stream) return;
if (
shouldUpdateUrl ||
now - lastUrlUpdateTime.get() > URL_UPDATE_INTERVAL
) {
router.setParams({
playbackPosition: msToTicks(currentTime).toString(),
});
lastUrlUpdateTime.value = now;
}
reportPlaybackProgress();
if (!item?.Id) return;
playbackManager.reportPlaybackProgress(
item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
},
[
item?.Id,
@@ -339,28 +395,10 @@ export default function page() {
setIsPipStarted(pipStarted);
}, []);
const reportPlaybackProgress = useCallback(async () => {
if (!api || offline || !stream) return;
await getPlaystateApi(api).reportPlaybackProgress({
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
});
}, [
api,
isPlaying,
offline,
stream,
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
progress,
]);
/** Gets the initial playback position in seconds. */
const startPosition = useMemo(() => {
if (offline) return 0;
return ticksToSeconds(getInitialPlaybackTicks());
}, [offline, getInitialPlaybackTicks]);
}, [getInitialPlaybackTicks]);
const volumeUpCb = useCallback(async () => {
if (Platform.isTV) return;
@@ -399,6 +437,7 @@ export default function page() {
console.error("Error toggling mute:", error);
}
}, [previousVolume]);
const volumeDownCb = useCallback(async () => {
if (Platform.isTV) return;
@@ -445,14 +484,32 @@ export default function page() {
const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") {
setIsPlaying(true);
reportPlaybackProgress();
if (item?.Id) {
playbackManager.reportPlaybackProgress(
item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
}
if (!Platform.isTV) await activateKeepAwakeAsync();
return;
}
if (state === "Paused") {
setIsPlaying(false);
reportPlaybackProgress();
if (item?.Id) {
playbackManager.reportPlaybackProgress(
item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
}
if (!Platform.isTV) await deactivateKeepAwake();
return;
}
@@ -464,7 +521,7 @@ export default function page() {
setIsBuffering(true);
}
},
[reportPlaybackProgress],
[playbackManager, item?.Id, progress],
);
const allAudio =
@@ -482,25 +539,29 @@ export default function page() {
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
DeliveryUrl: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl,
}));
/** The text based subtitle tracks */
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
/** The user chosen subtitle track from the server */
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex,
);
/** The user chosen audio track from the server */
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
/** Whether the stream we're playing is not transcoding*/
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
/** The initial options to pass to the VLC Player */
const initOptions = [``];
if (
chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
) {
// If not transcoding, we can the index as normal.
// If transcoding, we need to reverse the text based subtitles, because VLC reverses the HLS subtitles.
const finalIndex = notTranscoding
? allSubs.indexOf(chosenSubtitleTrack)
: textSubs.indexOf(chosenSubtitleTrack);
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`);
}
@@ -516,7 +577,72 @@ export default function page() {
return () => setIsMounted(false);
}, []);
if (itemStatus.isLoading || streamStatus.isLoading) {
// Memoize video ref functions to prevent unnecessary re-renders
const startPictureInPicture = useCallback(async () => {
return videoRef.current?.startPictureInPicture?.();
}, []);
const play = useCallback(() => {
videoRef.current?.play?.();
}, []);
const pause = useCallback(() => {
videoRef.current?.pause?.();
}, []);
const seek = useCallback((position: number) => {
videoRef.current?.seekTo?.(position);
}, []);
const getAudioTracks = useCallback(async () => {
return videoRef.current?.getAudioTracks?.() || null;
}, []);
const getSubtitleTracks = useCallback(async () => {
return videoRef.current?.getSubtitleTracks?.() || null;
}, []);
const setSubtitleTrack = useCallback((index: number) => {
videoRef.current?.setSubtitleTrack?.(index);
}, []);
const setSubtitleURL = useCallback((url: string, _customName?: string) => {
// Note: VlcPlayer type only expects url parameter
videoRef.current?.setSubtitleURL?.(url);
}, []);
const setAudioTrack = useCallback((index: number) => {
videoRef.current?.setAudioTrack?.(index);
}, []);
const setVideoAspectRatio = useCallback(
async (aspectRatio: string | null) => {
return (
videoRef.current?.setVideoAspectRatio?.(aspectRatio) ||
Promise.resolve()
);
},
[],
);
const setVideoScaleFactor = useCallback(async (scaleFactor: number) => {
return (
videoRef.current?.setVideoScaleFactor?.(scaleFactor) || Promise.resolve()
);
}, []);
console.log("Debug: component render"); // Uncomment to debug re-renders
// Show error UI first, before checking loading/missingdata
if (itemStatus.isError || streamStatus.isError) {
return (
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
<Text className='text-white'>{t("player.error")}</Text>
</View>
);
}
// Then show loader while either side is still fetching or data isnt present
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
// …loader UI…
return (
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
<Loader />
@@ -532,7 +658,14 @@ export default function page() {
);
return (
<View style={{ flex: 1, backgroundColor: "black" }}>
<View
style={{
flex: 1,
backgroundColor: "black",
height: "100%",
width: "100%",
}}
>
<View
style={{
display: "flex",
@@ -541,8 +674,6 @@ export default function page() {
position: "relative",
flexDirection: "column",
justifyContent: "center",
paddingLeft: ignoreSafeAreas ? 0 : insets.left,
paddingRight: ignoreSafeAreas ? 0 : insets.right,
}}
>
<VlcPlayerView
@@ -550,7 +681,7 @@ export default function page() {
source={{
uri: stream?.url || "",
autoplay: true,
isNetwork: true,
isNetwork: !offline,
startPosition,
externalSubtitles,
initOptions,
@@ -573,7 +704,7 @@ export default function page() {
}}
/>
</View>
{videoRef.current && !isPipStarted && isMounted === true && item ? (
{!isPipStarted && isMounted === true && item && (
<Controls
mediaSource={stream?.mediaSource}
item={item}
@@ -586,23 +717,27 @@ export default function page() {
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef?.current?.startPictureInPicture}
play={videoRef.current?.play}
pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo}
startPictureInPicture={startPictureInPicture}
play={play}
pause={pause}
seek={seek}
enableTrickplay={true}
getAudioTracks={videoRef.current?.getAudioTracks}
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
offline={offline}
setSubtitleTrack={videoRef.current.setSubtitleTrack}
setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
setAudioTrack={setAudioTrack}
setVideoAspectRatio={setVideoAspectRatio}
setVideoScaleFactor={setVideoScaleFactor}
aspectRatio={aspectRatio}
scaleFactor={scaleFactor}
setAspectRatio={setAspectRatio}
setScaleFactor={setScaleFactor}
isVlc
/>
) : null}
)}
</View>
);
}

View File

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

View File

@@ -1,7 +1,6 @@
import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Platform } from "react-native";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
@@ -11,7 +10,6 @@ import {
getTokenFromStorage,
JellyfinProvider,
} from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { type Settings, useSettings } from "@/utils/atoms/settings";
@@ -27,7 +25,6 @@ import {
writeToLog,
} from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
@@ -145,14 +142,13 @@ if (!Platform.isTV) {
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
console.log("TaskManager ~ trigger");
const now = Date.now();
const settingsData = storage.getString("settings");
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
const settings: Partial<Settings> = JSON.parse(settingsData);
const url = settings?.optimizedVersionsServerUrl;
if (!settings?.autoDownload || !url)
if (!settings?.autoDownload)
return BackgroundFetch.BackgroundFetchResult.NoData;
const token = getTokenFromStorage();
@@ -162,73 +158,6 @@ if (!Platform.isTV) {
if (!token || !deviceId || !baseDirectory)
return BackgroundFetch.BackgroundFetchResult.NoData;
const jobs = await getAllJobsByDeviceId({
deviceId,
authHeader: token,
url,
});
console.log("TaskManager ~ Active jobs: ", jobs.length);
for (const job of jobs) {
if (job.status === "completed") {
const downloadUrl = `${url}download/${job.id}`;
const tasks = await BackGroundDownloader.checkForExistingDownloads();
if (tasks.find((task: { id: string }) => task.id === job.id)) {
console.log("TaskManager ~ Download already in progress: ", job.id);
continue;
}
BackGroundDownloader.download({
id: job.id,
url: downloadUrl,
destination: `${baseDirectory}${job.item.Id}.mp4`,
headers: {
Authorization: token,
},
})
.begin(() => {
console.log("TaskManager ~ Download started: ", job.id);
})
.done(() => {
console.log("TaskManager ~ Download completed: ", job.id);
saveDownloadedItemInfo(job.item);
BackGroundDownloader.completeHandler(job.id);
cancelJobById({
authHeader: token,
id: job.id,
url: url,
});
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: "Download completed",
data: {
url: "/downloads",
},
},
trigger: null,
});
})
.error((error: any) => {
console.log("TaskManager ~ Download error: ", job.id, error);
BackGroundDownloader.completeHandler(job.id);
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: "Download failed",
data: {
url: "/downloads",
},
},
trigger: null,
});
});
}
}
console.log(`Auto download started: ${new Date(now).toISOString()}`);
// Be sure to return the successful result type!
return BackgroundFetch.BackgroundFetchResult.NewData;
});
@@ -466,85 +395,62 @@ function Layout() {
return (
<QueryClientProvider client={queryClient}>
<JobQueueProvider>
<JellyfinProvider>
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<BottomSheetModalProvider>
<SystemBars style='light' hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
<JellyfinProvider>
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<BottomSheetModalProvider>
<SystemBars style='light' hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
header: () => null,
}}
closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</JellyfinProvider>
</JobQueueProvider>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</JellyfinProvider>
</QueryClientProvider>
);
}
function saveDownloadedItemInfo(item: BaseItemDto) {
try {
const downloadedItems = storage.getString("downloadedItems");
const items: BaseItemDto[] = downloadedItems
? JSON.parse(downloadedItems)
: [];
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
if (existingItemIndex !== -1) {
items[existingItemIndex] = item;
} else {
items.push(item);
}
storage.set("downloadedItems", JSON.stringify(items));
} catch (error) {
writeToLog("ERROR", "Failed to save downloaded item information:", error);
console.error("Failed to save downloaded item information:", error);
}
}

View File

@@ -291,7 +291,7 @@ const Login: React.FC = () => {
marginLeft: -23,
marginBottom: -20,
}}
source={require("@/assets/images/StreamyFinFinal.png")}
source={require("@/assets/images/icon-ios-plain.png")}
/>
<Text className='text-3xl font-bold'>Streamyfin</Text>
<Text className='text-neutral-500'>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 75 KiB

View File

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

View File

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

1680
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { Feather } from "@expo/vector-icons";
import { useCallback, useEffect } from "react";
import { Platform, type ViewProps } from "react-native";
import { Platform } from "react-native";
import GoogleCast, {
CastButton,
CastContext,
@@ -11,12 +11,6 @@ import GoogleCast, {
} from "react-native-google-cast";
import { RoundButton } from "./RoundButton";
interface Props extends ViewProps {
width?: number;
height?: number;
background?: "blur" | "transparent";
}
export function Chromecast({
width = 48,
height = 48,

View File

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

View File

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

View File

@@ -9,21 +9,20 @@ import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { type Href, router, useFocusEffect } from "expo-router";
import { type Href, router } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import type React from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { Alert, Platform, View, type ViewProps } from "react-native";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, Platform, Switch, View, type ViewProps } from "react-native";
import { toast } from "sonner-native";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { 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 { getDownloadUrl } from "@/utils/jellyfin/media/getDownloadUrl";
import { AudioTrackSelector } from "./AudioTrackSelector";
import { type Bitrate, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button";
@@ -34,6 +33,13 @@ import ProgressCircle from "./ProgressCircle";
import { RoundButton } from "./RoundButton";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
export type SelectedOptions = {
bitrate: Bitrate;
mediaSource: MediaSourceInfo | undefined;
audioIndex: number | undefined;
subtitleIndex: number;
};
interface DownloadProps extends ViewProps {
items: BaseItemDto[];
MissingDownloadIconComponent: () => React.ReactElement;
@@ -54,33 +60,29 @@ export const DownloadItems: React.FC<DownloadProps> = ({
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [queue, setQueue] = useAtom(queueAtom);
const [queue, _setQueue] = useAtom(queueAtom);
const [settings] = useSettings();
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
//const { startRemuxing } = useRemuxHlsToMp4();
const { processes, startBackgroundDownload, getDownloadedItems } =
useDownload();
const downloadedFiles = getDownloadedItems();
const [selectedMediaSource, setSelectedMediaSource] = useState<
MediaSourceInfo | undefined | null
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined
>(undefined);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(
settings?.defaultBitrate ?? {
key: "Max",
value: undefined,
},
);
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(items[0], settings);
const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading,
[user],
);
const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === DownloadMethod.Optimized,
[settings],
);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -102,6 +104,28 @@ export const DownloadItems: React.FC<DownloadProps> = ({
[items, downloadedFiles],
);
// Initialize selectedOptions with default values
useEffect(() => {
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
}, [
defaultAudioIndex,
defaultBitrate,
defaultSubtitleIndex,
defaultMediaSource,
]);
const itemsToDownload = useMemo(() => {
if (downloadUnwatchedOnly) {
return itemsNotDownloaded.filter((item) => !item.UserData?.Played);
}
return itemsNotDownloaded;
}, [itemsNotDownloaded, downloadUnwatchedOnly]);
const allItemsDownloaded = useMemo(() => {
if (items.length === 0) return false;
return itemsNotDownloaded.length === 0;
@@ -144,99 +168,98 @@ export const DownloadItems: React.FC<DownloadProps> = ({
);
};
const acceptDownloadOptions = useCallback(() => {
if (userCanDownload === true) {
if (itemsNotDownloaded.some((i) => !i.Id)) {
throw new Error("No item id");
}
closeModal();
initiateDownload(...itemsNotDownloaded);
} else {
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
);
}
}, [
queue,
setQueue,
itemsNotDownloaded,
usingOptimizedServer,
userCanDownload,
maxBitrate,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
]);
const initiateDownload = useCallback(
async (...items: BaseItemDto[]) => {
if (
!api ||
!user?.Id ||
items.some((p) => !p.Id) ||
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
(itemsNotDownloaded.length === 1 && !selectedOptions?.mediaSource?.Id)
) {
throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item",
);
}
let mediaSource = selectedMediaSource;
let audioIndex: number | undefined = selectedAudioStream;
let subtitleIndex: number | undefined = selectedSubtitleStream;
const downloadDetailsPromises = items.map(async (item) => {
const { mediaSource, audioIndex, subtitleIndex } =
itemsNotDownloaded.length > 1
? getDefaultPlaySettings(item, settings!)
: {
mediaSource: selectedOptions?.mediaSource,
audioIndex: selectedOptions?.audioIndex,
subtitleIndex: selectedOptions?.subtitleIndex,
};
for (const item of items) {
if (itemsNotDownloaded.length > 1) {
const defaults = getDefaultPlaySettings(item, settings!);
mediaSource = defaults.mediaSource;
audioIndex = defaults.audioIndex;
subtitleIndex = defaults.subtitleIndex;
}
const res = await getStreamUrl({
const downloadDetails = await getDownloadUrl({
api,
item,
startTimeTicks: 0,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: maxBitrate.value,
mediaSourceId: mediaSource?.Id,
subtitleStreamIndex: subtitleIndex,
deviceProfile: download,
download: true,
// deviceId: mediaSource?.Id,
userId: user.Id!,
mediaSource: mediaSource!,
audioStreamIndex: audioIndex ?? -1,
subtitleStreamIndex: subtitleIndex ?? -1,
maxBitrate: selectedOptions?.bitrate || defaultBitrate,
deviceId: api.deviceInfo.id,
});
if (!res) {
return {
url: downloadDetails?.url,
item,
mediaSource: downloadDetails?.mediaSource,
};
});
const downloadDetails = await Promise.all(downloadDetailsPromises);
for (const { url, item, mediaSource } of downloadDetails) {
if (!url) {
Alert.alert(
t("home.downloads.something_went_wrong"),
t("home.downloads.could_not_get_stream_url_from_jellyfin"),
);
continue;
}
const { mediaSource: source, url } = res;
if (!url || !source) throw new Error("No url");
saveDownloadItemInfoToDiskTmp(item, source, url);
await startBackgroundDownload(url, item, source, maxBitrate);
if (!mediaSource) {
console.error(`Could not get download URL for ${item.Name}`);
toast.error(
t("Could not get download URL for {{itemName}}", {
itemName: item.Name,
}),
);
continue;
}
await startBackgroundDownload(
url,
item,
mediaSource,
selectedOptions?.bitrate || defaultBitrate,
);
}
},
[
api,
user?.Id,
itemsNotDownloaded,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
selectedOptions,
settings,
maxBitrate,
usingOptimizedServer,
defaultBitrate,
startBackgroundDownload,
],
);
const acceptDownloadOptions = useCallback(() => {
if (userCanDownload === true) {
if (itemsToDownload.some((i) => !i.Id)) {
throw new Error("No item id");
}
closeModal();
initiateDownload(...itemsToDownload);
} else {
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
);
}
}, [closeModal, initiateDownload, itemsToDownload, userCanDownload]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
@@ -247,19 +270,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
),
[],
);
useFocusEffect(
useCallback(() => {
if (!settings) return;
if (itemsNotDownloaded.length !== 1) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(items[0], settings);
setSelectedMediaSource(mediaSource ?? undefined);
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [items, itemsNotDownloaded, settings]),
);
const renderButtonContent = () => {
if (processes.length > 0 && itemsProcesses.length > 0) {
@@ -327,40 +337,78 @@ export const DownloadItems: React.FC<DownloadProps> = ({
<Text className='text-neutral-300'>
{subtitle ||
t("item_card.download.download_x_item", {
item_count: itemsNotDownloaded.length,
item_count: itemsToDownload.length,
})}
</Text>
</View>
<View className='flex flex-col space-y-2 w-full items-start'>
<BitrateSelector
inverted
onChange={setMaxBitrate}
selected={maxBitrate}
onChange={(val) =>
setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val },
)
}
selected={selectedOptions?.bitrate}
/>
{itemsNotDownloaded.length > 1 && (
<View className='flex flex-row items-center justify-between w-full py-2'>
<Text>{t("item_card.download.download_unwatched_only")}</Text>
<Switch
onValueChange={setDownloadUnwatchedOnly}
value={downloadUnwatchedOnly}
/>
</View>
)}
{itemsNotDownloaded.length === 1 && (
<>
<View>
<MediaSourceSelector
item={items[0]}
onChange={setSelectedMediaSource}
selected={selectedMediaSource}
onChange={(val) =>
setSelectedOptions(
(prev) =>
prev && {
...prev,
mediaSource: val,
},
)
}
selected={selectedOptions?.mediaSource}
/>
{selectedMediaSource && (
{selectedOptions?.mediaSource && (
<View className='flex flex-col space-y-2'>
<AudioTrackSelector
source={selectedMediaSource}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
source={selectedOptions.mediaSource}
onChange={(val) => {
setSelectedOptions(
(prev) =>
prev && {
...prev,
audioIndex: val,
},
);
}}
selected={selectedOptions.audioIndex}
/>
<SubtitleTrackSelector
source={selectedMediaSource}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
source={selectedOptions.mediaSource}
onChange={(val) => {
setSelectedOptions(
(prev) =>
prev && {
...prev,
subtitleIndex: val,
},
);
}}
selected={selectedOptions.subtitleIndex}
/>
</View>
)}
</>
</View>
)}
</View>
<Button
className='mt-auto'
onPress={acceptDownloadOptions}
@@ -368,13 +416,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
>
{t("item_card.download.download_button")}
</Button>
<View className='opacity-70 text-center w-full flex items-center'>
<Text className='text-xs'>
{usingOptimizedServer
? t("item_card.download.using_optimized_server")
: t("item_card.download.using_default_method")}
</Text>
</View>
</View>
</BottomSheetView>
</BottomSheetModal>

View File

@@ -45,8 +45,13 @@ export type SelectedOptions = {
subtitleIndex: number;
};
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
({ item }) => {
interface ItemContentProps {
item: BaseItemDto;
isOffline: boolean;
}
export const ItemContent: React.FC<ItemContentProps> = React.memo(
({ item, isOffline }) => {
const [api] = useAtom(apiAtom);
const [settings] = useSettings();
const { orientation } = useOrientation();
@@ -68,7 +73,16 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(item, settings);
} = useDefaultPlaySettings(item!, settings);
const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null),
[api, item],
);
const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
// Needs to automatically change the selected to the default values for default indexes.
useEffect(() => {
@@ -116,22 +130,15 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
}, [item, navigation, user]);
useEffect(() => {
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
setHeaderHeight(230);
else if (item.Type === "Movie") setHeaderHeight(500);
else setHeaderHeight(350);
}, [item.Type, orientation]);
if (item) {
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
setHeaderHeight(230);
else if (item.Type === "Movie") setHeaderHeight(500);
else setHeaderHeight(350);
}
}, [item, orientation]);
const logoUrl = useMemo(
() => getLogoImageUrlById({ api, item }),
[api, item],
);
const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
if (!selectedOptions) return <View />;
if (!item || !selectedOptions) return null;
return (
<View
@@ -179,8 +186,8 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
>
<View className='flex flex-col bg-transparent shrink'>
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
<ItemHeader item={item} className='mb-4' />
{item.Type !== "Program" && !Platform.isTV && (
<ItemHeader item={item} className='mb-2' />
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
<View className='flex flex-row items-center justify-start w-full h-16'>
<BitrateSelector
className='mr-1'
@@ -239,25 +246,34 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
className='grow'
selectedOptions={selectedOptions}
item={item}
isOffline={isOffline}
/>
</View>
{item.Type === "Episode" && (
<SeasonEpisodesCarousel item={item} loading={loading} />
<SeasonEpisodesCarousel
item={item}
loading={loading}
isOffline={isOffline}
/>
)}
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
{!isOffline && (
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
)}
<OverviewText text={item.Overview} className='px-4 mb-4' />
{item.Type !== "Program" && (
<>
{item.Type === "Episode" && (
{item.Type === "Episode" && !isOffline && (
<CurrentSeries item={item} className='mb-4' />
)}
<CastAndCrew item={item} className='mb-4' loading={loading} />
{!isOffline && (
<CastAndCrew item={item} className='mb-4' loading={loading} />
)}
{item.People && item.People.length > 0 && (
{item.People && item.People.length > 0 && !isOffline && (
<View className='mb-4'>
{item.People.slice(0, 3).map((person, idx) => (
<MoreMoviesWithActor
@@ -270,7 +286,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</View>
)}
<SimilarItems itemId={item.Id} />
{!isOffline && <SimilarItems itemId={item.Id} />}
</>
)}
</View>

View File

@@ -33,16 +33,16 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
<ItemActions item={item} />
</View>
{item.Type === "Episode" && (
<>
<View>
<EpisodeTitleHeader item={item} />
<GenreTags genres={item.Genres!} />
</>
</View>
)}
{item.Type === "Movie" && (
<>
<View>
<MoviesTitleHeader item={item} />
<GenreTags genres={item.Genres!} />
</>
</View>
)}
</View>
</View>

View File

@@ -236,6 +236,7 @@ const formatFileSize = (bytes?: number | null) => {
if (bytes === 0) return "0 Byte";
const i = Number.parseInt(
Math.floor(Math.log(bytes) / Math.log(1024)).toString(),
10,
);
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
};

View File

@@ -2,7 +2,7 @@ import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
@@ -24,36 +24,27 @@ export const MediaSourceSelector: React.FC<Props> = ({
}) => {
const isTv = Platform.isTV;
const selectedName = useMemo(
() =>
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
(x) => x.Type === "Video",
)?.DisplayTitle || "",
[item, selected],
);
const { t } = useTranslation();
const commonPrefix = useMemo(() => {
const mediaSources = item.MediaSources || [];
if (!mediaSources.length) return "";
let commonPrefix = "";
for (let i = 0; i < mediaSources[0].Name!.length; i++) {
const char = mediaSources[0].Name![i];
if (mediaSources.every((source) => source.Name![i] === char)) {
commonPrefix += char;
} else {
commonPrefix = commonPrefix.slice(0, -1);
break;
}
const getDisplayName = useCallback((source: MediaSourceInfo) => {
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
if (videoStream?.DisplayTitle) {
return videoStream.DisplayTitle;
}
return commonPrefix;
}, [item.MediaSources]);
const name = (name?: string | null) => {
return name?.replace(commonPrefix, "").toLowerCase();
};
// Fallback to source name
if (source.Name) {
return source.Name;
}
// Last resort fallback
return `Source ${source.Id}`;
}, []);
const selectedName = useMemo(() => {
if (!selected) return "";
return getDisplayName(selected);
}, [selected, getDisplayName]);
if (isTv) return null;
@@ -93,7 +84,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
}}
>
<DropdownMenu.ItemTitle>
{`${name(source.Name)}`}
{getDisplayName(source)}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}

View File

@@ -38,6 +38,7 @@ import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
selectedOptions: SelectedOptions;
isOffline?: boolean;
}
const ANIMATION_DURATION = 500;
@@ -46,6 +47,7 @@ const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({
item,
selectedOptions,
isOffline,
...props
}: Props) => {
const { showActionSheetWithOptions } = useActionSheet();
@@ -75,7 +77,7 @@ export const PlayButton: React.FC<Props> = ({
}
router.push(`/player/direct-player?${q}`);
},
[router],
[router, isOffline],
);
const onPress = useCallback(async () => {
@@ -90,6 +92,8 @@ export const PlayButton: React.FC<Props> = ({
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
offline: isOffline ? "true" : "false",
});
const queryString = queryParams.toString();

View File

@@ -1,10 +1,8 @@
import { useActionSheet } from "@expo/react-native-action-sheet";
import { 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 { useAtom } from "jotai";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native";
import Animated, {
Easing,
@@ -17,7 +15,6 @@ import Animated, {
withTiming,
} 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 { runtimeTicksToMinutes } from "@/utils/time";
@@ -37,12 +34,7 @@ export const PlayButton: React.FC<Props> = ({
selectedOptions,
...props
}: Props) => {
const { showActionSheetWithOptions } = useActionSheet();
const { t } = useTranslation();
const [colorAtom] = useAtom(itemThemeColorAtom);
const _api = useAtomValue(apiAtom);
const _user = useAtomValue(userAtom);
const router = useRouter();

View File

@@ -1,5 +1,4 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query";
import type React from "react";
import { View, type ViewProps } from "react-native";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
@@ -7,44 +6,13 @@ import { RoundButton } from "./RoundButton";
interface Props extends ViewProps {
items: BaseItemDto[];
isOffline?: boolean;
size?: "default" | "large";
}
export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
const queryClient = useQueryClient();
const _invalidateQueries = () => {
items.forEach((item) => {
queryClient.invalidateQueries({
queryKey: ["item", item.Id],
});
});
queryClient.invalidateQueries({
queryKey: ["resumeItems"],
});
queryClient.invalidateQueries({
queryKey: ["continueWatching"],
});
queryClient.invalidateQueries({
queryKey: ["nextUp-all"],
});
queryClient.invalidateQueries({
queryKey: ["nextUp"],
});
queryClient.invalidateQueries({
queryKey: ["episodes"],
});
queryClient.invalidateQueries({
queryKey: ["seasons"],
});
queryClient.invalidateQueries({
queryKey: ["home"],
});
};
const allPlayed = items.every((item) => item.UserData?.Played);
const markAsPlayedStatus = useMarkAsPlayed(items);
const toggle = useMarkAsPlayed(items);
return (
<View {...props}>
@@ -52,8 +20,7 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
fillColor={allPlayed ? "primary" : undefined}
icon={allPlayed ? "checkmark" : "checkmark"}
onPress={async () => {
console.log(allPlayed);
await markAsPlayedStatus(!allPlayed);
await toggle(!allPlayed);
}}
size={props.size}
/>

View File

@@ -20,8 +20,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
selected,
...props
}) => {
const isTv = Platform.isTV;
const { t } = useTranslation();
const subtitleStreams = useMemo(() => {
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
}, [source]);
@@ -31,10 +30,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
[subtitleStreams, selected],
);
const { t } = useTranslation();
if (isTv) return null;
if (subtitleStreams?.length === 0) return null;
if (Platform.isTV || subtitleStreams?.length === 0) return null;
return (
<View

View File

@@ -1,5 +1,5 @@
import { FlashList, type FlashListProps } from "@shopify/flash-list";
import React, { forwardRef, useImperativeHandle, useRef } from "react";
import React, { useImperativeHandle, useRef } from "react";
import { View, type ViewStyle } from "react-native";
import { Text } from "./Text";
@@ -19,64 +19,59 @@ interface HorizontalScrollProps<T>
keyExtractor?: (item: T, index: number) => string;
containerStyle?: ViewStyle;
contentContainerStyle?: ViewStyle;
loadingContainerStyle?: ViewStyle;
height?: number;
loading?: boolean;
extraData?: any;
noItemsText?: string;
}
export const HorizontalScroll = forwardRef<
HorizontalScrollRef,
HorizontalScrollProps<any>
>(
<T,>(
{
data = [],
keyExtractor,
renderItem,
containerStyle,
contentContainerStyle,
loadingContainerStyle,
loading = false,
height = 164,
extraData,
noItemsText,
...props
}: HorizontalScrollProps<T>,
ref: React.ForwardedRef<HorizontalScrollRef>,
) => {
const flashListRef = useRef<FlashList<T>>(null);
export const HorizontalScroll = <T,>(
props: HorizontalScrollProps<T> & {
ref?: React.ForwardedRef<HorizontalScrollRef>;
},
) => {
const {
data = [],
keyExtractor,
renderItem,
containerStyle,
contentContainerStyle,
loading = false,
height = 164,
extraData,
noItemsText,
ref,
...restProps
} = props;
useImperativeHandle(ref!, () => ({
scrollToIndex: (index: number, viewOffset: number) => {
flashListRef.current?.scrollToIndex({
index,
animated: true,
viewPosition: 0,
viewOffset,
});
},
}));
const flashListRef = useRef<FlashList<T>>(null);
const renderFlashListItem = ({
item,
index,
}: {
item: T;
index: number;
}) => <View className='mr-2'>{renderItem(item, index)}</View>;
useImperativeHandle(ref!, () => ({
scrollToIndex: (index: number, viewOffset: number) => {
flashListRef.current?.scrollToIndex({
index,
animated: true,
viewPosition: 0,
viewOffset,
});
},
}));
if (!data || loading) {
return (
<View className='px-4 mb-2'>
<View className='bg-neutral-950 h-24 w-full rounded-md mb-2' />
<View className='bg-neutral-950 h-10 w-full rounded-md mb-1' />
</View>
);
}
const renderFlashListItem = ({ item, index }: { item: T; index: number }) => (
<View className='mr-2'>{renderItem(item, index)}</View>
);
if (!data || loading) {
return (
<View className='px-4 mb-2'>
<View className='bg-neutral-950 h-24 w-full rounded-md mb-2' />
<View className='bg-neutral-950 h-10 w-full rounded-md mb-1' />
</View>
);
}
return (
<View style={[{ height }, containerStyle]}>
<FlashList<T>
ref={flashListRef}
data={data}
@@ -97,8 +92,8 @@ export const HorizontalScroll = forwardRef<
</Text>
</View>
)}
{...props}
{...restProps}
/>
);
},
);
</View>
);
};

View File

@@ -56,7 +56,7 @@ export function InfiniteHorizontalScroll({
};
});
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey,
queryFn,
getNextPageParam: (lastPage, pages) => {

View File

@@ -2,7 +2,7 @@ import { useRouter, useSegments } from "expo-router";
import type React from "react";
import { type PropsWithChildren, useCallback, useMemo } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import * as ContextMenu from "@/components/ContextMenu";
import * as ContextMenu from "zeego/context-menu";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import {
@@ -66,7 +66,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
onPress={() => {
if (!result) return;
// @ts-ignore
// @ts-expect-error
router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
params: {

View File

@@ -0,0 +1,47 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useMemo } from "react";
import { View } from "react-native";
interface ProgressBarProps {
item: BaseItemDto;
}
export const ProgressBar: React.FC<ProgressBarProps> = ({ item }) => {
const progress = useMemo(() => {
if (item.Type === "Program") {
if (!item.StartDate || !item.EndDate) {
return 0;
}
const startDate = new Date(item.StartDate);
const endDate = new Date(item.EndDate);
const now = new Date();
const total = endDate.getTime() - startDate.getTime();
if (total <= 0) {
return 0;
}
const elapsed = now.getTime() - startDate.getTime();
return (elapsed / total) * 100;
}
return item.UserData?.PlayedPercentage || 0;
}, [item]);
if (progress <= 0) {
return null;
}
return (
<>
<View
className={
"absolute bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full"
}
/>
<View
style={{
width: `${progress}%`,
}}
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"}
/>
</>
);
};

View File

@@ -1,10 +1,5 @@
import { Platform, Text as RNText, type TextProps } from "react-native";
import { UITextView } from "react-native-uitextview";
export function Text(
props: TextProps & {
uiTextView?: boolean;
},
) {
export function Text(props: TextProps) {
const { style, ...otherProps } = props;
if (Platform.isTV)
return (
@@ -16,7 +11,7 @@ export function Text(
);
return (
<UITextView
<RNText
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}

View File

@@ -11,6 +11,7 @@ import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
interface Props extends TouchableOpacityProps {
item: BaseItemDto;
isOffline?: boolean;
}
export const itemRouter = (
@@ -50,6 +51,7 @@ export const itemRouter = (
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
item,
isOffline = false,
children,
...props
}) => {
@@ -105,7 +107,10 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity
onLongPress={showActionSheet}
onPress={() => {
const url = itemRouter(item, from);
let url = itemRouter(item, from);
if (isOffline) {
url += `&offline=true`;
}
// @ts-expect-error
router.push(url);
}}
@@ -114,4 +119,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
{children}
</TouchableOpacity>
);
return null;
};

View File

@@ -6,7 +6,6 @@ import { t } from "i18next";
import { useMemo } from "react";
import {
ActivityIndicator,
Platform,
TouchableOpacity,
type TouchableOpacityProps,
View,
@@ -15,18 +14,17 @@ import {
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { JobStatus } from "@/providers/Downloads/types";
import { storage } from "@/utils/mmkv";
import type { JobStatus } from "@/utils/optimize-server";
import { formatTimeString } from "@/utils/time";
import { Button } from "../Button";
const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
: null;
interface Props extends ViewProps {}
const bytesToMB = (bytes: number) => {
return bytes / 1024 / 1024;
};
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
const { processes } = useDownload();
if (processes?.length === 0)
@@ -60,32 +58,18 @@ interface DownloadCardProps extends TouchableOpacityProps {
}
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { processes, startDownload } = useDownload();
const { startDownload, removeProcess } = useDownload();
const router = useRouter();
const { removeProcess, setProcesses } = useDownload();
const [settings] = useSettings();
const queryClient = useQueryClient();
const cancelJobMutation = useMutation({
mutationFn: async (id: string) => {
if (!process) throw new Error("No active download");
try {
const tasks = await BackGroundDownloader.checkForExistingDownloads();
for (const task of tasks) {
if (task.id === id) {
task.stop();
}
}
} finally {
await removeProcess(id);
if (settings?.downloadMethod === DownloadMethod.Optimized) {
await queryClient.refetchQueries({ queryKey: ["jobs"] });
}
}
removeProcess(id);
},
onSuccess: () => {
toast.success(t("home.downloads.toasts.download_cancelled"));
queryClient.invalidateQueries({ queryKey: ["downloads"] });
},
onError: (e) => {
console.error(e);
@@ -94,11 +78,14 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
});
const eta = (p: JobStatus) => {
if (!p.speed || !p.progress) return null;
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
const length = p?.item?.RunTimeTicks || 0;
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
return formatTimeString(timeLeft, "tick");
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
if (bytesRemaining <= 0) return null;
const secondsRemaining = bytesRemaining / p.speed;
return formatTimeString(secondsRemaining, "s");
};
const base64Image = useMemo(() => {
@@ -111,8 +98,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden'
{...props}
>
{(process.status === "optimizing" ||
process.status === "downloading") && (
{process.status === "downloading" && (
<View
className={`
bg-purple-600 h-1 absolute bottom-0 left-0
@@ -152,8 +138,10 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
) : (
<Text className='text-xs'>{process.progress.toFixed(0)}%</Text>
)}
{process.speed && (
<Text className='text-xs'>{process.speed?.toFixed(2)}x</Text>
{process.speed && process.speed > 0 && (
<Text className='text-xs'>
{bytesToMB(process.speed).toFixed(2)} MB/s
</Text>
)}
{eta(process) && (
<Text className='text-xs'>
@@ -169,7 +157,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
<TouchableOpacity
disabled={cancelJobMutation.isPending}
onPress={() => cancelJobMutation.mutate(process.id)}
className='ml-auto'
className='ml-auto p-2 rounded-full'
>
{cancelJobMutation.isPending ? (
<ActivityIndicator size='small' color='white' />

View File

@@ -13,7 +13,8 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
items,
...props
}) => {
const { downloadedFiles, getDownloadedItemSize } = useDownload();
const { getDownloadedItemSize, getDownloadedItems } = useDownload();
const downloadedFiles = getDownloadedItems();
const [size, setSize] = useState<string | undefined>();
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);

View File

@@ -4,18 +4,13 @@ import {
} from "@expo/react-native-action-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
import { useCallback, useMemo } from "react";
import {
TouchableOpacity,
type TouchableOpacityProps,
View,
} from "react-native";
import { useCallback } from "react";
import { type TouchableOpacityProps, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import { useHaptic } from "@/hooks/useHaptic";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
import { runtimeTicksToSeconds } from "@/utils/time";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
@@ -23,26 +18,17 @@ interface EpisodeCardProps extends TouchableOpacityProps {
item: BaseItemDto;
}
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
const successHapticFeedback = useHaptic("success");
const _base64Image = useMemo(() => {
return storage.getString(item.Id!);
}, [item]);
const handleOpenFile = useCallback(() => {
openFile(item);
}, [item, openFile]);
/**
* Handles deleting the file with haptic feedback.
*/
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id);
deleteFile(item.Id, "Episode");
successHapticFeedback();
}
}, [deleteFile, item.Id]);
@@ -73,10 +59,10 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
}, [showActionSheetWithOptions, handleDeleteFile]);
return (
<TouchableOpacity
onPress={handleOpenFile}
<TouchableItemRouter
item={item}
isOffline={true}
onLongPress={showActionSheet}
key={item.Id}
className='flex flex-col mb-4'
>
<View className='flex flex-row items-start mb-2'>
@@ -100,7 +86,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
<Text numberOfLines={3} className='text-xs text-neutral-500 shrink'>
{item.Overview}
</Text>
</TouchableOpacity>
</TouchableItemRouter>
);
};

View File

@@ -7,12 +7,12 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import type React from "react";
import { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import { View } from "react-native";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import { useHaptic } from "@/hooks/useHaptic";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
import { ProgressBar } from "../common/ProgressBar";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText";
interface MovieCardProps {
@@ -26,16 +26,10 @@ interface MovieCardProps {
*/
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
const successHapticFeedback = useHaptic("success");
const handleOpenFile = useCallback(() => {
openFile(item);
}, [item, openFile]);
const base64Image = useMemo(() => {
return storage.getString(item.Id!);
return storage.getString(item?.Id!);
}, []);
/**
@@ -43,8 +37,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
*/
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id);
successHapticFeedback();
deleteFile(item.Id, "Movie");
}
}, [deleteFile, item.Id]);
@@ -74,9 +67,9 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
}, [showActionSheetWithOptions, handleDeleteFile]);
return (
<TouchableOpacity onPress={handleOpenFile} onLongPress={showActionSheet}>
<TouchableItemRouter onLongPress={showActionSheet} item={item} isOffline>
{base64Image ? (
<View className='w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
<View className='relative w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
@@ -87,22 +80,24 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
resizeMode: "cover",
}}
/>
<ProgressBar item={item} />
</View>
) : (
<View className='w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center'>
<View className='relative w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center'>
<Ionicons
name='image-outline'
size={24}
color='gray'
className='self-center mt-16'
/>
<ProgressBar item={item} />
</View>
)}
<View className='w-28'>
<ItemCardText item={item} />
</View>
<DownloadSize items={[item]} />
</TouchableOpacity>
</TouchableItemRouter>
);
};

View File

@@ -72,7 +72,6 @@ export const FilterSheet = <T,>({
renderItemLabel,
showSearch = true,
multiple = false,
...props
}: Props<T>) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["80%"], []);

View File

@@ -9,7 +9,7 @@ import { Image, Text, View } from "react-native";
import heart from "@/assets/icons/heart.fill.png";
import { Colors } from "@/constants/Colors";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { ScrollingCollectionList } from "./ScrollingCollectionList";
import { InfiniteScrollingCollectionList } from "./InfiniteScrollingCollectionList";
type FavoriteTypes =
| "Series"
@@ -33,7 +33,11 @@ export const Favorites = () => {
});
const fetchFavoritesByType = useCallback(
async (itemType: BaseItemKind) => {
async (
itemType: BaseItemKind,
startIndex: number = 0,
limit: number = 20,
) => {
const response = await getItemsApi(api as Api).getItems({
userId: user?.Id,
sortBy: ["SeriesSortName", "SortName"],
@@ -44,16 +48,19 @@ export const Favorites = () => {
collapseBoxSetItems: false,
excludeLocationTypes: ["Virtual"],
enableTotalRecordCount: false,
limit: 20,
startIndex: startIndex,
limit: limit,
includeItemTypes: [itemType],
});
const items = response.data.Items || [];
// Update empty state for this specific type
setEmptyState((prev) => ({
...prev,
[itemType as FavoriteTypes]: items.length === 0,
}));
// Update empty state for this specific type only for the first page
if (startIndex === 0) {
setEmptyState((prev) => ({
...prev,
[itemType as FavoriteTypes]: items.length === 0,
}));
}
return items;
},
@@ -82,27 +89,33 @@ export const Favorites = () => {
};
const fetchFavoriteSeries = useCallback(
() => fetchFavoritesByType("Series"),
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("Series", pageParam),
[fetchFavoritesByType],
);
const fetchFavoriteMovies = useCallback(
() => fetchFavoritesByType("Movie"),
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("Movie", pageParam),
[fetchFavoritesByType],
);
const fetchFavoriteEpisodes = useCallback(
() => fetchFavoritesByType("Episode"),
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("Episode", pageParam),
[fetchFavoritesByType],
);
const fetchFavoriteVideos = useCallback(
() => fetchFavoritesByType("Video"),
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("Video", pageParam),
[fetchFavoritesByType],
);
const fetchFavoriteBoxsets = useCallback(
() => fetchFavoritesByType("BoxSet"),
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("BoxSet", pageParam),
[fetchFavoritesByType],
);
const fetchFavoritePlaylists = useCallback(
() => fetchFavoritesByType("Playlist"),
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("Playlist", pageParam),
[fetchFavoritesByType],
);
@@ -123,38 +136,38 @@ export const Favorites = () => {
</Text>
</View>
)}
<ScrollingCollectionList
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteSeries}
queryKey={["home", "favorites", "series"]}
title={t("favorites.series")}
hideIfEmpty
/>
<ScrollingCollectionList
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteMovies}
queryKey={["home", "favorites", "movies"]}
title={t("favorites.movies")}
hideIfEmpty
orientation='vertical'
/>
<ScrollingCollectionList
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteEpisodes}
queryKey={["home", "favorites", "episodes"]}
title={t("favorites.episodes")}
hideIfEmpty
/>
<ScrollingCollectionList
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteVideos}
queryKey={["home", "favorites", "videos"]}
title={t("favorites.videos")}
hideIfEmpty
/>
<ScrollingCollectionList
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteBoxsets}
queryKey={["home", "favorites", "boxsets"]}
title={t("favorites.boxsets")}
hideIfEmpty
/>
<ScrollingCollectionList
<InfiniteScrollingCollectionList
queryFn={fetchFavoritePlaylists}
queryKey={["home", "favorites", "playlists"]}
title={t("favorites.playlists")}

View File

@@ -0,0 +1,191 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
type QueryFunction,
type QueryKey,
useInfiniteQuery,
} from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
ScrollView,
View,
type ViewProps,
} from "react-native";
import { Text } from "@/components/common/Text";
import MoviePoster from "@/components/posters/MoviePoster";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText";
import SeriesPoster from "../posters/SeriesPoster";
interface Props extends ViewProps {
title?: string | null;
orientation?: "horizontal" | "vertical";
disabled?: boolean;
queryKey: QueryKey;
queryFn: QueryFunction<BaseItemDto[], QueryKey, number>;
hideIfEmpty?: boolean;
pageSize?: number;
}
export const InfiniteScrollingCollectionList: React.FC<Props> = ({
title,
orientation = "vertical",
disabled = false,
queryFn,
queryKey,
hideIfEmpty = false,
pageSize = 20,
...props
}) => {
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: queryKey,
queryFn: ({ pageParam = 0, ...context }) =>
queryFn({ ...context, queryKey, pageParam }),
getNextPageParam: (lastPage, allPages) => {
// If the last page has fewer items than pageSize, we've reached the end
if (lastPage.length < pageSize) {
return undefined;
}
// Otherwise, return the next start index
return allPages.length * pageSize;
},
initialPageParam: 0,
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
});
const { t } = useTranslation();
// Flatten all pages into a single array
const allItems = data?.pages.flat() || [];
if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
if (disabled || !title) return null;
const handleScroll = (event: any) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
const paddingToBottom = 20;
// Check if we're near the end of the scroll
if (
layoutMeasurement.width + contentOffset.x >=
contentSize.width - paddingToBottom
) {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}
};
return (
<View {...props}>
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
{title}
</Text>
{isLoading === false && allItems.length === 0 && (
<View className='px-4'>
<Text className='text-neutral-500'>{t("home.no_items")}</Text>
</View>
)}
{isLoading ? (
<View
className={`
flex flex-row gap-2 px-4
`}
>
{[1, 2, 3].map((i) => (
<View className='w-44' key={i}>
<View className='bg-neutral-900 h-24 w-full rounded-md mb-1' />
<View className='rounded-md overflow-hidden mb-1 self-start'>
<Text
className='text-neutral-900 bg-neutral-900 rounded-md'
numberOfLines={1}
>
Nisi mollit voluptate amet.
</Text>
</View>
<View className='rounded-md overflow-hidden self-start mb-1'>
<Text
className='text-neutral-900 bg-neutral-900 text-xs rounded-md '
numberOfLines={1}
>
Lorem ipsum
</Text>
</View>
</View>
))}
</View>
) : (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
onScroll={handleScroll}
scrollEventThrottle={16}
>
<View className='px-4 flex flex-row'>
{allItems.map((item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className={`mr-2
${orientation === "horizontal" ? "w-44" : "w-28"}
`}
>
{item.Type === "Episode" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "Episode" && orientation === "vertical" && (
<SeriesPoster item={item} />
)}
{item.Type === "Movie" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "Movie" && orientation === "vertical" && (
<MoviePoster item={item} />
)}
{item.Type === "Series" && orientation === "vertical" && (
<SeriesPoster item={item} />
)}
{item.Type === "Series" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "Program" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "BoxSet" && orientation === "vertical" && (
<MoviePoster item={item} />
)}
{item.Type === "BoxSet" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "Playlist" && orientation === "vertical" && (
<MoviePoster item={item} />
)}
{item.Type === "Playlist" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "Video" && orientation === "vertical" && (
<MoviePoster item={item} />
)}
{item.Type === "Video" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
<ItemCardText item={item} />
</TouchableItemRouter>
))}
{/* Loading indicator for next page */}
{isFetchingNextPage && (
<View className='justify-center items-center w-16'>
<ActivityIndicator size='small' color='#6366f1' />
</View>
)}
</View>
</ScrollView>
)}
</View>
);
};

View File

@@ -154,7 +154,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
if (!from) return;
const url = itemRouter(item, from);
lightHapticFeedback();
// @ts-ignore
// @ts-expect-error
if (url) router.push(url);
}, [item, from]);

View File

@@ -20,6 +20,7 @@ interface Props extends ViewProps {
queryKey: QueryKey;
queryFn: QueryFunction<BaseItemDto[]>;
hideIfEmpty?: boolean;
isOffline?: boolean;
}
export const ScrollingCollectionList: React.FC<Props> = ({
@@ -29,6 +30,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
queryFn,
queryKey,
hideIfEmpty = false,
isOffline = false,
...props
}) => {
const { data, isLoading } = useQuery({
@@ -90,6 +92,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
<TouchableItemRouter
item={item}
key={item.Id}
isOffline={isOffline}
className={`mr-2
${orientation === "horizontal" ? "w-44" : "w-28"}
`}

View File

@@ -50,11 +50,8 @@ const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
const DetailFacts: React.FC<
{ details?: MovieDetails | TvDetails } & ViewProps
> = ({ details, className, ...props }) => {
const {
jellyseerrUser,
jellyseerrRegion: region,
jellyseerrLocale: locale,
} = useJellyseerr();
const { jellyseerrRegion: region, jellyseerrLocale: locale } =
useJellyseerr();
const { t } = useTranslation();
const releases = useMemo(

View File

@@ -40,7 +40,6 @@ const ParallaxSlideShow = <T,>({
renderItem,
keyExtractor,
onEndReached,
...props
}: PropsWithChildren<Props<T> & ViewProps>) => {
const insets = useSafeAreaInsets();
@@ -140,7 +139,7 @@ const ParallaxSlideShow = <T,>({
}
nestedScrollEnabled
showsVerticalScrollIndicator={false}
//@ts-ignore
//@ts-expect-error
renderItem={({ item, index }) => renderItem(item, index)}
keyExtractor={keyExtractor}
numColumns={3}

View File

@@ -38,16 +38,7 @@ const RequestModal = forwardRef<
Props & Omit<ViewProps, "id">
>(
(
{
id,
title,
requestBody,
type,
isAnime = false,
onRequested,
onDismiss,
...props
},
{ id, title, requestBody, type, isAnime = false, onRequested, onDismiss },
ref,
) => {
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();

View File

@@ -24,7 +24,7 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
[slide],
);
const { data, isFetching, isLoading } = useQuery({
const { data } = useQuery({
queryKey: ["jellyseerr", "discover", slide.type, slide.id],
queryFn: async () => {
return jellyseerrApi?.getGenreSliders(

View File

@@ -11,11 +11,7 @@ import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces
const RequestCard: React.FC<{ request: MediaRequest }> = ({ request }) => {
const { jellyseerrApi } = useJellyseerr();
const {
data: details,
isLoading,
isError,
} = useQuery({
const { data: details } = useQuery({
queryKey: [
"jellyseerr",
"detail",
@@ -57,11 +53,7 @@ const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
}) => {
const { jellyseerrApi } = useJellyseerr();
const {
data: requests,
isLoading,
isError,
} = useQuery({
const { data: requests } = useQuery({
queryKey: ["jellyseerr", "recent_requests"],
queryFn: async () => jellyseerrApi?.requests(),
enabled: !!jellyseerrApi,

View File

@@ -49,7 +49,7 @@ const Slide = <T,>({
data={data}
onEndReachedThreshold={1}
onEndReached={onEndReached}
//@ts-ignore
//@ts-expect-error
renderItem={({ item, index }) =>
item ? renderItem(item, index) : null
}

View File

@@ -82,7 +82,6 @@ const ListItemContent = ({
showArrow,
iconAfter,
children,
...props
}: Props) => {
return (
<>

View File

@@ -9,7 +9,7 @@ interface Props extends ViewProps {
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
return (
<View {...props}>
<Text uiTextView selectable className='font-bold text-2xl mb-1'>
<Text selectable className='font-bold text-2xl mb-1'>
{item?.Name}
</Text>
<Text className='opacity-50'>{item?.ProductionYear}</Text>

View File

@@ -38,7 +38,6 @@ const JellyseerrPoster: React.FC<Props> = ({
horizontal,
showDownloadInfo,
mediaRequest,
...props
}) => {
const { jellyseerrApi, getTitle, getYear, getMediaType } = useJellyseerr();
const loadingOpacity = useSharedValue(1);

View File

@@ -35,12 +35,12 @@ export const SearchItemWrapper = <T,>({
showsHorizontalScrollIndicator={false}
keyExtractor={(_, index) => index.toString()}
estimatedItemSize={250}
/*@ts-ignore */
/*@ts-expect-error */
data={items}
onEndReachedThreshold={1}
onEndReached={onEndReached}
//@ts-ignore
renderItem={({ item, index }) => (item ? renderItem(item) : <></>)}
//@ts-expect-error
renderItem={({ item }) => (item ? renderItem(item) : null)}
/>
</>
);

View File

@@ -55,7 +55,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
<TouchableOpacity
onPress={() => {
const url = itemRouter(i, from);
// @ts-ignore
// @ts-expect-error
router.push(url);
}}
className='flex flex-col w-28'

View File

@@ -12,14 +12,14 @@ export const EpisodeTitleHeader: React.FC<Props> = ({ item, ...props }) => {
return (
<View {...props}>
<Text uiTextView className='font-bold text-2xl' selectable>
<Text className='font-bold text-2xl' selectable>
{item?.Name}
</Text>
<View className='flex flex-row items-center mb-1'>
<TouchableOpacity
onPress={() => {
router.push(
// @ts-ignore
// @ts-expect-error
`/(auth)/series/${item.SeriesId}?seasonIndex=${item?.ParentIndexNumber}`,
);
}}

View File

@@ -57,7 +57,7 @@ const JellyseerrSeasonEpisodes: React.FC<{
);
};
const RenderItem = ({ item, index }: any) => {
const RenderItem = ({ item }: any) => {
const {
jellyseerrApi,
jellyseerrRegion: region,

View File

@@ -94,7 +94,6 @@ export const SeasonDropdown: React.FC<Props> = ({
item[keys.id],
initialSeasonIndex,
keys,
onSelect,
]);
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
@@ -123,16 +122,18 @@ export const SeasonDropdown: React.FC<Props> = ({
sideOffset={8}
>
<DropdownMenu.Label>{t("item_card.seasons")}</DropdownMenu.Label>
{seasons?.sort(sortByIndex).map((season: any) => (
<DropdownMenu.Item
key={season[keys.title]}
onSelect={() => onSelect(season)}
>
<DropdownMenu.ItemTitle>
{season[keys.title]}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
{seasons?.sort(sortByIndex).map((season: any) => {
const title =
season[keys.title] || season.Name || `Season ${season.IndexNumber}`;
return (
<DropdownMenu.Item
key={season.Id || season.IndexNumber}
onSelect={() => onSelect(season)}
>
<DropdownMenu.ItemTitle>{title}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
);
})}
</DropdownMenu.Content>
</DropdownMenu.Root>
);

View File

@@ -1,9 +1,11 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { router } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, type ViewProps } from "react-native";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
@@ -16,15 +18,19 @@ import { ItemCardText } from "../ItemCardText";
interface Props extends ViewProps {
item?: BaseItemDto | null;
loading?: boolean;
isOffline?: boolean;
}
export const SeasonEpisodesCarousel: React.FC<Props> = ({
item,
loading,
isOffline,
...props
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { getDownloadedItems } = useDownload();
const downloadedFiles = getDownloadedItems();
const scrollRef = useRef<HorizontalScrollRef>(null);
@@ -41,24 +47,28 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
isLoading,
isFetching,
} = useQuery({
queryKey: ["episodes", seasonId],
queryKey: ["episodes", seasonId, isOffline],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await api.axiosInstance.get(
`${api.basePath}/Shows/${item?.Id}/Episodes`,
{
params: {
userId: user?.Id,
seasonId,
Fields:
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount,Overview",
},
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
},
);
if (isOffline) {
return downloadedFiles
?.filter(
(f) => f.item.Type === "Episode" && f.item.SeasonId === seasonId,
)
.map((f) => f.item);
}
if (!api || !user?.Id || !item?.SeriesId) return [];
const response = await getTvShowsApi(api).getEpisodes({
userId: user.Id,
seasonId: seasonId || undefined,
seriesId: item.SeriesId,
fields: [
"ItemCounts",
"PrimaryImageAspectRatio",
"CanDelete",
"MediaSourceCount",
"Overview",
],
});
return response.data.Items as BaseItemDto[];
},
enabled: !!api && !!user?.Id && !!seasonId,

View File

@@ -27,7 +27,7 @@ type Props = {
export const seasonIndexAtom = atom<SeasonIndexState>({});
export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
export const SeasonPicker: React.FC<Props> = ({ item }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
@@ -86,7 +86,8 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
userId: user.Id,
seasonId: selectedSeasonId,
enableUserData: true,
fields: ["MediaSources", "MediaStreams", "Overview"],
// Note: Including trick play is necessary to enable trick play downloads
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
});
if (res.data.TotalRecordCount === 0)
@@ -97,6 +98,10 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
return res.data.Items;
},
select: (data) =>
[...(data || [])].sort(
(a, b) => (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
),
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
});

View File

@@ -10,7 +10,7 @@ import { ListItem } from "../list/ListItem";
interface Props extends ViewProps {}
export const AppLanguageSelector: React.FC<Props> = ({ ...props }) => {
export const AppLanguageSelector: React.FC<Props> = () => {
const isTv = Platform.isTV;
const [settings, updateSettings] = useSettings();
const { t } = useTranslation();

View File

@@ -8,7 +8,7 @@ import { ListItem } from "../list/ListItem";
export const Dashboard = () => {
const [settings, _updateSettings] = useSettings();
const { sessions = [], isLoading } = useSessions({} as useSessionsProps);
const { sessions = [] } = useSessions({} as useSessionsProps);
const router = useRouter();
const { t } = useTranslation();

View File

@@ -1,34 +1,17 @@
import { Ionicons } from "@expo/vector-icons";
import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useMemo } from "react";
import { Platform, Switch, TouchableOpacity } from "react-native";
import { Stepper } from "@/components/inputs/Stepper";
import { useDownload } from "@/providers/DownloadProvider";
import {
DownloadMethod,
type Settings,
useSettings,
} from "@/utils/atoms/settings";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next";
import { Stepper } from "@/components/inputs/Stepper";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { Text } from "../common/Text";
import { type Settings, useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export default function DownloadSettings({ ...props }) {
const [settings, updateSettings, pluginSettings] = useSettings();
const { setProcesses } = useDownload();
const router = useRouter();
const queryClient = useQueryClient();
const { t } = useTranslation();
const allDisabled = useMemo(
() =>
pluginSettings?.downloadMethod?.locked === true &&
pluginSettings?.remuxConcurrentLimit?.locked === true &&
pluginSettings?.autoDownload.locked === true,
[pluginSettings],
@@ -39,70 +22,9 @@ export default function DownloadSettings({ ...props }) {
return (
<DisabledSetting disabled={allDisabled} {...props} className='mb-4'>
<ListGroup title={t("home.settings.downloads.downloads_title")}>
<ListItem
title={t("home.settings.downloads.download_method")}
disabled={pluginSettings?.downloadMethod?.locked}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{settings.downloadMethod === DownloadMethod.Remux
? t("home.settings.downloads.default")
: t("home.settings.downloads.optimized")}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>
{t("home.settings.downloads.download_method")}
</DropdownMenu.Label>
<DropdownMenu.Item
key='1'
onSelect={() => {
updateSettings({ downloadMethod: DownloadMethod.Remux });
setProcesses([]);
}}
>
<DropdownMenu.ItemTitle>
{t("home.settings.downloads.default")}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key='2'
onSelect={() => {
updateSettings({ downloadMethod: DownloadMethod.Optimized });
setProcesses([]);
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>
{t("home.settings.downloads.optimized")}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem>
<ListItem
title={t("home.settings.downloads.remux_max_download")}
disabled={
pluginSettings?.remuxConcurrentLimit?.locked ||
settings.downloadMethod !== DownloadMethod.Remux
}
disabled={pluginSettings?.remuxConcurrentLimit?.locked}
>
<Stepper
value={settings.remuxConcurrentLimit}
@@ -116,33 +38,6 @@ export default function DownloadSettings({ ...props }) {
}
/>
</ListItem>
<ListItem
title={t("home.settings.downloads.auto_download")}
disabled={
pluginSettings?.autoDownload?.locked ||
settings.downloadMethod !== DownloadMethod.Optimized
}
>
<Switch
disabled={
pluginSettings?.autoDownload?.locked ||
settings.downloadMethod !== DownloadMethod.Optimized
}
value={settings.autoDownload}
onValueChange={(value) => updateSettings({ autoDownload: value })}
/>
</ListItem>
<ListItem
disabled={
pluginSettings?.optimizedVersionsServerUrl?.locked ||
settings.downloadMethod !== DownloadMethod.Optimized
}
onPress={() => router.push("/settings/optimized-server/page")}
showArrow
title={t("home.settings.downloads.optimized_versions_server")}
/>
</ListGroup>
</DisabledSetting>
);

View File

@@ -1,3 +1,3 @@
export default function DownloadSettings({ ...props }) {
export default function DownloadSettings() {
return null;
}

View File

@@ -82,6 +82,17 @@ export const HomeIndex = () => {
const scrollViewRef = useRef<ScrollView>(null);
const { downloadedFiles, cleanCacheDirectory } = useDownload();
const prevIsConnected = useRef<boolean | null>(false);
const invalidateCache = useInvalidatePlaybackProgressCache();
useEffect(() => {
// Only invalidate cache when transitioning from offline to online
if (isConnected && !prevIsConnected.current) {
invalidateCache();
}
// Update the ref to the current state for the next render
prevIsConnected.current = isConnected;
}, [isConnected, invalidateCache]);
useEffect(() => {
if (Platform.isTV) {
navigation.setOptions({
@@ -144,10 +155,6 @@ export const HomeIndex = () => {
setIsConnected(state.isConnected);
});
// cleanCacheDirectory().catch((e) =>
// console.error("Something went wrong cleaning cache directory")
// );
return () => {
unsubscribe();
};
@@ -188,8 +195,6 @@ export const HomeIndex = () => {
);
}, [userViews]);
const invalidateCache = useInvalidatePlaybackProgressCache();
const refetch = async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();

View File

@@ -14,12 +14,8 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const JellyseerrSettings = () => {
const {
jellyseerrApi,
jellyseerrUser,
setJellyseerrUser,
clearAllJellyseerData,
} = useJellyseerr();
const { jellyseerrUser, setJellyseerrUser, clearAllJellyseerData } =
useJellyseerr();
const { t } = useTranslation();

View File

@@ -1,45 +0,0 @@
import { useTranslation } from "react-i18next";
import { Linking, TextInput, View } from "react-native";
import { Text } from "../common/Text";
interface Props {
value: string;
onChangeValue: (value: string) => void;
}
export const OptimizedServerForm: React.FC<Props> = ({
value,
onChangeValue,
}) => {
const handleOpenLink = () => {
Linking.openURL("https://github.com/streamyfin/optimized-versions-server");
};
const { t } = useTranslation();
return (
<View>
<View className='flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'>
<View className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}>
<Text className='mr-4'>{t("home.settings.downloads.url")}</Text>
<TextInput
className='text-white'
placeholder={t("home.settings.downloads.server_url_placeholder")}
value={value}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={(text) => onChangeValue(text)}
/>
</View>
</View>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.downloads.optimized_version_hint")}{" "}
<Text className='text-blue-500' onPress={handleOpenLink}>
{t("home.settings.downloads.read_more_about_optimized_server")}
</Text>
</Text>
</View>
);
};

View File

@@ -1,7 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Platform, View } from "react-native";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
@@ -16,15 +15,17 @@ export const StorageSettings = () => {
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const { data: size, isLoading: appSizeLoading } = useQuery({
queryKey: ["appSize", appSizeUsage],
const { data: size } = useQuery({
queryKey: ["appSize"],
queryFn: async () => {
const app = await appSizeUsage;
const app = await appSizeUsage();
const remaining = await FileSystem.getFreeDiskStorageAsync();
const total = await FileSystem.getTotalDiskCapacityAsync();
return { app, remaining, total, used: (total - remaining) / total };
return {
appSize: app.appSize,
total: app.total,
remaining: app.remaining,
used: (app.total - app.remaining) / app.total,
};
},
});
@@ -39,6 +40,7 @@ export const StorageSettings = () => {
};
const calculatePercentage = (value: number, total: number) => {
console.log("usage", value, total);
return ((value / total) * 100).toFixed(2);
};
@@ -58,33 +60,30 @@ export const StorageSettings = () => {
</View>
<View className='h-3 w-full bg-gray-100/10 rounded-md overflow-hidden flex flex-row'>
{size && (
<>
<View className='flex flex-row'>
<View
style={{
width: `${(size.app / size.total) * 100}%`,
width: `${(size.appSize / size.total) * 100}%`,
backgroundColor: Colors.primaryRGB,
}}
/>
<View
style={{
width: `${
((size.total - size.remaining - size.app) / size.total) *
100
}%`,
width: `${((size.total - size.remaining - size.appSize) / size.total) * 100}%`,
backgroundColor: Colors.primaryLightRGB,
}}
/>
</>
</View>
)}
</View>
<View className='flex flex-row gap-x-2'>
{size && (
<>
<View className='flex flex-row gap-x-2'>
<View className='flex flex-row items-center'>
<View className='w-3 h-3 rounded-full bg-purple-600 mr-1' />
<Text className='text-white text-xs'>
{t("home.settings.storage.app_usage", {
usedSpace: calculatePercentage(size.app, size.total),
usedSpace: calculatePercentage(size.appSize, size.total),
})}
</Text>
</View>
@@ -93,23 +92,25 @@ export const StorageSettings = () => {
<Text className='text-white text-xs'>
{t("home.settings.storage.device_usage", {
availableSpace: calculatePercentage(
size.total - size.remaining - size.app,
size.total - size.remaining - size.appSize,
size.total,
),
})}
</Text>
</View>
</>
</View>
)}
</View>
</View>
<ListGroup>
<ListItem
textColor='red'
onPress={onDeleteClicked}
title={t("home.settings.storage.delete_all_downloaded_files")}
/>
</ListGroup>
{!Platform.isTV && (
<ListGroup>
<ListItem
textColor='red'
onPress={onDeleteClicked}
title={t("home.settings.storage.delete_all_downloaded_files")}
/>
</ListGroup>
)}
</View>
);
};

View File

@@ -1,16 +1,15 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react";
import { useEffect, useRef } from "react";
import { Platform, StyleSheet, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import { useSharedValue } from "react-native-reanimated";
import type { VolumeResult } from "react-native-volume-manager";
const VolumeManager = Platform.isTV
? null
: require("react-native-volume-manager");
import { Ionicons } from "@expo/vector-icons";
import type { VolumeResult } from "react-native-volume-manager";
interface AudioSliderProps {
setVisibility: (show: boolean) => void;
}
@@ -47,7 +46,7 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
const handleValueChange = async (value: number) => {
volume.value = value;
await VolumeManager.setVolume(value / 100);
// await VolumeManager.setVolume(value / 100);
// Re-call showNativeVolumeUI to ensure the setting is applied on iOS
VolumeManager.showNativeVolumeUI({ enabled: false });
@@ -55,7 +54,7 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
useEffect(() => {
if (isTv) return;
const volumeListener = VolumeManager.addVolumeListener(
const _volumeListener = VolumeManager.addVolumeListener(
(result: VolumeResult) => {
volume.value = result.volume * 100;
setVisibility(true);
@@ -73,7 +72,7 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
);
return () => {
volumeListener.remove();
// volumeListener.remove();
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
@@ -114,7 +113,7 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
const styles = StyleSheet.create({
sliderContainer: {
width: 150,
width: 130,
display: "flex",
flexDirection: "row",
justifyContent: "center",

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