Compare commits
17 Commits
rebase
...
refactor-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a41802f30e | ||
|
|
6686da2bea | ||
|
|
9597b40726 | ||
|
|
1e6408d5be | ||
|
|
c2f6897f47 | ||
|
|
eaf3682384 | ||
|
|
f3c7b636a8 | ||
|
|
64d34a9354 | ||
|
|
2a2ecf0526 | ||
|
|
a77c7e8e3c | ||
|
|
88791eccf9 | ||
|
|
515f7ea26d | ||
|
|
e83bbf3121 | ||
|
|
89b34eddc1 | ||
|
|
89fd7f0e34 | ||
|
|
ab9ae5b620 | ||
|
|
a9c519971e |
14
.claude/settings.local.json
Normal 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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
7
.cursor/rules/no-custom-ios-folder-logic.mdc
Normal 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.
|
||||||
42
.github/workflows/build-android.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: 🤖 Android APK Build
|
name: 🤖 Android APK Build (Phone + TV)
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
@@ -12,12 +12,17 @@ on:
|
|||||||
branches: [develop, master]
|
branches: [develop, master]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build-android:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
name: 🏗️ Build Android APK
|
name: 🏗️ Build Android APK
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
target: [phone, tv]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
@@ -30,40 +35,40 @@ jobs:
|
|||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
with:
|
with:
|
||||||
bun-version: '1.2.17'
|
bun-version: latest
|
||||||
|
|
||||||
- name: ☕ Setup JDK
|
|
||||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
|
||||||
with:
|
|
||||||
distribution: 'zulu'
|
|
||||||
java-version: '17'
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-bun-cache-
|
${{ runner.os }}-bun-cache-
|
||||||
|
|
||||||
- name: 📦 Install dependencies
|
- name: 📦 Install dependencies and reload submodules
|
||||||
run: |
|
run: |
|
||||||
bun install --frozen-lockfile
|
bun install --frozen-lockfile
|
||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: 💾 Cache Android dependencies
|
- name: 💾 Cache Android dependencies
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: android/.gradle
|
||||||
android/.gradle
|
key: ${{ runner.os }}-android-deps-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
key: ${{ runner.os }}-android-deps-${{ hashFiles('android/**/build.gradle') }}
|
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-android-deps-
|
${{ runner.os }}-android-deps-
|
||||||
|
|
||||||
- name: 🛠️ Generate project files
|
- 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: 🚀 Build APK
|
||||||
|
env:
|
||||||
|
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
|
||||||
run: bun run build:android:local
|
run: bun run build:android:local
|
||||||
|
|
||||||
- name: 📅 Set date tag
|
- name: 📅 Set date tag
|
||||||
@@ -72,8 +77,9 @@ jobs:
|
|||||||
- name: 📤 Upload APK artifact
|
- name: 📤 Upload APK artifact
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
with:
|
with:
|
||||||
name: streamyfin-apk-${{ env.DATE_TAG }}
|
name: streamyfin-android-${{ matrix.target }}-apk-${{ env.DATE_TAG }}
|
||||||
path: |
|
path: |
|
||||||
android/app/build/outputs/apk/release/*.apk
|
android/app/build/outputs/apk/release/*.apk
|
||||||
android/app/build/outputs/bundle/release/*.aab
|
android/app/build/outputs/bundle/release/*.aab
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
|||||||
38
.github/workflows/build-ios.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: 🤖 iOS IPA Build
|
name: 🤖 iOS IPA Build (Phone + TV)
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
@@ -12,14 +12,20 @@ on:
|
|||||||
branches: [develop, master]
|
branches: [develop, master]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build-ios:
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
name: 🏗️ Build iOS IPA
|
name: 🏗️ Build iOS IPA
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
target: [phone, tv]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Check out repository
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
@@ -30,33 +36,39 @@ jobs:
|
|||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
with:
|
with:
|
||||||
bun-version: '1.2.17'
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-bun-cache-
|
${{ runner.os }}-bun-cache-
|
||||||
|
|
||||||
- name: 📦 Install & Prepare
|
- name: 📦 Install dependencies and reload submodules
|
||||||
run: |
|
run: |
|
||||||
bun install --frozen-lockfile
|
bun install --frozen-lockfile
|
||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: 🛠️ Generate project files
|
- 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
|
uses: expo/expo-github-action@main
|
||||||
with:
|
with:
|
||||||
eas-version: 16.7.1
|
eas-version: 16.17.4
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
- name: 🏗️ Build iOS app
|
- name: 🏗️ Build iOS app
|
||||||
run: |
|
env:
|
||||||
eas build -p ios --local --non-interactive
|
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
|
||||||
|
run: eas build -p ios --local --non-interactive
|
||||||
|
|
||||||
- name: 📅 Set date tag
|
- name: 📅 Set date tag
|
||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
@@ -64,7 +76,7 @@ jobs:
|
|||||||
- name: 📤 Upload IPA artifact
|
- name: 📤 Upload IPA artifact
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
with:
|
with:
|
||||||
name: streamyfin-ipa-${{ env.DATE_TAG }}
|
name: streamyfin-ios-${{ matrix.target }}-ipa-${{ env.DATE_TAG }}
|
||||||
path: |
|
path: build-*.ipa
|
||||||
build-*.ipa
|
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/check-lockfile.yml
vendored
@@ -29,10 +29,10 @@ jobs:
|
|||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
with:
|
with:
|
||||||
bun-version: '1.2.17'
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.bun/install/cache
|
~/.bun/install/cache
|
||||||
|
|||||||
6
.github/workflows/ci-codeql.yml
vendored
@@ -31,13 +31,13 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
|
uses: github/codeql-action/init@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.8
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended,security-and-quality
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
- name: 🛠️ Autobuild
|
- name: 🛠️ Autobuild
|
||||||
uses: github/codeql-action/autobuild@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
|
uses: github/codeql-action/autobuild@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.8
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
|
uses: github/codeql-action/analyze@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.8
|
||||||
|
|||||||
30
.github/workflows/linting.yml
vendored
@@ -1,10 +1,12 @@
|
|||||||
name: 🚦 Security & Quality Gate
|
name: 🚦 Security & Quality Gate
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request:
|
||||||
types: [opened, edited, synchronize, reopened]
|
types: [opened, edited, synchronize, reopened]
|
||||||
branches: [develop, master]
|
branches: [develop, master]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [develop]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -12,6 +14,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
validate_pr_title:
|
validate_pr_title:
|
||||||
name: "📝 Validate PR Title"
|
name: "📝 Validate PR Title"
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
@@ -61,6 +64,28 @@ jobs:
|
|||||||
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
||||||
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
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:
|
code_quality:
|
||||||
name: "🔍 Lint & Test (${{ matrix.command }})"
|
name: "🔍 Lint & Test (${{ matrix.command }})"
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -70,6 +95,7 @@ jobs:
|
|||||||
command:
|
command:
|
||||||
- "lint"
|
- "lint"
|
||||||
- "check"
|
- "check"
|
||||||
|
- "format"
|
||||||
steps:
|
steps:
|
||||||
- name: "📥 Checkout PR code"
|
- name: "📥 Checkout PR code"
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
@@ -86,7 +112,7 @@ jobs:
|
|||||||
- name: "🍞 Setup Bun"
|
- name: "🍞 Setup Bun"
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
with:
|
with:
|
||||||
bun-version: '1.2.17'
|
bun-version: latest
|
||||||
|
|
||||||
- name: "📦 Install dependencies"
|
- name: "📦 Install dependencies"
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ module.exports = ({ config }) => {
|
|||||||
"react-native-google-cast",
|
"react-native-google-cast",
|
||||||
{ useDefaultExpandedMediaControls: true },
|
{ useDefaultExpandedMediaControls: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Add the background downloader plugin only for non-TV builds
|
||||||
|
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
android: {
|
android: {
|
||||||
|
|||||||
28
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.29.6",
|
"version": "0.29.13",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -29,18 +29,19 @@
|
|||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
||||||
"icon": {
|
"icon": {
|
||||||
"dark": "./assets/images/icon-plain.png",
|
"dark": "./assets/images/icon-ios-plain.png",
|
||||||
"light": "./assets/images/icon-ios-light.png",
|
"light": "./assets/images/icon-ios-light.png",
|
||||||
"tinted": "./assets/images/icon-ios-tinted.png"
|
"tinted": "./assets/images/icon-ios-tinted.png"
|
||||||
}
|
},
|
||||||
|
"appleTeamId": "MWD5K362T8"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 57,
|
"versionCode": 57,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-plain.png",
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
"monochromeImage": "./assets/images/icon-mono.png",
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
"backgroundColor": "#464646"
|
"backgroundColor": "#2E2E2E"
|
||||||
},
|
},
|
||||||
"package": "com.fredrikburmester.streamyfin",
|
"package": "com.fredrikburmester.streamyfin",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
@@ -72,10 +73,7 @@
|
|||||||
{
|
{
|
||||||
"ios": {
|
"ios": {
|
||||||
"deploymentTarget": "15.6",
|
"deploymentTarget": "15.6",
|
||||||
"extraPods": [
|
"useFrameworks": "static"
|
||||||
{ "name": "SDWebImage", "modular_headers": true },
|
|
||||||
{ "name": "SDWebImageSVGCoder", "modular_headers": true }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"compileSdkVersion": 35,
|
"compileSdkVersion": 35,
|
||||||
@@ -120,12 +118,11 @@
|
|||||||
["./plugins/withAndroidManifest.js"],
|
["./plugins/withAndroidManifest.js"],
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
["./plugins/withGradleProperties.js"],
|
["./plugins/withGradleProperties.js"],
|
||||||
["./plugins/withRNBackgroundDownloader.js"],
|
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
"backgroundColor": "#2e2e2e",
|
"backgroundColor": "#2e2e2e",
|
||||||
"image": "./assets/images/StreamyFinFinal.png",
|
"image": "./assets/images/icon-ios-plain.png",
|
||||||
"imageWidth": 100
|
"imageWidth": 100
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -136,12 +133,7 @@
|
|||||||
"color": "#9333EA"
|
"color": "#9333EA"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
"./plugins/with-runtime-framework-headers.js",
|
||||||
"react-native-google-cast",
|
|
||||||
{
|
|
||||||
"useDefaultExpandedMediaControls": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"react-native-bottom-tabs"
|
"react-native-bottom-tabs"
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function SearchLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.favorites"),
|
headerTitle: t("tabs.favorites"),
|
||||||
headerLargeStyle: {
|
headerLargeStyle: {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default function IndexLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.home"),
|
headerTitle: t("tabs.home"),
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Image } from "expo-image";
|
|||||||
import { useFocusEffect, useRouter } from "expo-router";
|
import { useFocusEffect, useRouter } from "expo-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Linking, TouchableOpacity, View } from "react-native";
|
import { Linking, Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
@@ -19,7 +19,9 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='bg-neutral-900 h-full py-16 px-4 space-y-8'>
|
<View
|
||||||
|
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 space-y-4" : "py-16 space-y-8"} px-4`}
|
||||||
|
>
|
||||||
<View>
|
<View>
|
||||||
<Text className='text-3xl font-bold text-center mb-2'>
|
<Text className='text-3xl font-bold text-center mb-2'>
|
||||||
{t("home.intro.welcome_to_streamyfin")}
|
{t("home.intro.welcome_to_streamyfin")}
|
||||||
@@ -49,42 +51,50 @@ export default function page() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-row items-center mt-4'>
|
{!Platform.isTV && (
|
||||||
<View
|
<>
|
||||||
style={{
|
<View className='flex flex-row items-center mt-4'>
|
||||||
width: 50,
|
<View
|
||||||
height: 50,
|
style={{
|
||||||
}}
|
width: 50,
|
||||||
className='flex items-center justify-center'
|
height: 50,
|
||||||
>
|
}}
|
||||||
<Ionicons name='cloud-download-outline' size={32} color='white' />
|
className='flex items-center justify-center'
|
||||||
</View>
|
>
|
||||||
<View className='shrink ml-2'>
|
<Ionicons
|
||||||
<Text className='font-bold mb-1'>
|
name='cloud-download-outline'
|
||||||
{t("home.intro.downloads_feature_title")}
|
size={32}
|
||||||
</Text>
|
color='white'
|
||||||
<Text className='shrink text-xs'>
|
/>
|
||||||
{t("home.intro.downloads_feature_description")}
|
</View>
|
||||||
</Text>
|
<View className='shrink ml-2'>
|
||||||
</View>
|
<Text className='font-bold mb-1'>
|
||||||
</View>
|
{t("home.intro.downloads_feature_title")}
|
||||||
<View className='flex flex-row items-center mt-4'>
|
</Text>
|
||||||
<View
|
<Text className='shrink text-xs'>
|
||||||
style={{
|
{t("home.intro.downloads_feature_description")}
|
||||||
width: 50,
|
</Text>
|
||||||
height: 50,
|
</View>
|
||||||
}}
|
</View>
|
||||||
className='flex items-center justify-center'
|
<View className='flex flex-row items-center mt-4'>
|
||||||
>
|
<View
|
||||||
<Feather name='cast' size={28} color={"white"} />
|
style={{
|
||||||
</View>
|
width: 50,
|
||||||
<View className='shrink ml-2'>
|
height: 50,
|
||||||
<Text className='font-bold mb-1'>Chromecast</Text>
|
}}
|
||||||
<Text className='shrink text-xs'>
|
className='flex items-center justify-center'
|
||||||
{t("home.intro.chromecast_feature_description")}
|
>
|
||||||
</Text>
|
<Feather name='cast' size={28} color={"white"} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
<View className='shrink ml-2'>
|
||||||
|
<Text className='font-bold mb-1'>Chromecast</Text>
|
||||||
|
<Text className='shrink text-xs'>
|
||||||
|
{t("home.intro.chromecast_feature_description")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<View className='flex flex-row items-center mt-4'>
|
<View className='flex flex-row items-center mt-4'>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -99,19 +109,22 @@ export default function page() {
|
|||||||
<Text className='font-bold mb-1'>
|
<Text className='font-bold mb-1'>
|
||||||
{t("home.intro.centralised_settings_plugin_title")}
|
{t("home.intro.centralised_settings_plugin_title")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='shrink text-xs'>
|
<View className='flex-row flex-wrap items-baseline'>
|
||||||
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
<Text className='shrink text-xs'>
|
||||||
<Text
|
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
||||||
className='text-purple-600'
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
Linking.openURL(
|
Linking.openURL(
|
||||||
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
|
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("home.intro.read_more")}
|
<Text className='text-xs text-purple-600 underline'>
|
||||||
</Text>
|
{t("home.intro.read_more")}
|
||||||
</Text>
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -434,8 +434,6 @@ const TranscodingStreamView = ({
|
|||||||
isTranscoding,
|
isTranscoding,
|
||||||
properties,
|
properties,
|
||||||
transcodeProperties,
|
transcodeProperties,
|
||||||
value,
|
|
||||||
transcodeValue,
|
|
||||||
}: TranscodingStreamViewProps) => {
|
}: TranscodingStreamViewProps) => {
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-col pt-2 first:pt-0'>
|
<View className='flex flex-col pt-2 first:pt-0'>
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default function page() {
|
|||||||
{new Date(log.timestamp).toLocaleString()}
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text uiTextView selectable className='text-xs'>
|
<Text selectable className='text-xs'>
|
||||||
{log.message}
|
{log.message}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function page() {
|
|||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
|
||||||
const { companyId, name, image, type } = local as unknown as {
|
const { companyId, image, type } = local as unknown as {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
name: string;
|
name: string;
|
||||||
image: string;
|
image: string;
|
||||||
|
|||||||
@@ -221,11 +221,7 @@ const Page: React.FC = () => {
|
|||||||
| TvDetails
|
| TvDetails
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text selectable className='font-bold text-2xl mb-1'>
|
||||||
uiTextView
|
|
||||||
selectable
|
|
||||||
className='font-bold text-2xl mb-1'
|
|
||||||
>
|
|
||||||
{mediaTitle}
|
{mediaTitle}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='opacity-50'>{releaseYear}</Text>
|
<Text className='opacity-50'>{releaseYear}</Text>
|
||||||
@@ -256,26 +252,28 @@ const Page: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
details?.mediaInfo?.jellyfinMediaId && (
|
details?.mediaInfo?.jellyfinMediaId && (
|
||||||
<View className='flex flex-row space-x-2 mt-4'>
|
<View className='flex flex-row space-x-2 mt-4'>
|
||||||
<Button
|
{!Platform.isTV && (
|
||||||
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
<Button
|
||||||
color='transparent'
|
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
||||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
color='transparent'
|
||||||
iconLeft={
|
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||||
<Ionicons
|
iconLeft={
|
||||||
name='warning-outline'
|
<Ionicons
|
||||||
size={20}
|
name='warning-outline'
|
||||||
color='white'
|
size={20}
|
||||||
/>
|
color='white'
|
||||||
}
|
/>
|
||||||
style={{
|
}
|
||||||
borderWidth: 1,
|
style={{
|
||||||
borderStyle: "solid",
|
borderWidth: 1,
|
||||||
}}
|
borderStyle: "solid",
|
||||||
>
|
}}
|
||||||
<Text className='text-sm'>
|
>
|
||||||
{t("jellyseerr.report_issue_button")}
|
<Text className='text-sm'>
|
||||||
</Text>
|
{t("jellyseerr.report_issue_button")}
|
||||||
</Button>
|
</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
|
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -333,92 +331,95 @@ const Page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
onDismiss={() => _setRequestBody(undefined)}
|
onDismiss={() => _setRequestBody(undefined)}
|
||||||
/>
|
/>
|
||||||
<BottomSheetModal
|
{!Platform.isTV && (
|
||||||
ref={bottomSheetModalRef}
|
// This is till it's fixed because the menu isn't selectable on TV
|
||||||
enableDynamicSizing
|
<BottomSheetModal
|
||||||
handleIndicatorStyle={{
|
ref={bottomSheetModalRef}
|
||||||
backgroundColor: "white",
|
enableDynamicSizing
|
||||||
}}
|
handleIndicatorStyle={{
|
||||||
backgroundStyle={{
|
backgroundColor: "white",
|
||||||
backgroundColor: "#171717",
|
}}
|
||||||
}}
|
backgroundStyle={{
|
||||||
backdropComponent={renderBackdrop}
|
backgroundColor: "#171717",
|
||||||
>
|
}}
|
||||||
<BottomSheetView>
|
backdropComponent={renderBackdrop}
|
||||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
>
|
||||||
<View>
|
<BottomSheetView>
|
||||||
<Text className='font-bold text-2xl text-neutral-100'>
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
{t("jellyseerr.whats_wrong")}
|
<View>
|
||||||
</Text>
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
</View>
|
{t("jellyseerr.whats_wrong")}
|
||||||
<View className='flex flex-col space-y-2 items-start'>
|
</Text>
|
||||||
<View className='flex flex-col'>
|
</View>
|
||||||
<DropdownMenu.Root>
|
<View className='flex flex-col space-y-2 items-start'>
|
||||||
<DropdownMenu.Trigger>
|
<View className='flex flex-col'>
|
||||||
<View className='flex flex-col'>
|
<DropdownMenu.Root>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
<DropdownMenu.Trigger>
|
||||||
{t("jellyseerr.issue_type")}
|
<View className='flex flex-col'>
|
||||||
</Text>
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
{t("jellyseerr.issue_type")}
|
||||||
<Text style={{}} className='' numberOfLines={1}>
|
|
||||||
{issueType
|
|
||||||
? IssueTypeName[issueType]
|
|
||||||
: t("jellyseerr.select_an_issue")}
|
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
</View>
|
<Text style={{}} className='' numberOfLines={1}>
|
||||||
</DropdownMenu.Trigger>
|
{issueType
|
||||||
<DropdownMenu.Content
|
? IssueTypeName[issueType]
|
||||||
loop={false}
|
: t("jellyseerr.select_an_issue")}
|
||||||
side='bottom'
|
</Text>
|
||||||
align='center'
|
</TouchableOpacity>
|
||||||
alignOffset={0}
|
</View>
|
||||||
avoidCollisions={true}
|
</DropdownMenu.Trigger>
|
||||||
collisionPadding={0}
|
<DropdownMenu.Content
|
||||||
sideOffset={0}
|
loop={false}
|
||||||
>
|
side='bottom'
|
||||||
<DropdownMenu.Label>
|
align='center'
|
||||||
{t("jellyseerr.types")}
|
alignOffset={0}
|
||||||
</DropdownMenu.Label>
|
avoidCollisions={true}
|
||||||
{Object.entries(IssueTypeName)
|
collisionPadding={0}
|
||||||
.reverse()
|
sideOffset={0}
|
||||||
.map(([key, value], _idx) => (
|
>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Label>
|
||||||
key={value}
|
{t("jellyseerr.types")}
|
||||||
onSelect={() =>
|
</DropdownMenu.Label>
|
||||||
setIssueType(key as unknown as IssueType)
|
{Object.entries(IssueTypeName)
|
||||||
}
|
.reverse()
|
||||||
>
|
.map(([key, value], _idx) => (
|
||||||
<DropdownMenu.ItemTitle>
|
<DropdownMenu.Item
|
||||||
{value}
|
key={value}
|
||||||
</DropdownMenu.ItemTitle>
|
onSelect={() =>
|
||||||
</DropdownMenu.Item>
|
setIssueType(key as unknown as IssueType)
|
||||||
))}
|
}
|
||||||
</DropdownMenu.Content>
|
>
|
||||||
</DropdownMenu.Root>
|
<DropdownMenu.ItemTitle>
|
||||||
</View>
|
{value}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
||||||
<BottomSheetTextInput
|
<BottomSheetTextInput
|
||||||
multiline
|
multiline
|
||||||
maxLength={254}
|
maxLength={254}
|
||||||
style={{ color: "white" }}
|
style={{ color: "white" }}
|
||||||
clearButtonMode='always'
|
clearButtonMode='always'
|
||||||
placeholder={t("jellyseerr.describe_the_issue")}
|
placeholder={t("jellyseerr.describe_the_issue")}
|
||||||
placeholderTextColor='#9CA3AF'
|
placeholderTextColor='#9CA3AF'
|
||||||
// Issue with multiline + Textinput inside a portal
|
// Issue with multiline + Textinput inside a portal
|
||||||
// https://github.com/callstack/react-native-paper/issues/1668
|
// https://github.com/callstack/react-native-paper/issues/1668
|
||||||
defaultValue={issueMessage}
|
defaultValue={issueMessage}
|
||||||
onChangeText={setIssueMessage}
|
onChangeText={setIssueMessage}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<Button className='mt-auto' onPress={submitIssue} color='purple'>
|
||||||
|
{t("jellyseerr.submit_button")}
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
<Button className='mt-auto' onPress={submitIssue} color='purple'>
|
</BottomSheetView>
|
||||||
{t("jellyseerr.submit_button")}
|
</BottomSheetModal>
|
||||||
</Button>
|
)}
|
||||||
</View>
|
|
||||||
</BottomSheetView>
|
|
||||||
</BottomSheetModal>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,14 +21,13 @@ export default function page() {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
jellyseerrApi,
|
jellyseerrApi,
|
||||||
jellyseerrUser,
|
|
||||||
jellyseerrRegion: region,
|
jellyseerrRegion: region,
|
||||||
jellyseerrLocale: locale,
|
jellyseerrLocale: locale,
|
||||||
} = useJellyseerr();
|
} = useJellyseerr();
|
||||||
|
|
||||||
const { personId } = local as { personId: string };
|
const { personId } = local as { personId: string };
|
||||||
|
|
||||||
const { data, isLoading, isFetching } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ["jellyseerr", "person", personId],
|
queryKey: ["jellyseerr", "person", personId],
|
||||||
queryFn: async () => ({
|
queryFn: async () => ({
|
||||||
details: await jellyseerrApi?.personDetails(personId),
|
details: await jellyseerrApi?.personDetails(personId),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type {
|
import {
|
||||||
|
createMaterialTopTabNavigator,
|
||||||
MaterialTopTabNavigationEventMap,
|
MaterialTopTabNavigationEventMap,
|
||||||
MaterialTopTabNavigationOptions,
|
MaterialTopTabNavigationOptions,
|
||||||
} from "@react-navigation/material-top-tabs";
|
} from "@react-navigation/material-top-tabs";
|
||||||
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
|
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
|
|||||||
@@ -24,14 +24,6 @@ export default function page() {
|
|||||||
const [date, _setDate] = useState<Date>(new Date());
|
const [date, _setDate] = useState<Date>(new Date());
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
const { data: guideInfo } = useQuery({
|
|
||||||
queryKey: ["livetv", "guideInfo"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await getLiveTvApi(api!).getGuideInfo();
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: channels } = useQuery({
|
const { data: channels } = useQuery({
|
||||||
queryKey: ["livetv", "channels", currentPage],
|
queryKey: ["livetv", "channels", currentPage],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default function IndexLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.library"),
|
headerTitle: t("tabs.library"),
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
@@ -200,7 +200,7 @@ export default function IndexLayout() {
|
|||||||
name='[libraryId]'
|
name='[libraryId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -213,7 +213,7 @@ export default function IndexLayout() {
|
|||||||
name='collections/[collectionId]'
|
name='collections/[collectionId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default function SearchLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.search"),
|
headerTitle: t("tabs.search"),
|
||||||
headerLargeStyle: {
|
headerLargeStyle: {
|
||||||
@@ -31,7 +31,7 @@ export default function SearchLayout() {
|
|||||||
name='collections/[collectionId]'
|
name='collections/[collectionId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
@@ -257,6 +258,26 @@ export default function search() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* <View
|
||||||
|
className='flex flex-col'
|
||||||
|
style={{
|
||||||
|
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||||
|
}}
|
||||||
|
> */}
|
||||||
|
{Platform.isTV && (
|
||||||
|
<Input
|
||||||
|
placeholder={t("search.search")}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
router.setParams({ q: "" });
|
||||||
|
setSearch(text);
|
||||||
|
}}
|
||||||
|
keyboardType='default'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
clearButtonMode='while-editing'
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<View
|
<View
|
||||||
className='flex flex-col'
|
className='flex flex-col'
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default function TabLayout() {
|
|||||||
>
|
>
|
||||||
<NativeTabs.Screen redirect name='index' />
|
<NativeTabs.Screen redirect name='index' />
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
listeners={({ navigation }) => ({
|
listeners={(_e) => ({
|
||||||
tabPress: (_e) => {
|
tabPress: (_e) => {
|
||||||
eventBus.emit("scrollToTop");
|
eventBus.emit("scrollToTop");
|
||||||
},
|
},
|
||||||
@@ -69,7 +69,7 @@ export default function TabLayout() {
|
|||||||
title: t("tabs.home"),
|
title: t("tabs.home"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? ({ focused }) => require("@/assets/icons/house.fill.png")
|
? (_e) => require("@/assets/icons/house.fill.png")
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
focused
|
focused
|
||||||
? { sfSymbol: "house.fill" }
|
? { sfSymbol: "house.fill" }
|
||||||
@@ -77,7 +77,7 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
listeners={({ navigation }) => ({
|
listeners={(_e) => ({
|
||||||
tabPress: (_e) => {
|
tabPress: (_e) => {
|
||||||
eventBus.emit("searchTabPressed");
|
eventBus.emit("searchTabPressed");
|
||||||
},
|
},
|
||||||
@@ -87,7 +87,7 @@ export default function TabLayout() {
|
|||||||
title: t("tabs.search"),
|
title: t("tabs.search"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? ({ focused }) => require("@/assets/icons/magnifyingglass.png")
|
? (_e) => require("@/assets/icons/magnifyingglass.png")
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
focused
|
focused
|
||||||
? { sfSymbol: "magnifyingglass" }
|
? { sfSymbol: "magnifyingglass" }
|
||||||
@@ -116,7 +116,7 @@ export default function TabLayout() {
|
|||||||
title: t("tabs.library"),
|
title: t("tabs.library"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? ({ focused }) => require("@/assets/icons/server.rack.png")
|
? (_e) => require("@/assets/icons/server.rack.png")
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
focused
|
focused
|
||||||
? { sfSymbol: "rectangle.stack.fill" }
|
? { sfSymbol: "rectangle.stack.fill" }
|
||||||
@@ -130,7 +130,7 @@ export default function TabLayout() {
|
|||||||
tabBarItemHidden: !settings?.showCustomMenuLinks,
|
tabBarItemHidden: !settings?.showCustomMenuLinks,
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? ({ focused }) => require("@/assets/icons/list.png")
|
? (_e) => require("@/assets/icons/list.png")
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
focused
|
focused
|
||||||
? { sfSymbol: "list.dash.fill" }
|
? { sfSymbol: "list.dash.fill" }
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-native/no-inline-styles */
|
||||||
import {
|
import {
|
||||||
type BaseItemDto,
|
type BaseItemDto,
|
||||||
type MediaSourceInfo,
|
type MediaSourceInfo,
|
||||||
@@ -13,7 +14,14 @@ import {
|
|||||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||||
import { router, useGlobalSearchParams, useNavigation } from "expo-router";
|
import { router, useGlobalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useReducer,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, View } from "react-native";
|
import { Alert, Platform, View } from "react-native";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
@@ -47,49 +55,74 @@ const downloadProvider = !Platform.isTV
|
|||||||
|
|
||||||
const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas";
|
const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas";
|
||||||
|
|
||||||
export default function page() {
|
/* Playback state reducer to consolidate related state */
|
||||||
|
interface VideoState {
|
||||||
|
isPlaying: boolean;
|
||||||
|
isMuted: boolean;
|
||||||
|
isBuffering: boolean;
|
||||||
|
isVideoLoaded: boolean;
|
||||||
|
isPipStarted: boolean;
|
||||||
|
}
|
||||||
|
type VideoAction =
|
||||||
|
| { type: "PLAYING_CHANGED"; value: boolean }
|
||||||
|
| { type: "BUFFERING_CHANGED"; value: boolean }
|
||||||
|
| { type: "VIDEO_LOADED" }
|
||||||
|
| { type: "MUTED_CHANGED"; value: boolean }
|
||||||
|
| { type: "PIP_CHANGED"; value: boolean };
|
||||||
|
|
||||||
|
const videoReducer = (state: VideoState, action: VideoAction): VideoState => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "PLAYING_CHANGED":
|
||||||
|
return { ...state, isPlaying: action.value };
|
||||||
|
case "BUFFERING_CHANGED":
|
||||||
|
return { ...state, isBuffering: action.value };
|
||||||
|
case "VIDEO_LOADED":
|
||||||
|
// Mark video as loaded and buffering false here
|
||||||
|
return { ...state, isVideoLoaded: true, isBuffering: false };
|
||||||
|
case "MUTED_CHANGED":
|
||||||
|
return { ...state, isMuted: action.value };
|
||||||
|
case "PIP_CHANGED":
|
||||||
|
return { ...state, isPipStarted: action.value };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialVideoState: VideoState = {
|
||||||
|
isPlaying: false,
|
||||||
|
isMuted: false,
|
||||||
|
isBuffering: true,
|
||||||
|
isVideoLoaded: false,
|
||||||
|
isPipStarted: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DirectPlayerPage() {
|
||||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
/* Consolidated video playback state */
|
||||||
|
const [videoState, dispatch] = useReducer(videoReducer, initialVideoState);
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
|
||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(() => {
|
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(() => {
|
||||||
// Load persisted state from storage
|
return storage.getBoolean(IGNORE_SAFE_AREAS_KEY) ?? false;
|
||||||
const saved = storage.getBoolean(IGNORE_SAFE_AREAS_KEY);
|
|
||||||
return saved ?? false;
|
|
||||||
});
|
});
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
const insets = useSafeAreaInsets();
|
||||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
const [settings] = useSettings();
|
||||||
const [isPipStarted, setIsPipStarted] = useState(false);
|
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
const isSeeking = useSharedValue(false);
|
const isSeeking = useSharedValue(false);
|
||||||
const cacheProgress = useSharedValue(0);
|
const cacheProgress = useSharedValue(0);
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
const VolumeManager = Platform.isTV
|
const VolumeManager = Platform.isTV
|
||||||
? null
|
? null
|
||||||
: require("react-native-volume-manager");
|
: require("react-native-volume-manager");
|
||||||
|
|
||||||
const getDownloadedItem = downloadProvider.useDownload();
|
|
||||||
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
|
||||||
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
|
||||||
|
|
||||||
const setShowControls = useCallback((show: boolean) => {
|
|
||||||
_setShowControls(show);
|
|
||||||
lightHapticFeedback();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Persist ignoreSafeAreas state whenever it changes
|
|
||||||
useEffect(() => {
|
|
||||||
storage.set(IGNORE_SAFE_AREAS_KEY, ignoreSafeAreas);
|
|
||||||
}, [ignoreSafeAreas]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
itemId,
|
itemId,
|
||||||
audioIndex: audioIndexStr,
|
audioIndex: audioIndexStr,
|
||||||
@@ -105,71 +138,82 @@ export default function page() {
|
|||||||
mediaSourceId: string;
|
mediaSourceId: string;
|
||||||
bitrateValue: string;
|
bitrateValue: string;
|
||||||
offline: string;
|
offline: string;
|
||||||
/** Playback position in ticks. */
|
|
||||||
playbackPosition?: string;
|
playbackPosition?: string;
|
||||||
}>();
|
}>();
|
||||||
const [settings] = useSettings();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const offline = offlineStr === "true";
|
|
||||||
|
|
||||||
const audioIndex = audioIndexStr
|
const offline = offlineStr === "true";
|
||||||
? Number.parseInt(audioIndexStr, 10)
|
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||||
: undefined;
|
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
|
||||||
const subtitleIndex = subtitleIndexStr
|
|
||||||
? Number.parseInt(subtitleIndexStr, 10)
|
|
||||||
: -1;
|
|
||||||
const bitrateValue = bitrateValueStr
|
const bitrateValue = bitrateValueStr
|
||||||
? Number.parseInt(bitrateValueStr, 10)
|
? parseInt(bitrateValueStr, 10)
|
||||||
: BITRATES[0].value;
|
: BITRATES[0].value;
|
||||||
|
|
||||||
|
const setShowControls = useCallback(
|
||||||
|
(show: boolean) => {
|
||||||
|
_setShowControls(show);
|
||||||
|
lightHapticFeedback();
|
||||||
|
},
|
||||||
|
[lightHapticFeedback],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
storage.set(IGNORE_SAFE_AREAS_KEY, ignoreSafeAreas);
|
||||||
|
}, [ignoreSafeAreas]);
|
||||||
|
|
||||||
|
/* Fetch the item info */
|
||||||
const [item, setItem] = useState<BaseItemDto | null>(null);
|
const [item, setItem] = useState<BaseItemDto | null>(null);
|
||||||
const [itemStatus, setItemStatus] = useState({
|
const [itemStatus, setItemStatus] = useState({
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isError: false,
|
isError: false,
|
||||||
});
|
});
|
||||||
|
const getDownloadedItem = downloadProvider.useDownload();
|
||||||
|
|
||||||
/** Gets the initial playback position from the URL or the item's user data. */
|
|
||||||
const getInitialPlaybackTicks = useCallback((): number => {
|
const getInitialPlaybackTicks = useCallback((): number => {
|
||||||
if (playbackPositionFromUrl) {
|
if (playbackPositionFromUrl) {
|
||||||
return Number.parseInt(playbackPositionFromUrl, 10);
|
return parseInt(playbackPositionFromUrl, 10);
|
||||||
}
|
}
|
||||||
return item?.UserData?.PlaybackPositionTicks ?? 0;
|
return item?.UserData?.PlaybackPositionTicks ?? 0;
|
||||||
}, [playbackPositionFromUrl, item]);
|
}, [playbackPositionFromUrl, item]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchItemData = async () => {
|
if (!itemId) return;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
(async () => {
|
||||||
setItemStatus({ isLoading: true, isError: false });
|
setItemStatus({ isLoading: true, isError: false });
|
||||||
try {
|
try {
|
||||||
let fetchedItem: BaseItemDto | null = null;
|
let fetchedItem: BaseItemDto | null = null;
|
||||||
if (offline && !Platform.isTV) {
|
if (offline && !Platform.isTV) {
|
||||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||||
if (data) fetchedItem = data.item as BaseItemDto;
|
fetchedItem = data?.item as BaseItemDto | null;
|
||||||
} else {
|
} else {
|
||||||
const res = await getUserLibraryApi(api!).getItem({
|
const res = await getUserLibraryApi(api!).getItem(
|
||||||
itemId,
|
{ itemId, userId: user?.Id },
|
||||||
userId: user?.Id,
|
{ signal: controller.signal },
|
||||||
});
|
);
|
||||||
fetchedItem = res.data;
|
fetchedItem = res.data;
|
||||||
}
|
}
|
||||||
setItem(fetchedItem);
|
if (!controller.signal.aborted) {
|
||||||
setItemStatus({ isLoading: false, isError: false });
|
setItem(fetchedItem);
|
||||||
|
setItemStatus({ isLoading: false, isError: false });
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch item:", error);
|
if (!controller.signal.aborted) {
|
||||||
setItemStatus({ isLoading: false, isError: true });
|
console.error("Failed to fetch item:", error);
|
||||||
|
setItemStatus({ isLoading: false, isError: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
})();
|
||||||
|
|
||||||
if (itemId) {
|
return () => controller.abort();
|
||||||
fetchItemData();
|
}, [itemId, offline, api, user?.Id, getDownloadedItem]);
|
||||||
}
|
|
||||||
}, [itemId, offline, api, user?.Id]);
|
|
||||||
|
|
||||||
|
/* Fetch stream info */
|
||||||
interface Stream {
|
interface Stream {
|
||||||
mediaSource: MediaSourceInfo;
|
mediaSource: MediaSourceInfo;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [stream, setStream] = useState<Stream | null>(null);
|
const [stream, setStream] = useState<Stream | null>(null);
|
||||||
const [streamStatus, setStreamStatus] = useState({
|
const [streamStatus, setStreamStatus] = useState({
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
@@ -179,17 +223,16 @@ export default function page() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStreamData = async () => {
|
const fetchStreamData = async () => {
|
||||||
setStreamStatus({ isLoading: true, isError: false });
|
setStreamStatus({ isLoading: true, isError: false });
|
||||||
const native = await generateDeviceProfile();
|
|
||||||
try {
|
try {
|
||||||
|
const native = await generateDeviceProfile();
|
||||||
let result: Stream | null = null;
|
let result: Stream | null = null;
|
||||||
|
|
||||||
if (offline && !Platform.isTV) {
|
if (offline && !Platform.isTV) {
|
||||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||||
if (!data?.mediaSource) return;
|
if (!data?.mediaSource) return;
|
||||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
const url = await getDownloadedFileUrl(data.item.Id!);
|
||||||
if (item) {
|
result = { mediaSource: data.mediaSource, sessionId: "", url };
|
||||||
result = { mediaSource: data.mediaSource, sessionId: "", url };
|
} else if (item) {
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const res = await getStreamUrl({
|
const res = await getStreamUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
@@ -197,7 +240,7 @@ export default function page() {
|
|||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
audioStreamIndex: audioIndex,
|
audioStreamIndex: audioIndex,
|
||||||
maxStreamingBitrate: bitrateValue,
|
maxStreamingBitrate: bitrateValue,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId,
|
||||||
subtitleStreamIndex: subtitleIndex,
|
subtitleStreamIndex: subtitleIndex,
|
||||||
deviceProfile: native,
|
deviceProfile: native,
|
||||||
});
|
});
|
||||||
@@ -212,6 +255,7 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
result = { mediaSource, sessionId, url };
|
result = { mediaSource, sessionId, url };
|
||||||
}
|
}
|
||||||
|
|
||||||
setStream(result);
|
setStream(result);
|
||||||
setStreamStatus({ isLoading: false, isError: false });
|
setStreamStatus({ isLoading: false, isError: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -219,219 +263,310 @@ export default function page() {
|
|||||||
setStreamStatus({ isLoading: false, isError: true });
|
setStreamStatus({ isLoading: false, isError: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchStreamData();
|
fetchStreamData();
|
||||||
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!stream) return;
|
|
||||||
|
|
||||||
const reportPlaybackStart = async () => {
|
|
||||||
await getPlaystateApi(api!).reportPlaybackStart({
|
|
||||||
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
reportPlaybackStart();
|
|
||||||
}, [stream]);
|
|
||||||
|
|
||||||
const togglePlay = async () => {
|
|
||||||
lightHapticFeedback();
|
|
||||||
setIsPlaying(!isPlaying);
|
|
||||||
if (isPlaying) {
|
|
||||||
await videoRef.current?.pause();
|
|
||||||
reportPlaybackProgress();
|
|
||||||
} else {
|
|
||||||
videoRef.current?.play();
|
|
||||||
await getPlaystateApi(api!).reportPlaybackStart({
|
|
||||||
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const reportPlaybackStopped = useCallback(async () => {
|
|
||||||
if (offline) return;
|
|
||||||
const currentTimeInTicks = msToTicks(progress.get());
|
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: currentTimeInTicks,
|
|
||||||
playSessionId: stream?.sessionId!,
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidateProgressCache();
|
|
||||||
}, [
|
}, [
|
||||||
|
itemId,
|
||||||
|
mediaSourceId,
|
||||||
|
bitrateValue,
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
mediaSourceId,
|
user?.Id,
|
||||||
stream,
|
|
||||||
progress,
|
|
||||||
offline,
|
offline,
|
||||||
revalidateProgressCache,
|
getInitialPlaybackTicks,
|
||||||
|
audioIndex,
|
||||||
|
subtitleIndex,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
reportPlaybackStopped();
|
|
||||||
setIsPlaybackStopped(true);
|
|
||||||
videoRef.current?.stop();
|
|
||||||
}, [videoRef, reportPlaybackStopped]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
/* Memoized playback state info for reporting */
|
||||||
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
const currentPlayStateInfo = useMemo(() => {
|
||||||
return () => {
|
if (!stream) return null;
|
||||||
beforeRemoveListener();
|
|
||||||
};
|
|
||||||
}, [navigation, stop]);
|
|
||||||
|
|
||||||
const currentPlayStateInfo = () => {
|
|
||||||
if (!stream) return;
|
|
||||||
return {
|
return {
|
||||||
itemId: item?.Id!,
|
itemId: item?.Id!,
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
audioStreamIndex: audioIndex,
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
subtitleStreamIndex: subtitleIndex,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId,
|
||||||
positionTicks: msToTicks(progress.get()),
|
positionTicks: msToTicks(progress.get()),
|
||||||
isPaused: !isPlaying,
|
isPaused: !videoState.isPlaying,
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
playMethod: stream.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
playSessionId: stream.sessionId,
|
playSessionId: stream.sessionId,
|
||||||
isMuted: isMuted,
|
isMuted: videoState.isMuted,
|
||||||
canSeek: true,
|
canSeek: true,
|
||||||
repeatMode: RepeatMode.RepeatNone,
|
repeatMode: RepeatMode.RepeatNone,
|
||||||
playbackOrder: PlaybackOrder.Default,
|
playbackOrder: PlaybackOrder.Default,
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
|
||||||
async (data: ProgressUpdatePayload) => {
|
|
||||||
if (isSeeking.get() || isPlaybackStopped) return;
|
|
||||||
|
|
||||||
const { currentTime } = data.nativeEvent;
|
|
||||||
if (isBuffering) {
|
|
||||||
setIsBuffering(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
progress.set(currentTime);
|
|
||||||
|
|
||||||
// Update the playback position in the URL.
|
|
||||||
router.setParams({
|
|
||||||
playbackPosition: msToTicks(currentTime).toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (offline) return;
|
|
||||||
if (!item?.Id || !stream) return;
|
|
||||||
|
|
||||||
reportPlaybackProgress();
|
|
||||||
},
|
|
||||||
[
|
|
||||||
item?.Id,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
isPlaying,
|
|
||||||
stream,
|
|
||||||
isSeeking,
|
|
||||||
isPlaybackStopped,
|
|
||||||
isBuffering,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onPipStarted = useCallback((e: PipStartedPayload) => {
|
|
||||||
const { pipStarted } = e.nativeEvent;
|
|
||||||
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,
|
item?.Id,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
progress,
|
progress,
|
||||||
|
videoState.isPlaying,
|
||||||
|
videoState.isMuted,
|
||||||
|
stream,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Gets the initial playback position in seconds. */
|
/* Playback progress reporting */
|
||||||
const startPosition = useMemo(() => {
|
const reportPlaybackProgress = useCallback(async () => {
|
||||||
if (offline) return 0;
|
if (!api || offline || !stream || !currentPlayStateInfo) return;
|
||||||
return ticksToSeconds(getInitialPlaybackTicks());
|
await getPlaystateApi(api).reportPlaybackProgress({
|
||||||
}, [offline, getInitialPlaybackTicks]);
|
playbackProgressInfo: currentPlayStateInfo as PlaybackProgressInfo,
|
||||||
|
});
|
||||||
|
}, [api, offline, stream, currentPlayStateInfo]);
|
||||||
|
|
||||||
|
/* Report playback stopped */
|
||||||
|
const reportPlaybackStopped = useCallback(async () => {
|
||||||
|
if (offline || !stream) return;
|
||||||
|
await getPlaystateApi(api!).onPlaybackStopped({
|
||||||
|
itemId: item?.Id!,
|
||||||
|
mediaSourceId,
|
||||||
|
positionTicks: msToTicks(progress.get()),
|
||||||
|
playSessionId: stream.sessionId,
|
||||||
|
});
|
||||||
|
revalidateProgressCache();
|
||||||
|
}, [
|
||||||
|
api,
|
||||||
|
item?.Id,
|
||||||
|
mediaSourceId,
|
||||||
|
progress,
|
||||||
|
stream,
|
||||||
|
offline,
|
||||||
|
revalidateProgressCache,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/* Toggle play/pause */
|
||||||
|
const togglePlay = useCallback(async () => {
|
||||||
|
lightHapticFeedback();
|
||||||
|
const playing = videoState.isPlaying;
|
||||||
|
dispatch({ type: "PLAYING_CHANGED", value: !playing });
|
||||||
|
|
||||||
|
if (playing) {
|
||||||
|
await videoRef.current?.pause();
|
||||||
|
reportPlaybackProgress();
|
||||||
|
} else {
|
||||||
|
await videoRef.current?.play();
|
||||||
|
if (currentPlayStateInfo) {
|
||||||
|
await getPlaystateApi(api!).reportPlaybackStart({
|
||||||
|
playbackStartInfo: currentPlayStateInfo as PlaybackStartInfo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
videoState.isPlaying,
|
||||||
|
lightHapticFeedback,
|
||||||
|
reportPlaybackProgress,
|
||||||
|
api,
|
||||||
|
currentPlayStateInfo,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/* Stop playback and clean up */
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
reportPlaybackStopped();
|
||||||
|
setIsPlaybackStopped(true);
|
||||||
|
videoRef.current?.stop();
|
||||||
|
}, [reportPlaybackStopped]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = navigation.addListener("beforeRemove", stop);
|
||||||
|
return unsubscribe;
|
||||||
|
}, [navigation, stop]);
|
||||||
|
|
||||||
|
/* VLC init options optimized for performance */
|
||||||
|
const optimizedInitOptions = useMemo(() => {
|
||||||
|
const opts = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||||
|
// Reduce buffering memory usage
|
||||||
|
opts.push("--network-caching=300", "--file-caching=300");
|
||||||
|
if (Platform.OS === "android") opts.push("--aout=opensles");
|
||||||
|
if (Platform.OS === "ios") opts.push("--ios-hw-decoding");
|
||||||
|
|
||||||
|
// Pre-selection of audio & subtitle tracks handled here
|
||||||
|
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||||
|
const allAudio =
|
||||||
|
stream?.mediaSource.MediaStreams?.filter((s) => s.Type === "Audio") ?? [];
|
||||||
|
const allSubs =
|
||||||
|
stream?.mediaSource.MediaStreams?.filter(
|
||||||
|
(s) => s.Type === "Subtitle",
|
||||||
|
)?.sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) ?? [];
|
||||||
|
|
||||||
|
if (subtitleIndex >= 0) {
|
||||||
|
const chosenSubtitleTrack = allSubs.find(
|
||||||
|
(s) => s.Index === subtitleIndex,
|
||||||
|
);
|
||||||
|
const textSubs = allSubs.filter((s) => s.IsTextSubtitleStream);
|
||||||
|
if (
|
||||||
|
chosenSubtitleTrack &&
|
||||||
|
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
||||||
|
) {
|
||||||
|
const finalIdx = notTranscoding
|
||||||
|
? allSubs.indexOf(chosenSubtitleTrack)
|
||||||
|
: textSubs.indexOf(chosenSubtitleTrack);
|
||||||
|
opts.push(`--sub-track=${finalIdx}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (notTranscoding && audioIndex !== undefined) {
|
||||||
|
const chosenAudioTrack = allAudio.find((a) => a.Index === audioIndex);
|
||||||
|
if (chosenAudioTrack)
|
||||||
|
opts.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts;
|
||||||
|
}, [settings.subtitleSize, stream?.mediaSource, subtitleIndex, audioIndex]);
|
||||||
|
|
||||||
|
/* On Picture-In-Picture started or stopped */
|
||||||
|
const onPipStarted = useCallback((e: PipStartedPayload) => {
|
||||||
|
dispatch({ type: "PIP_CHANGED", value: e.nativeEvent.pipStarted });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* Progress event handler */
|
||||||
|
const onProgress = useCallback(
|
||||||
|
(data: ProgressUpdatePayload) => {
|
||||||
|
if (isSeeking.get() || isPlaybackStopped) return;
|
||||||
|
if (videoState.isBuffering)
|
||||||
|
dispatch({ type: "BUFFERING_CHANGED", value: false });
|
||||||
|
const { currentTime } = data.nativeEvent;
|
||||||
|
progress.set(currentTime);
|
||||||
|
router.setParams({ playbackPosition: msToTicks(currentTime).toString() });
|
||||||
|
if (!offline) reportPlaybackProgress();
|
||||||
|
},
|
||||||
|
[
|
||||||
|
isSeeking,
|
||||||
|
isPlaybackStopped,
|
||||||
|
progress,
|
||||||
|
offline,
|
||||||
|
reportPlaybackProgress,
|
||||||
|
videoState.isBuffering,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Playback state changes */
|
||||||
|
const onPlaybackStateChanged = useCallback(
|
||||||
|
async (e: PlaybackStatePayload) => {
|
||||||
|
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||||
|
switch (state) {
|
||||||
|
case "Playing":
|
||||||
|
dispatch({ type: "PLAYING_CHANGED", value: true });
|
||||||
|
await activateKeepAwakeAsync();
|
||||||
|
reportPlaybackProgress();
|
||||||
|
break;
|
||||||
|
case "Paused":
|
||||||
|
dispatch({ type: "PLAYING_CHANGED", value: false });
|
||||||
|
await deactivateKeepAwake();
|
||||||
|
reportPlaybackProgress();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
dispatch({ type: "BUFFERING_CHANGED", value: !!isBuffering });
|
||||||
|
dispatch({ type: "PLAYING_CHANGED", value: !!isPlaying });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[reportPlaybackProgress],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Safe wrapper for player methods that skips calls if video not loaded */
|
||||||
|
const safeMethod =
|
||||||
|
<T extends unknown[]>(
|
||||||
|
fn: ((...args: T) => any) | undefined,
|
||||||
|
name: string,
|
||||||
|
) =>
|
||||||
|
async (...args: T) => {
|
||||||
|
// New safeguard: skip calling if video not loaded yet
|
||||||
|
if (!videoState.isVideoLoaded) {
|
||||||
|
writeToLog("WARN", `${name} skipped - video not loaded yet`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fn) {
|
||||||
|
writeToLog("ERROR", `${name} fn missing`, {
|
||||||
|
isVideoLoaded: videoState.isVideoLoaded,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await fn(...args);
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", `Error in ${name}`, {
|
||||||
|
error,
|
||||||
|
isVideoLoaded: videoState.isVideoLoaded,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const play = useCallback(
|
||||||
|
() => safeMethod(videoRef.current?.play, "play")(),
|
||||||
|
[videoRef],
|
||||||
|
);
|
||||||
|
const pause = useCallback(
|
||||||
|
() => safeMethod(videoRef.current?.pause, "pause")(),
|
||||||
|
[videoRef],
|
||||||
|
);
|
||||||
|
const startPictureInPicture = useCallback(
|
||||||
|
() => safeMethod(videoRef.current?.startPictureInPicture, "PiP")(),
|
||||||
|
[videoRef],
|
||||||
|
);
|
||||||
|
const seek = useCallback(
|
||||||
|
(t: number) => safeMethod(videoRef.current?.seekTo, "seek")(t),
|
||||||
|
[videoRef],
|
||||||
|
);
|
||||||
|
const getAudioTracks = useCallback(
|
||||||
|
() => safeMethod(videoRef.current?.getAudioTracks, "getAudioTracks")(),
|
||||||
|
[videoRef],
|
||||||
|
);
|
||||||
|
const getSubtitleTracks = useCallback(
|
||||||
|
() =>
|
||||||
|
safeMethod(videoRef.current?.getSubtitleTracks, "getSubtitleTracks")(),
|
||||||
|
[videoRef],
|
||||||
|
);
|
||||||
|
const setAudioTrack = useCallback(
|
||||||
|
(i: number) =>
|
||||||
|
safeMethod(videoRef.current?.setAudioTrack, "setAudioTrack")(i),
|
||||||
|
[videoRef],
|
||||||
|
);
|
||||||
|
const setSubtitleTrack = useCallback(
|
||||||
|
(i: number) =>
|
||||||
|
safeMethod(videoRef.current?.setSubtitleTrack, "setSubtitleTrack")(i),
|
||||||
|
[videoRef],
|
||||||
|
);
|
||||||
|
const setSubtitleURL = useCallback(
|
||||||
|
(url: string, n: string) =>
|
||||||
|
safeMethod(videoRef.current?.setSubtitleURL, "setSubtitleURL")(url, n),
|
||||||
|
[videoRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Volume handlers */
|
||||||
|
const [previousVolume, setPreviousVolume] = useState<number | null>(null);
|
||||||
const volumeUpCb = useCallback(async () => {
|
const volumeUpCb = useCallback(async () => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
|
const { volume } = await VolumeManager.getVolume();
|
||||||
try {
|
await VolumeManager.setVolume(Math.min(volume + 0.1, 1));
|
||||||
const { volume: currentVolume } = await VolumeManager.getVolume();
|
|
||||||
const newVolume = Math.min(currentVolume + 0.1, 1.0);
|
|
||||||
|
|
||||||
await VolumeManager.setVolume(newVolume);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error adjusting volume:", error);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
const [previousVolume, setPreviousVolume] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const toggleMuteCb = useCallback(async () => {
|
|
||||||
if (Platform.isTV) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { volume: currentVolume } = await VolumeManager.getVolume();
|
|
||||||
const currentVolumePercent = currentVolume * 100;
|
|
||||||
|
|
||||||
if (currentVolumePercent > 0) {
|
|
||||||
// Currently not muted, so mute
|
|
||||||
setPreviousVolume(currentVolumePercent);
|
|
||||||
await VolumeManager.setVolume(0);
|
|
||||||
setIsMuted(true);
|
|
||||||
} else {
|
|
||||||
// Currently muted, so restore previous volume
|
|
||||||
const volumeToRestore = previousVolume || 50; // Default to 50% if no previous volume
|
|
||||||
await VolumeManager.setVolume(volumeToRestore / 100);
|
|
||||||
setPreviousVolume(null);
|
|
||||||
setIsMuted(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error toggling mute:", error);
|
|
||||||
}
|
|
||||||
}, [previousVolume]);
|
|
||||||
const volumeDownCb = useCallback(async () => {
|
const volumeDownCb = useCallback(async () => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
|
const { volume } = await VolumeManager.getVolume();
|
||||||
try {
|
await VolumeManager.setVolume(Math.max(volume - 0.1, 0));
|
||||||
const { volume: currentVolume } = await VolumeManager.getVolume();
|
|
||||||
const newVolume = Math.max(currentVolume - 0.1, 0); // Decrease by 10%
|
|
||||||
console.log(
|
|
||||||
"Volume Down",
|
|
||||||
Math.round(currentVolume * 100),
|
|
||||||
"→",
|
|
||||||
Math.round(newVolume * 100),
|
|
||||||
);
|
|
||||||
await VolumeManager.setVolume(newVolume);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error adjusting volume:", error);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
const setVolumeCb = useCallback(async (v: number) => {
|
||||||
const setVolumeCb = useCallback(async (newVolume: number) => {
|
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
|
await VolumeManager.setVolume(Math.max(0, Math.min(v, 100)) / 100);
|
||||||
try {
|
|
||||||
const clampedVolume = Math.max(0, Math.min(newVolume, 100));
|
|
||||||
console.log("Setting volume to", clampedVolume);
|
|
||||||
await VolumeManager.setVolume(clampedVolume / 100);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error setting volume:", error);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
const toggleMuteCb = useCallback(async () => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
const { volume } = await VolumeManager.getVolume();
|
||||||
|
const percent = volume * 100;
|
||||||
|
if (percent > 0) {
|
||||||
|
setPreviousVolume(percent);
|
||||||
|
await VolumeManager.setVolume(0);
|
||||||
|
dispatch({ type: "MUTED_CHANGED", value: true });
|
||||||
|
} else {
|
||||||
|
const restore = previousVolume || 50;
|
||||||
|
await VolumeManager.setVolume(restore / 100);
|
||||||
|
setPreviousVolume(null);
|
||||||
|
dispatch({ type: "MUTED_CHANGED", value: false });
|
||||||
|
}
|
||||||
|
}, [previousVolume]);
|
||||||
|
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
isPlaying: isPlaying,
|
isPlaying: videoState.isPlaying,
|
||||||
togglePlay: togglePlay,
|
togglePlay,
|
||||||
stopPlayback: stop,
|
stopPlayback: stop,
|
||||||
offline,
|
offline,
|
||||||
toggleMute: toggleMuteCb,
|
toggleMute: toggleMuteCb,
|
||||||
@@ -440,107 +575,44 @@ export default function page() {
|
|||||||
setVolume: setVolumeCb,
|
setVolume: setVolumeCb,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onPlaybackStateChanged = useCallback(
|
/* Calculate start position in seconds */
|
||||||
async (e: PlaybackStatePayload) => {
|
const startPosition = useMemo(
|
||||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
() => (offline ? 0 : ticksToSeconds(getInitialPlaybackTicks())),
|
||||||
if (state === "Playing") {
|
[offline, getInitialPlaybackTicks],
|
||||||
setIsPlaying(true);
|
|
||||||
reportPlaybackProgress();
|
|
||||||
if (!Platform.isTV) await activateKeepAwakeAsync();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === "Paused") {
|
|
||||||
setIsPlaying(false);
|
|
||||||
reportPlaybackProgress();
|
|
||||||
if (!Platform.isTV) await deactivateKeepAwake();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlaying) {
|
|
||||||
setIsPlaying(true);
|
|
||||||
setIsBuffering(false);
|
|
||||||
} else if (isBuffering) {
|
|
||||||
setIsBuffering(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[reportPlaybackProgress],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const allAudio =
|
/* Conditionally render based on loading and error state */
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
if (itemStatus.isError || streamStatus.isError) {
|
||||||
(audio) => audio.Type === "Audio",
|
|
||||||
) || [];
|
|
||||||
|
|
||||||
// Move all the external subtitles last, because vlc places them last.
|
|
||||||
const allSubs =
|
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
|
||||||
(sub) => sub.Type === "Subtitle",
|
|
||||||
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || [];
|
|
||||||
|
|
||||||
const externalSubtitles = allSubs
|
|
||||||
.filter((sub: any) => sub.DeliveryMethod === "External")
|
|
||||||
.map((sub: any) => ({
|
|
||||||
name: sub.DisplayTitle,
|
|
||||||
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
|
||||||
|
|
||||||
const chosenSubtitleTrack = allSubs.find(
|
|
||||||
(sub) => sub.Index === subtitleIndex,
|
|
||||||
);
|
|
||||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
|
||||||
|
|
||||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
|
||||||
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
|
||||||
if (
|
|
||||||
chosenSubtitleTrack &&
|
|
||||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
|
||||||
) {
|
|
||||||
const finalIndex = notTranscoding
|
|
||||||
? allSubs.indexOf(chosenSubtitleTrack)
|
|
||||||
: textSubs.indexOf(chosenSubtitleTrack);
|
|
||||||
initOptions.push(`--sub-track=${finalIndex}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notTranscoding && chosenAudioTrack) {
|
|
||||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
|
||||||
|
|
||||||
// Add useEffect to handle mounting
|
|
||||||
useEffect(() => {
|
|
||||||
setIsMounted(true);
|
|
||||||
return () => setIsMounted(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (itemStatus.isLoading || streamStatus.isLoading) {
|
|
||||||
return (
|
return (
|
||||||
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
|
<View className='w-screen h-screen items-center justify-center bg-black'>
|
||||||
|
<Text className='text-white'>{t("player.error")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
|
||||||
|
return (
|
||||||
|
<View className='w-screen h-screen items-center justify-center bg-black'>
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (itemStatus.isError || streamStatus.isError)
|
const allSubs =
|
||||||
return (
|
stream?.mediaSource.MediaStreams?.filter((s) => s.Type === "Subtitle") ||
|
||||||
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
|
[];
|
||||||
<Text className='text-white'>{t("player.error")}</Text>
|
const externalSubtitles = allSubs
|
||||||
</View>
|
.filter((s) => s.DeliveryMethod === "External")
|
||||||
);
|
.map((s) => ({
|
||||||
|
name: s.DisplayTitle,
|
||||||
|
DeliveryUrl: api?.basePath + s.DeliveryUrl,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
<View style={{ flex: 1, backgroundColor: "black" }}>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
position: "relative",
|
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
|
||||||
paddingLeft: ignoreSafeAreas ? 0 : insets.left,
|
paddingLeft: ignoreSafeAreas ? 0 : insets.left,
|
||||||
paddingRight: ignoreSafeAreas ? 0 : insets.right,
|
paddingRight: ignoreSafeAreas ? 0 : insets.right,
|
||||||
}}
|
}}
|
||||||
@@ -548,21 +620,20 @@ export default function page() {
|
|||||||
<VlcPlayerView
|
<VlcPlayerView
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
source={{
|
source={{
|
||||||
uri: stream?.url || "",
|
uri: stream.url,
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
isNetwork: true,
|
isNetwork: true,
|
||||||
startPosition,
|
startPosition,
|
||||||
externalSubtitles,
|
externalSubtitles,
|
||||||
initOptions,
|
initOptions: optimizedInitOptions,
|
||||||
}}
|
}}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
onVideoProgress={onProgress}
|
onVideoProgress={onProgress}
|
||||||
progressUpdateInterval={1000}
|
progressUpdateInterval={1000}
|
||||||
onVideoStateChange={onPlaybackStateChanged}
|
onVideoStateChange={onPlaybackStateChanged}
|
||||||
onPipStarted={onPipStarted}
|
onPipStarted={onPipStarted}
|
||||||
onVideoLoadEnd={() => {
|
// Mark video as loaded on load end to enable player method calls safely
|
||||||
setIsVideoLoaded(true);
|
onVideoLoadEnd={() => dispatch({ type: "VIDEO_LOADED" })}
|
||||||
}}
|
|
||||||
onVideoError={(e) => {
|
onVideoError={(e) => {
|
||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -573,36 +644,42 @@ export default function page() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{videoRef.current && !isPipStarted && isMounted === true && item ? (
|
|
||||||
|
{!videoState.isPipStarted && (
|
||||||
<Controls
|
<Controls
|
||||||
mediaSource={stream?.mediaSource}
|
mediaSource={stream.mediaSource}
|
||||||
item={item}
|
item={item}
|
||||||
videoRef={videoRef}
|
videoRef={videoRef}
|
||||||
togglePlay={togglePlay}
|
togglePlay={togglePlay}
|
||||||
isPlaying={isPlaying}
|
isPlaying={videoState.isPlaying}
|
||||||
isSeeking={isSeeking}
|
isSeeking={isSeeking}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
cacheProgress={cacheProgress}
|
cacheProgress={cacheProgress}
|
||||||
isBuffering={isBuffering}
|
isBuffering={videoState.isBuffering}
|
||||||
showControls={showControls}
|
showControls={showControls}
|
||||||
setShowControls={setShowControls}
|
setShowControls={setShowControls}
|
||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
ignoreSafeAreas={ignoreSafeAreas}
|
||||||
isVideoLoaded={isVideoLoaded}
|
isVideoLoaded={videoState.isVideoLoaded}
|
||||||
startPictureInPicture={videoRef?.current?.startPictureInPicture}
|
startPictureInPicture={startPictureInPicture}
|
||||||
play={videoRef.current?.play}
|
play={play}
|
||||||
pause={videoRef.current?.pause}
|
pause={pause}
|
||||||
seek={videoRef.current?.seekTo}
|
seek={seek}
|
||||||
enableTrickplay={true}
|
enableTrickplay
|
||||||
getAudioTracks={videoRef.current?.getAudioTracks}
|
// Pass undefined for player methods until the video is loaded to avoid crashes
|
||||||
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
getAudioTracks={videoState.isVideoLoaded ? getAudioTracks : undefined}
|
||||||
|
getSubtitleTracks={
|
||||||
|
videoState.isVideoLoaded ? getSubtitleTracks : undefined
|
||||||
|
}
|
||||||
offline={offline}
|
offline={offline}
|
||||||
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
setSubtitleTrack={
|
||||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
videoState.isVideoLoaded ? setSubtitleTrack : undefined
|
||||||
setAudioTrack={videoRef.current.setAudioTrack}
|
}
|
||||||
|
setSubtitleURL={videoState.isVideoLoaded ? setSubtitleURL : undefined}
|
||||||
|
setAudioTrack={videoState.isVideoLoaded ? setAudioTrack : undefined}
|
||||||
isVlc
|
isVlc
|
||||||
/>
|
/>
|
||||||
) : null}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ScrollViewStyleReset } from "expo-router/html";
|
import { ScrollViewStyleReset } from "expo-router/html";
|
||||||
import type { PropsWithChildren } from "react";
|
import { type PropsWithChildren } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This file is web-only and used to configure the root HTML for every web page during static rendering.
|
* This file is web-only and used to configure the root HTML for every web page during static rendering.
|
||||||
|
|||||||
156
app/_layout.tsx
@@ -146,91 +146,99 @@ if (!Platform.isTV) {
|
|||||||
console.log("TaskManager ~ trigger");
|
console.log("TaskManager ~ trigger");
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const settingsData = storage.getString("settings");
|
|
||||||
|
|
||||||
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
try {
|
||||||
|
const settingsData = storage.getString("settings");
|
||||||
|
|
||||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
const url = settings?.optimizedVersionsServerUrl;
|
|
||||||
if (!settings?.autoDownload || !url)
|
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
|
||||||
|
|
||||||
const token = getTokenFromStorage();
|
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||||
const deviceId = getOrSetDeviceId();
|
const url = settings?.optimizedVersionsServerUrl;
|
||||||
const baseDirectory = FileSystem.documentDirectory;
|
|
||||||
|
|
||||||
if (!token || !deviceId || !baseDirectory)
|
if (!settings?.autoDownload || !url)
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const jobs = await getAllJobsByDeviceId({
|
const token = getTokenFromStorage();
|
||||||
deviceId,
|
const deviceId = getOrSetDeviceId();
|
||||||
authHeader: token,
|
const baseDirectory = FileSystem.documentDirectory;
|
||||||
url,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
if (!token || !deviceId || !baseDirectory)
|
||||||
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
for (const job of jobs) {
|
const jobs = await getAllJobsByDeviceId({
|
||||||
if (job.status === "completed") {
|
deviceId,
|
||||||
const downloadUrl = `${url}download/${job.id}`;
|
authHeader: token,
|
||||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
url,
|
||||||
|
});
|
||||||
|
|
||||||
if (tasks.find((task: { id: string }) => task.id === job.id)) {
|
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
||||||
console.log("TaskManager ~ Download already in progress: ", job.id);
|
|
||||||
continue;
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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()}`);
|
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
||||||
// Be sure to return the successful result type!
|
|
||||||
return BackgroundFetch.BackgroundFetchResult.NewData;
|
// Be sure to return the successful result type!
|
||||||
|
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Background task error:", error);
|
||||||
|
return BackgroundFetch.BackgroundFetchResult.Failed;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ const Login: React.FC = () => {
|
|||||||
marginLeft: -23,
|
marginLeft: -23,
|
||||||
marginBottom: -20,
|
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-3xl font-bold'>Streamyfin</Text>
|
||||||
<Text className='text-neutral-500'>
|
<Text className='text-neutral-500'>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 79 KiB |
BIN
assets/images/icon-android-plain.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
assets/images/icon-android-themed.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 75 KiB |
@@ -7,15 +7,27 @@ declare module "react-native-mmkv" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add the augmentation methods directly to the MMKV prototype
|
||||||
|
// This follows the recommended pattern while adding the helper methods your app uses
|
||||||
MMKV.prototype.get = function <T>(key: string): T | undefined {
|
MMKV.prototype.get = function <T>(key: string): T | undefined {
|
||||||
const serializedItem = this.getString(key);
|
try {
|
||||||
return serializedItem ? JSON.parse(serializedItem) : undefined;
|
const serializedItem = this.getString(key);
|
||||||
|
if (!serializedItem) return undefined;
|
||||||
|
return JSON.parse(serializedItem);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to parse MMKV value for key "${key}":`, error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
||||||
if (value === undefined) {
|
try {
|
||||||
this.delete(key);
|
if (value === undefined) {
|
||||||
} else {
|
this.delete(key);
|
||||||
this.set(key, JSON.stringify(value));
|
} else {
|
||||||
|
this.set(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to set MMKV value for key "${key}":`, error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.1.2/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.1.4/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"**/*",
|
"**/*",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { Platform, type ViewProps } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import GoogleCast, {
|
import GoogleCast, {
|
||||||
CastButton,
|
CastButton,
|
||||||
CastContext,
|
CastContext,
|
||||||
@@ -11,12 +11,6 @@ import GoogleCast, {
|
|||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
background?: "blur" | "transparent";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Chromecast({
|
export function Chromecast({
|
||||||
width = 48,
|
width = 48,
|
||||||
height = 48,
|
height = 48,
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from "zeego/context-menu";
|
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
|
||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -17,7 +15,6 @@ import Animated, {
|
|||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
@@ -37,12 +34,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
selectedOptions,
|
selectedOptions,
|
||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||||
const _api = useAtomValue(apiAtom);
|
|
||||||
const _user = useAtomValue(userAtom);
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function InfiniteHorizontalScroll({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
getNextPageParam: (lastPage, pages) => {
|
getNextPageParam: (lastPage, pages) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useRouter, useSegments } from "expo-router";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { type PropsWithChildren, useCallback, useMemo } from "react";
|
import { type PropsWithChildren, useCallback, useMemo } from "react";
|
||||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
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 { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import { Platform, Text as RNText, type TextProps } from "react-native";
|
import { Platform, Text as RNText, type TextProps } from "react-native";
|
||||||
import { UITextView } from "react-native-uitextview";
|
export function Text(props: TextProps) {
|
||||||
export function Text(
|
|
||||||
props: TextProps & {
|
|
||||||
uiTextView?: boolean;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const { style, ...otherProps } = props;
|
const { style, ...otherProps } = props;
|
||||||
if (Platform.isTV)
|
if (Platform.isTV)
|
||||||
return (
|
return (
|
||||||
@@ -16,7 +11,7 @@ export function Text(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UITextView
|
<RNText
|
||||||
allowFontScaling={false}
|
allowFontScaling={false}
|
||||||
style={[{ color: "white" }, style]}
|
style={[{ color: "white" }, style]}
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ interface DownloadCardProps extends TouchableOpacityProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||||
const { processes, startDownload } = useDownload();
|
const { startDownload } = useDownload();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { removeProcess, setProcesses } = useDownload();
|
const { removeProcess } = useDownload();
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ interface EpisodeCardProps extends TouchableOpacityProps {
|
|||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||||
const { deleteFile } = useDownload();
|
const { deleteFile } = useDownload();
|
||||||
const { openFile } = useDownloadedFileOpener();
|
const { openFile } = useDownloadedFileOpener();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ export const FilterSheet = <T,>({
|
|||||||
renderItemLabel,
|
renderItemLabel,
|
||||||
showSearch = true,
|
showSearch = true,
|
||||||
multiple = false,
|
multiple = false,
|
||||||
...props
|
|
||||||
}: Props<T>) => {
|
}: Props<T>) => {
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
const snapPoints = useMemo(() => ["80%"], []);
|
const snapPoints = useMemo(() => ["80%"], []);
|
||||||
|
|||||||
@@ -50,11 +50,8 @@ const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
|
|||||||
const DetailFacts: React.FC<
|
const DetailFacts: React.FC<
|
||||||
{ details?: MovieDetails | TvDetails } & ViewProps
|
{ details?: MovieDetails | TvDetails } & ViewProps
|
||||||
> = ({ details, className, ...props }) => {
|
> = ({ details, className, ...props }) => {
|
||||||
const {
|
const { jellyseerrRegion: region, jellyseerrLocale: locale } =
|
||||||
jellyseerrUser,
|
useJellyseerr();
|
||||||
jellyseerrRegion: region,
|
|
||||||
jellyseerrLocale: locale,
|
|
||||||
} = useJellyseerr();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const releases = useMemo(
|
const releases = useMemo(
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ const ParallaxSlideShow = <T,>({
|
|||||||
renderItem,
|
renderItem,
|
||||||
keyExtractor,
|
keyExtractor,
|
||||||
onEndReached,
|
onEndReached,
|
||||||
...props
|
|
||||||
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
|||||||
@@ -38,16 +38,7 @@ const RequestModal = forwardRef<
|
|||||||
Props & Omit<ViewProps, "id">
|
Props & Omit<ViewProps, "id">
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
{
|
{ id, title, requestBody, type, isAnime = false, onRequested, onDismiss },
|
||||||
id,
|
|
||||||
title,
|
|
||||||
requestBody,
|
|
||||||
type,
|
|
||||||
isAnime = false,
|
|
||||||
onRequested,
|
|
||||||
onDismiss,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
|||||||
[slide],
|
[slide],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isFetching, isLoading } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ["jellyseerr", "discover", slide.type, slide.id],
|
queryKey: ["jellyseerr", "discover", slide.type, slide.id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return jellyseerrApi?.getGenreSliders(
|
return jellyseerrApi?.getGenreSliders(
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces
|
|||||||
const RequestCard: React.FC<{ request: MediaRequest }> = ({ request }) => {
|
const RequestCard: React.FC<{ request: MediaRequest }> = ({ request }) => {
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
|
||||||
const {
|
const { data: details } = useQuery({
|
||||||
data: details,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"jellyseerr",
|
"jellyseerr",
|
||||||
"detail",
|
"detail",
|
||||||
@@ -57,11 +53,7 @@ const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
|
||||||
const {
|
const { data: requests } = useQuery({
|
||||||
data: requests,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["jellyseerr", "recent_requests"],
|
queryKey: ["jellyseerr", "recent_requests"],
|
||||||
queryFn: async () => jellyseerrApi?.requests(),
|
queryFn: async () => jellyseerrApi?.requests(),
|
||||||
enabled: !!jellyseerrApi,
|
enabled: !!jellyseerrApi,
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ const ListItemContent = ({
|
|||||||
showArrow,
|
showArrow,
|
||||||
iconAfter,
|
iconAfter,
|
||||||
children,
|
children,
|
||||||
...props
|
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface Props extends ViewProps {
|
|||||||
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text uiTextView selectable className='font-bold text-2xl mb-1'>
|
<Text selectable className='font-bold text-2xl mb-1'>
|
||||||
{item?.Name}
|
{item?.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='opacity-50'>{item?.ProductionYear}</Text>
|
<Text className='opacity-50'>{item?.ProductionYear}</Text>
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ const JellyseerrPoster: React.FC<Props> = ({
|
|||||||
horizontal,
|
horizontal,
|
||||||
showDownloadInfo,
|
showDownloadInfo,
|
||||||
mediaRequest,
|
mediaRequest,
|
||||||
...props
|
|
||||||
}) => {
|
}) => {
|
||||||
const { jellyseerrApi, getTitle, getYear, getMediaType } = useJellyseerr();
|
const { jellyseerrApi, getTitle, getYear, getMediaType } = useJellyseerr();
|
||||||
const loadingOpacity = useSharedValue(1);
|
const loadingOpacity = useSharedValue(1);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const SearchItemWrapper = <T,>({
|
|||||||
onEndReachedThreshold={1}
|
onEndReachedThreshold={1}
|
||||||
onEndReached={onEndReached}
|
onEndReached={onEndReached}
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
renderItem={({ item, index }) => (item ? renderItem(item) : <></>)}
|
renderItem={({ item }) => (item ? renderItem(item) : null)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const EpisodeTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text uiTextView className='font-bold text-2xl' selectable>
|
<Text className='font-bold text-2xl' selectable>
|
||||||
{item?.Name}
|
{item?.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='flex flex-row items-center mb-1'>
|
<View className='flex flex-row items-center mb-1'>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const JellyseerrSeasonEpisodes: React.FC<{
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const RenderItem = ({ item, index }: any) => {
|
const RenderItem = ({ item }: any) => {
|
||||||
const {
|
const {
|
||||||
jellyseerrApi,
|
jellyseerrApi,
|
||||||
jellyseerrRegion: region,
|
jellyseerrRegion: region,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ type Props = {
|
|||||||
|
|
||||||
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
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 [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { ListItem } from "../list/ListItem";
|
|||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const AppLanguageSelector: React.FC<Props> = ({ ...props }) => {
|
export const AppLanguageSelector: React.FC<Props> = () => {
|
||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { ListItem } from "../list/ListItem";
|
|||||||
|
|
||||||
export const Dashboard = () => {
|
export const Dashboard = () => {
|
||||||
const [settings, _updateSettings] = useSettings();
|
const [settings, _updateSettings] = useSettings();
|
||||||
const { sessions = [], isLoading } = useSessions({} as useSessionsProps);
|
const { sessions = [] } = useSessions({} as useSessionsProps);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export default function DownloadSettings({ ...props }) {
|
export default function DownloadSettings() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,8 @@ import { ListGroup } from "../list/ListGroup";
|
|||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export const JellyseerrSettings = () => {
|
export const JellyseerrSettings = () => {
|
||||||
const {
|
const { jellyseerrUser, setJellyseerrUser, clearAllJellyseerData } =
|
||||||
jellyseerrApi,
|
useJellyseerr();
|
||||||
jellyseerrUser,
|
|
||||||
setJellyseerrUser,
|
|
||||||
clearAllJellyseerData,
|
|
||||||
} = useJellyseerr();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const StorageSettings = () => {
|
|||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
const errorHapticFeedback = useHaptic("error");
|
const errorHapticFeedback = useHaptic("error");
|
||||||
|
|
||||||
const { data: size, isLoading: appSizeLoading } = useQuery({
|
const { data: size } = useQuery({
|
||||||
queryKey: ["appSize", appSizeUsage],
|
queryKey: ["appSize", appSizeUsage],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const app = await appSizeUsage;
|
const app = await appSizeUsage;
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { Platform, StyleSheet, View } from "react-native";
|
import { Platform, StyleSheet, View } from "react-native";
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import type { VolumeResult } from "react-native-volume-manager";
|
||||||
|
|
||||||
const VolumeManager = Platform.isTV
|
const VolumeManager = Platform.isTV
|
||||||
? null
|
? null
|
||||||
: require("react-native-volume-manager");
|
: require("react-native-volume-manager");
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { VolumeResult } from "react-native-volume-manager";
|
|
||||||
|
|
||||||
interface AudioSliderProps {
|
interface AudioSliderProps {
|
||||||
setVisibility: (show: boolean) => void;
|
setVisibility: (show: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -47,7 +46,7 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
|||||||
|
|
||||||
const handleValueChange = async (value: number) => {
|
const handleValueChange = async (value: number) => {
|
||||||
volume.value = value;
|
volume.value = value;
|
||||||
await VolumeManager.setVolume(value / 100);
|
// await VolumeManager.setVolume(value / 100);
|
||||||
|
|
||||||
// Re-call showNativeVolumeUI to ensure the setting is applied on iOS
|
// Re-call showNativeVolumeUI to ensure the setting is applied on iOS
|
||||||
VolumeManager.showNativeVolumeUI({ enabled: false });
|
VolumeManager.showNativeVolumeUI({ enabled: false });
|
||||||
@@ -55,7 +54,7 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isTv) return;
|
if (isTv) return;
|
||||||
const volumeListener = VolumeManager.addVolumeListener(
|
const _volumeListener = VolumeManager.addVolumeListener(
|
||||||
(result: VolumeResult) => {
|
(result: VolumeResult) => {
|
||||||
volume.value = result.volume * 100;
|
volume.value = result.volume * 100;
|
||||||
setVisibility(true);
|
setVisibility(true);
|
||||||
@@ -73,7 +72,7 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
volumeListener.remove();
|
// volumeListener.remove();
|
||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
clearTimeout(timeoutRef.current);
|
clearTimeout(timeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
Platform,
|
Platform,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
|
useTVEventHandler,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
@@ -156,6 +157,134 @@ export const Controls: FC<Props> = ({
|
|||||||
prefetchAllTrickplayImages();
|
prefetchAllTrickplayImages();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const remoteScrubProgress = useSharedValue<number | null>(null);
|
||||||
|
const isRemoteScrubbing = useSharedValue(false);
|
||||||
|
const SCRUB_INTERVAL = isVlc ? secondsToMs(10) : msToTicks(secondsToMs(10));
|
||||||
|
const [showRemoteBubble, setShowRemoteBubble] = useState(false);
|
||||||
|
|
||||||
|
const [longPressScrubMode, setLongPressScrubMode] = useState<
|
||||||
|
"FF" | "RW" | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
useTVEventHandler((evt) => {
|
||||||
|
if (!evt) return;
|
||||||
|
|
||||||
|
switch (evt.eventType) {
|
||||||
|
case "longLeft": {
|
||||||
|
setLongPressScrubMode((prev) => (!prev ? "RW" : null));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "longRight": {
|
||||||
|
setLongPressScrubMode((prev) => (!prev ? "FF" : null));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "left":
|
||||||
|
case "right": {
|
||||||
|
isRemoteScrubbing.value = true;
|
||||||
|
setShowRemoteBubble(true);
|
||||||
|
|
||||||
|
const direction = evt.eventType === "left" ? -1 : 1;
|
||||||
|
const base = remoteScrubProgress.value ?? progress.value;
|
||||||
|
const updated = Math.max(
|
||||||
|
min.value,
|
||||||
|
Math.min(max.value, base + direction * SCRUB_INTERVAL),
|
||||||
|
);
|
||||||
|
remoteScrubProgress.value = updated;
|
||||||
|
const progressInTicks = isVlc ? msToTicks(updated) : updated;
|
||||||
|
calculateTrickplayUrl(progressInTicks);
|
||||||
|
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
||||||
|
const hours = Math.floor(progressInSeconds / 3600);
|
||||||
|
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||||
|
const seconds = progressInSeconds % 60;
|
||||||
|
setTime({ hours, minutes, seconds });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "select": {
|
||||||
|
if (isRemoteScrubbing.value && remoteScrubProgress.value != null) {
|
||||||
|
progress.value = remoteScrubProgress.value;
|
||||||
|
|
||||||
|
const seekTarget = isVlc
|
||||||
|
? Math.max(0, remoteScrubProgress.value)
|
||||||
|
: Math.max(0, ticksToSeconds(remoteScrubProgress.value));
|
||||||
|
|
||||||
|
seek(seekTarget);
|
||||||
|
if (isPlaying) play();
|
||||||
|
|
||||||
|
isRemoteScrubbing.value = false;
|
||||||
|
remoteScrubProgress.value = null;
|
||||||
|
setShowRemoteBubble(false);
|
||||||
|
} else {
|
||||||
|
togglePlay();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "down":
|
||||||
|
case "up":
|
||||||
|
// cancel scrubbing on other directions
|
||||||
|
isRemoteScrubbing.value = false;
|
||||||
|
remoteScrubProgress.value = null;
|
||||||
|
setShowRemoteBubble(false);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showControls) toggleControls();
|
||||||
|
});
|
||||||
|
|
||||||
|
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isActive = true;
|
||||||
|
let seekTime = 10;
|
||||||
|
|
||||||
|
const scrubWithLongPress = () => {
|
||||||
|
if (!isActive || !longPressScrubMode) return;
|
||||||
|
|
||||||
|
setIsSliding(true);
|
||||||
|
const scrubFn =
|
||||||
|
longPressScrubMode === "FF" ? handleSeekForward : handleSeekBackward;
|
||||||
|
scrubFn(seekTime);
|
||||||
|
seekTime *= 1.1;
|
||||||
|
|
||||||
|
longPressTimeoutRef.current = setTimeout(scrubWithLongPress, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (longPressScrubMode) {
|
||||||
|
isActive = true;
|
||||||
|
scrubWithLongPress();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isActive = false;
|
||||||
|
setIsSliding(false);
|
||||||
|
if (longPressTimeoutRef.current) {
|
||||||
|
clearTimeout(longPressTimeoutRef.current);
|
||||||
|
longPressTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [longPressScrubMode]);
|
||||||
|
|
||||||
|
const effectiveProgress = useSharedValue(0);
|
||||||
|
|
||||||
|
// Recompute progress whenever remote scrubbing is active
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => ({
|
||||||
|
isScrubbing: isRemoteScrubbing.value,
|
||||||
|
scrub: remoteScrubProgress.value,
|
||||||
|
actual: progress.value,
|
||||||
|
}),
|
||||||
|
(current) => {
|
||||||
|
effectiveProgress.value =
|
||||||
|
current.isScrubbing && current.scrub != null
|
||||||
|
? current.scrub
|
||||||
|
: current.actual;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item) {
|
if (item) {
|
||||||
progress.value = isVlc
|
progress.value = isVlc
|
||||||
@@ -374,20 +503,19 @@ export const Controls: FC<Props> = ({
|
|||||||
|
|
||||||
pause();
|
pause();
|
||||||
isSeeking.value = true;
|
isSeeking.value = true;
|
||||||
}, [showControls, isPlaying]);
|
}, [showControls, isPlaying, pause]);
|
||||||
|
|
||||||
const handleSliderComplete = useCallback(
|
const handleSliderComplete = useCallback(
|
||||||
async (value: number) => {
|
async (value: number) => {
|
||||||
isSeeking.value = false;
|
isSeeking.value = false;
|
||||||
progress.value = value;
|
progress.value = value;
|
||||||
setIsSliding(false);
|
setIsSliding(false);
|
||||||
|
|
||||||
seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))));
|
seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))));
|
||||||
if (wasPlayingRef.current) {
|
if (wasPlayingRef.current) {
|
||||||
play();
|
play();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isVlc],
|
[isVlc, seek, play],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
||||||
@@ -424,7 +552,43 @@ export const Controls: FC<Props> = ({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Error seeking video backwards", error);
|
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||||
}
|
}
|
||||||
}, [settings, isPlaying, isVlc]);
|
}, [settings, isPlaying, isVlc, play, seek]);
|
||||||
|
|
||||||
|
const handleSeekBackward = useCallback(
|
||||||
|
async (seconds: number) => {
|
||||||
|
wasPlayingRef.current = isPlaying;
|
||||||
|
try {
|
||||||
|
const curr = progress.value;
|
||||||
|
if (curr !== undefined) {
|
||||||
|
const newTime = isVlc
|
||||||
|
? Math.max(0, curr - secondsToMs(seconds))
|
||||||
|
: Math.max(0, ticksToSeconds(curr) - seconds);
|
||||||
|
seek(newTime);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isPlaying, isVlc, seek],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSeekForward = useCallback(
|
||||||
|
async (seconds: number) => {
|
||||||
|
wasPlayingRef.current = isPlaying;
|
||||||
|
try {
|
||||||
|
const curr = progress.value;
|
||||||
|
if (curr !== undefined) {
|
||||||
|
const newTime = isVlc
|
||||||
|
? curr + secondsToMs(seconds)
|
||||||
|
: ticksToSeconds(curr) + seconds;
|
||||||
|
seek(Math.max(0, newTime));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isPlaying, isVlc, seek],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSkipForward = useCallback(async () => {
|
const handleSkipForward = useCallback(async () => {
|
||||||
if (!settings?.forwardSkipTime) {
|
if (!settings?.forwardSkipTime) {
|
||||||
@@ -446,7 +610,7 @@ export const Controls: FC<Props> = ({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||||
}
|
}
|
||||||
}, [settings, isPlaying, isVlc]);
|
}, [settings, isPlaying, isVlc, play, seek]);
|
||||||
|
|
||||||
const toggleIgnoreSafeAreas = useCallback(() => {
|
const toggleIgnoreSafeAreas = useCallback(() => {
|
||||||
setIgnoreSafeAreas((prev) => !prev);
|
setIgnoreSafeAreas((prev) => !prev);
|
||||||
@@ -667,80 +831,87 @@ export const Controls: FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<BrightnessSlider />
|
<BrightnessSlider />
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity onPress={handleSkipBackward}>
|
{!Platform.isTV && (
|
||||||
<View
|
<TouchableOpacity onPress={handleSkipBackward}>
|
||||||
style={{
|
<View
|
||||||
position: "relative",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
opacity: showControls ? 1 : 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name='refresh-outline'
|
|
||||||
size={50}
|
|
||||||
color='white'
|
|
||||||
style={{
|
|
||||||
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
color: "white",
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: "bold",
|
|
||||||
bottom: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{settings?.rewindSkipTime}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
togglePlay();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!isBuffering ? (
|
|
||||||
<Ionicons
|
|
||||||
name={isPlaying ? "pause" : "play"}
|
|
||||||
size={50}
|
|
||||||
color='white'
|
|
||||||
style={{
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
opacity: showControls ? 1 : 0,
|
opacity: showControls ? 1 : 0,
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
) : (
|
<Ionicons
|
||||||
<Loader size={"large"} />
|
name='refresh-outline'
|
||||||
)}
|
size={50}
|
||||||
</TouchableOpacity>
|
color='white'
|
||||||
|
style={{
|
||||||
|
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
color: "white",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "bold",
|
||||||
|
bottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{settings?.rewindSkipTime}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
<TouchableOpacity onPress={handleSkipForward}>
|
<View
|
||||||
<View
|
style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}
|
||||||
style={{
|
>
|
||||||
position: "relative",
|
<TouchableOpacity
|
||||||
justifyContent: "center",
|
onPress={() => {
|
||||||
alignItems: "center",
|
togglePlay();
|
||||||
opacity: showControls ? 1 : 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name='refresh-outline' size={50} color='white' />
|
{!isBuffering ? (
|
||||||
<Text
|
<Ionicons
|
||||||
|
name={isPlaying ? "pause" : "play"}
|
||||||
|
size={50}
|
||||||
|
color='white'
|
||||||
|
style={{
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Loader size={"large"} />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!Platform.isTV && (
|
||||||
|
<TouchableOpacity onPress={handleSkipForward}>
|
||||||
|
<View
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "relative",
|
||||||
color: "white",
|
justifyContent: "center",
|
||||||
fontSize: 16,
|
alignItems: "center",
|
||||||
fontWeight: "bold",
|
opacity: showControls ? 1 : 0,
|
||||||
bottom: 10,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{settings?.forwardSkipTime}
|
<Ionicons name='refresh-outline' size={50} color='white' />
|
||||||
</Text>
|
<Text
|
||||||
</View>
|
style={{
|
||||||
</TouchableOpacity>
|
position: "absolute",
|
||||||
|
color: "white",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "bold",
|
||||||
|
bottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{settings?.forwardSkipTime}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -850,10 +1021,12 @@ export const Controls: FC<Props> = ({
|
|||||||
containerStyle={{
|
containerStyle={{
|
||||||
borderRadius: 100,
|
borderRadius: 100,
|
||||||
}}
|
}}
|
||||||
renderBubble={() => isSliding && memoizedRenderBubble()}
|
renderBubble={() =>
|
||||||
|
(isSliding || showRemoteBubble) && memoizedRenderBubble()
|
||||||
|
}
|
||||||
sliderHeight={10}
|
sliderHeight={10}
|
||||||
thumbWidth={0}
|
thumbWidth={0}
|
||||||
progress={progress}
|
progress={effectiveProgress}
|
||||||
minimumValue={min}
|
minimumValue={min}
|
||||||
maximumValue={max}
|
maximumValue={max}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
[seasons, seasonIndex],
|
[seasons, seasonIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: episodes, isFetching } = useQuery({
|
const { data: episodes } = useQuery({
|
||||||
queryKey: ["episodes", item.SeriesId, selectedSeasonId],
|
queryKey: ["episodes", item.SeriesId, selectedSeasonId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
|
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
|
||||||
|
|||||||
6
eas.json
@@ -46,14 +46,14 @@
|
|||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.29.6",
|
"channel": "0.29.13",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.29.6",
|
"channel": "0.29.13",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
},
|
},
|
||||||
"production-apk-tv": {
|
"production-apk-tv": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.29.6",
|
"channel": "0.29.13",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -16,34 +16,46 @@ export type HapticFeedbackType =
|
|||||||
export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
|
export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
|
const isDisabled =
|
||||||
|
isTv ||
|
||||||
|
!Haptics ||
|
||||||
|
settings?.disableHapticFeedback ||
|
||||||
|
Platform.OS === "web";
|
||||||
|
|
||||||
const createHapticHandler = useCallback(
|
const createHapticHandler = useCallback(
|
||||||
(type: typeof Haptics.ImpactFeedbackStyle) => {
|
(type: typeof Haptics.ImpactFeedbackStyle) => {
|
||||||
return Platform.OS === "web" || Platform.isTV
|
if (!Haptics || !type) return () => {};
|
||||||
? () => {}
|
return () => Haptics.impactAsync(type);
|
||||||
: () => Haptics.impactAsync(type);
|
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const createNotificationFeedback = useCallback(
|
const createNotificationFeedback = useCallback(
|
||||||
(type: typeof Haptics.NotificationFeedbackType) => {
|
(type: typeof Haptics.NotificationFeedbackType) => {
|
||||||
return Platform.OS === "web" || Platform.isTV
|
if (!Haptics || !type) return () => {};
|
||||||
? () => {}
|
return () => Haptics.notificationAsync(type);
|
||||||
: () => Haptics.notificationAsync(type);
|
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const hapticHandlers = useMemo(
|
const hapticHandlers = useMemo(() => {
|
||||||
() => ({
|
if (!Haptics) {
|
||||||
|
return {
|
||||||
|
light: () => {},
|
||||||
|
medium: () => {},
|
||||||
|
heavy: () => {},
|
||||||
|
selection: () => {},
|
||||||
|
success: () => {},
|
||||||
|
warning: () => {},
|
||||||
|
error: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
light: createHapticHandler(Haptics.ImpactFeedbackStyle.Light),
|
light: createHapticHandler(Haptics.ImpactFeedbackStyle.Light),
|
||||||
medium: createHapticHandler(Haptics.ImpactFeedbackStyle.Medium),
|
medium: createHapticHandler(Haptics.ImpactFeedbackStyle.Medium),
|
||||||
heavy: createHapticHandler(Haptics.ImpactFeedbackStyle.Heavy),
|
heavy: createHapticHandler(Haptics.ImpactFeedbackStyle.Heavy),
|
||||||
selection:
|
selection: Haptics.selectionAsync,
|
||||||
Platform.OS === "web" || Platform.isTV
|
|
||||||
? () => {}
|
|
||||||
: Haptics.selectionAsync,
|
|
||||||
success: createNotificationFeedback(
|
success: createNotificationFeedback(
|
||||||
Haptics.NotificationFeedbackType.Success,
|
Haptics.NotificationFeedbackType.Success,
|
||||||
),
|
),
|
||||||
@@ -51,16 +63,11 @@ export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
|
|||||||
Haptics.NotificationFeedbackType.Warning,
|
Haptics.NotificationFeedbackType.Warning,
|
||||||
),
|
),
|
||||||
error: createNotificationFeedback(Haptics.NotificationFeedbackType.Error),
|
error: createNotificationFeedback(Haptics.NotificationFeedbackType.Error),
|
||||||
}),
|
};
|
||||||
[createHapticHandler, createNotificationFeedback],
|
}, [createHapticHandler, createNotificationFeedback]);
|
||||||
);
|
|
||||||
|
|
||||||
if (isTv) {
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings?.disableHapticFeedback) {
|
if (settings?.disableHapticFeedback) {
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
return hapticHandlers[feedbackType];
|
return isDisabled ? () => {} : hapticHandlers[feedbackType];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { getColors, ImageColorsResult } from "react-native-image-colors";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
adjustToNearBlack,
|
adjustToNearBlack,
|
||||||
@@ -12,9 +13,6 @@ import {
|
|||||||
import { getItemImage } from "@/utils/getItemImage";
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
// import { getColors } from "react-native-image-colors";
|
|
||||||
const Colors = !Platform.isTV ? require("react-native-image-colors") : null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook to extract and manage image colors for a given item.
|
* Custom hook to extract and manage image colors for a given item.
|
||||||
*
|
*
|
||||||
@@ -65,48 +63,45 @@ export const useImageColors = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Colors.getColors(source.uri, {
|
// Extract colors from the image
|
||||||
|
getColors(source.uri, {
|
||||||
fallback: "#fff",
|
fallback: "#fff",
|
||||||
cache: false,
|
cache: false,
|
||||||
})
|
})
|
||||||
.then(
|
.then((colors: ImageColorsResult) => {
|
||||||
(colors: {
|
let primary = "#fff";
|
||||||
platform: string;
|
let text = "#000";
|
||||||
dominant: string;
|
let backup = "#fff";
|
||||||
vibrant: string;
|
|
||||||
detail: string;
|
|
||||||
primary: string;
|
|
||||||
}) => {
|
|
||||||
let primary = "#fff";
|
|
||||||
let text = "#000";
|
|
||||||
let backup = "#fff";
|
|
||||||
|
|
||||||
if (colors.platform === "android") {
|
// Select the appropriate color based on the platform
|
||||||
primary = colors.dominant;
|
if (colors.platform === "android") {
|
||||||
backup = colors.vibrant;
|
primary = colors.dominant;
|
||||||
} else if (colors.platform === "ios") {
|
backup = colors.vibrant;
|
||||||
primary = colors.detail;
|
} else if (colors.platform === "ios") {
|
||||||
backup = colors.primary;
|
primary = colors.detail;
|
||||||
}
|
backup = colors.primary;
|
||||||
|
}
|
||||||
|
|
||||||
if (primary && isCloseToBlack(primary)) {
|
// Adjust the primary color if it's too close to black
|
||||||
if (backup && !isCloseToBlack(backup)) primary = backup;
|
if (primary && isCloseToBlack(primary)) {
|
||||||
primary = adjustToNearBlack(primary);
|
if (backup && !isCloseToBlack(backup)) primary = backup;
|
||||||
}
|
primary = adjustToNearBlack(primary);
|
||||||
|
}
|
||||||
|
|
||||||
if (primary) text = calculateTextColor(primary);
|
// Calculate the text color based on the primary color
|
||||||
|
if (primary) text = calculateTextColor(primary);
|
||||||
|
|
||||||
setPrimaryColor({
|
setPrimaryColor({
|
||||||
primary,
|
primary,
|
||||||
text,
|
text,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (source.uri && primary) {
|
// Cache the colors in storage
|
||||||
storage.set(`${source.uri}-primary`, primary);
|
if (source.uri && primary) {
|
||||||
storage.set(`${source.uri}-text`, text);
|
storage.set(`${source.uri}-primary`, primary);
|
||||||
}
|
storage.set(`${source.uri}-text`, text);
|
||||||
},
|
}
|
||||||
)
|
})
|
||||||
.catch((error: any) => {
|
.catch((error: any) => {
|
||||||
console.error("Error getting colors", error);
|
console.error("Error getting colors", error);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const useJellyfinDiscovery = () => {
|
|||||||
setServers([]);
|
setServers([]);
|
||||||
|
|
||||||
const discoveredServers = new Set<string>();
|
const discoveredServers = new Set<string>();
|
||||||
let discoveryTimeout: NodeJS.Timeout;
|
let discoveryTimeout: number;
|
||||||
|
|
||||||
const socket = dgram.createSocket({
|
const socket = dgram.createSocket({
|
||||||
type: "udp4",
|
type: "udp4",
|
||||||
|
|||||||
@@ -14,12 +14,6 @@ interface TrickplayData {
|
|||||||
ThumbnailCount?: number;
|
ThumbnailCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrickplayInfo {
|
|
||||||
resolution: string;
|
|
||||||
aspectRatio: number;
|
|
||||||
data: TrickplayData;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TrickplayUrl {
|
interface TrickplayUrl {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -38,7 +32,8 @@ export const useTrickplay = (item: BaseItemDto, enabled = true) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mediaSourceId = item.Id;
|
const mediaSourceId = item.Id;
|
||||||
const trickplayData = item.Trickplay[mediaSourceId];
|
const trickplayData: Record<string, TrickplayData> | undefined =
|
||||||
|
item.Trickplay[mediaSourceId];
|
||||||
|
|
||||||
if (!trickplayData) {
|
if (!trickplayData) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
6
i18n.ts
@@ -1,6 +1,7 @@
|
|||||||
import { getLocales } from "expo-localization";
|
import { getLocales } from "expo-localization";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { initReactI18next } from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
|
import ca from "./translations/ca.json";
|
||||||
import da from "./translations/da.json";
|
import da from "./translations/da.json";
|
||||||
import de from "./translations/de.json";
|
import de from "./translations/de.json";
|
||||||
import en from "./translations/en.json";
|
import en from "./translations/en.json";
|
||||||
@@ -22,10 +23,12 @@ import sv from "./translations/sv.json";
|
|||||||
import tlh from "./translations/tlh.json";
|
import tlh from "./translations/tlh.json";
|
||||||
import tr from "./translations/tr.json";
|
import tr from "./translations/tr.json";
|
||||||
import uk from "./translations/uk.json";
|
import uk from "./translations/uk.json";
|
||||||
|
import vi from "./translations/vi.json";
|
||||||
import zhCN from "./translations/zh-CN.json";
|
import zhCN from "./translations/zh-CN.json";
|
||||||
import zhTW from "./translations/zh-TW.json";
|
import zhTW from "./translations/zh-TW.json";
|
||||||
|
|
||||||
export const APP_LANGUAGES = [
|
export const APP_LANGUAGES = [
|
||||||
|
{ label: "Catalan", value: "ca" },
|
||||||
{ label: "Dansk", value: "da" },
|
{ label: "Dansk", value: "da" },
|
||||||
{ label: "Deutsch", value: "de" },
|
{ label: "Deutsch", value: "de" },
|
||||||
{ label: "English", value: "en" },
|
{ label: "English", value: "en" },
|
||||||
@@ -49,11 +52,13 @@ export const APP_LANGUAGES = [
|
|||||||
{ label: "Українська", value: "uk" },
|
{ label: "Українська", value: "uk" },
|
||||||
{ label: "简体中文", value: "zh-CN" },
|
{ label: "简体中文", value: "zh-CN" },
|
||||||
{ label: "繁體中文", value: "zh-TW" },
|
{ label: "繁體中文", value: "zh-TW" },
|
||||||
|
{ label: "Tiếng Việt", value: "vi" },
|
||||||
];
|
];
|
||||||
|
|
||||||
i18n.use(initReactI18next).init({
|
i18n.use(initReactI18next).init({
|
||||||
compatibilityJSON: "v4",
|
compatibilityJSON: "v4",
|
||||||
resources: {
|
resources: {
|
||||||
|
ca: { translation: ca },
|
||||||
da: { translation: da },
|
da: { translation: da },
|
||||||
de: { translation: de },
|
de: { translation: de },
|
||||||
en: { translation: en },
|
en: { translation: en },
|
||||||
@@ -75,6 +80,7 @@ i18n.use(initReactI18next).init({
|
|||||||
tr: { translation: tr },
|
tr: { translation: tr },
|
||||||
tlh: { translation: tlh },
|
tlh: { translation: tlh },
|
||||||
uk: { translation: uk },
|
uk: { translation: uk },
|
||||||
|
vi: { translation: vi },
|
||||||
"zh-CN": { translation: zhCN },
|
"zh-CN": { translation: zhCN },
|
||||||
"zh-TW": { translation: zhTW },
|
"zh-TW": { translation: zhTW },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
|
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||||
const { getDefaultConfig } = require("expo/metro-config");
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
|
|
||||||
const config = getDefaultConfig(__dirname);
|
/** @type {import('expo/metro-config').MetroConfig} */
|
||||||
|
const config = getDefaultConfig(__dirname); // eslint-disable-line no-undef
|
||||||
|
|
||||||
|
// Add Hermes parser
|
||||||
|
config.transformer.hermesParser = true;
|
||||||
|
|
||||||
|
// When enabled, the optional code below will allow Metro to resolve
|
||||||
|
// and bundle source files with TV-specific extensions
|
||||||
|
// (e.g., *.ios.tv.tsx, *.android.tv.tsx, *.tv.tsx)
|
||||||
|
//
|
||||||
|
// Metro will still resolve source files with standard extensions
|
||||||
|
// as usual if TV-specific files are not found for a module.
|
||||||
|
//
|
||||||
if (process.env?.EXPO_TV === "1") {
|
if (process.env?.EXPO_TV === "1") {
|
||||||
const originalSourceExts = config.resolver.sourceExts;
|
const originalSourceExts = config.resolver.sourceExts;
|
||||||
const tvSourceExts = [
|
const tvSourceExts = [
|
||||||
@@ -11,4 +23,6 @@ if (process.env?.EXPO_TV === "1") {
|
|||||||
config.resolver.sourceExts = tvSourceExts;
|
config.resolver.sourceExts = tvSourceExts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// config.resolver.unstable_enablePackageExports = false;
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = config;
|
||||||
|
|||||||
143
package.json
@@ -20,123 +20,122 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bottom-tabs/react-navigation": "^0.9.2",
|
"@bottom-tabs/react-navigation": "^0.9.2",
|
||||||
"@expo/config-plugins": "~9.0.15",
|
"@expo/config-plugins": "~10.1.1",
|
||||||
|
"@expo/metro-runtime": "~5.0.4",
|
||||||
"@expo/react-native-action-sheet": "^4.1.1",
|
"@expo/react-native-action-sheet": "^4.1.1",
|
||||||
"@expo/vector-icons": "^14.0.4",
|
"@expo/vector-icons": "^14.1.0",
|
||||||
"@futurejj/react-native-visibility-sensor": "^1.3.10",
|
|
||||||
"@gorhom/bottom-sheet": "^5.1.0",
|
"@gorhom/bottom-sheet": "^5.1.0",
|
||||||
"@jellyfin/sdk": "^0.11.0",
|
"@jellyfin/sdk": "^0.11.0",
|
||||||
"@kesha-antonov/react-native-background-downloader": "3.2.6",
|
"@kesha-antonov/react-native-background-downloader": "^3.2.6",
|
||||||
"@react-native-community/netinfo": "11.4.1",
|
"@react-native-community/netinfo": "^11.4.1",
|
||||||
"@react-native-menu/menu": "^1.2.3",
|
"@react-native-menu/menu": "^1.2.3",
|
||||||
"@react-navigation/material-top-tabs": "^7.1.0",
|
"@react-navigation/material-top-tabs": "^7.2.14",
|
||||||
"@react-navigation/native": "^7.0.14",
|
"@react-navigation/native": "^7.0.14",
|
||||||
"@shopify/flash-list": "1.7.3",
|
"@shopify/flash-list": "^1.8.3",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
"add": "^2.0.6",
|
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"expo": "~52.0.31",
|
"expo": "^53.0.6",
|
||||||
"expo-asset": "~11.0.3",
|
"expo-application": "~6.1.4",
|
||||||
"expo-background-fetch": "~13.0.5",
|
"expo-asset": "~11.1.7",
|
||||||
"expo-blur": "~14.0.3",
|
"expo-background-fetch": "~13.1.5",
|
||||||
"expo-brightness": "~13.0.3",
|
"expo-blur": "~14.1.4",
|
||||||
"expo-build-properties": "~0.13.2",
|
"expo-brightness": "~13.1.4",
|
||||||
"expo-constants": "~17.0.5",
|
"expo-build-properties": "~0.14.6",
|
||||||
"expo-crypto": "~14.0.2",
|
"expo-constants": "~17.1.5",
|
||||||
"expo-dev-client": "~5.0.11",
|
"expo-dev-client": "^5.2.0",
|
||||||
"expo-device": "~7.0.2",
|
"expo-device": "~7.1.4",
|
||||||
"expo-font": "~13.0.3",
|
"expo-doctor": "^1.13.5",
|
||||||
"expo-haptics": "~14.0.1",
|
"expo-font": "~13.3.1",
|
||||||
"expo-image": "~2.0.4",
|
"expo-haptics": "~14.1.4",
|
||||||
"expo-keep-awake": "~14.0.2",
|
"expo-image": "~2.4.0",
|
||||||
"expo-linear-gradient": "~14.0.2",
|
"expo-linear-gradient": "~14.1.4",
|
||||||
"expo-linking": "~7.0.5",
|
"expo-linking": "~7.1.4",
|
||||||
"expo-localization": "~16.0.1",
|
"expo-localization": "~16.1.5",
|
||||||
"expo-network": "~7.0.5",
|
"expo-notifications": "~0.31.2",
|
||||||
"expo-notifications": "~0.29.13",
|
"expo-router": "~5.1.4",
|
||||||
"expo-router": "~4.0.17",
|
"expo-screen-orientation": "~8.1.6",
|
||||||
"expo-screen-orientation": "~8.0.4",
|
"expo-sensors": "~14.1.4",
|
||||||
"expo-sensors": "~14.0.2",
|
"expo-sharing": "~13.1.5",
|
||||||
"expo-sharing": "~13.0.1",
|
"expo-splash-screen": "~0.30.8",
|
||||||
"expo-splash-screen": "~0.29.22",
|
"expo-status-bar": "~2.2.3",
|
||||||
"expo-status-bar": "~2.0.1",
|
"expo-system-ui": "~5.0.7",
|
||||||
"expo-system-ui": "~4.0.8",
|
"expo-task-manager": "~13.1.5",
|
||||||
"expo-task-manager": "~12.0.5",
|
"expo-web-browser": "~14.2.0",
|
||||||
"expo-updates": "~0.27.4",
|
|
||||||
"expo-web-browser": "~14.0.2",
|
|
||||||
"i18next": "^25.0.0",
|
"i18next": "^25.0.0",
|
||||||
"install": "^0.13.0",
|
"jotai": "^2.12.5",
|
||||||
"jotai": "^2.11.3",
|
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
"react": "18.3.1",
|
"react": "19.0.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "19.0.0",
|
||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "^15.4.0",
|
||||||
"react-native": "npm:react-native-tvos@~0.77.2-0",
|
"react-native": "npm:react-native-tvos@0.79.5-0",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "^0.9.2",
|
"react-native-bottom-tabs": "^0.9.2",
|
||||||
"react-native-circular-progress": "^1.4.1",
|
"react-native-circular-progress": "^1.4.1",
|
||||||
"react-native-collapsible": "^1.6.2",
|
"react-native-collapsible": "^1.6.2",
|
||||||
"react-native-compressor": "^1.10.3",
|
|
||||||
"react-native-country-flag": "^2.0.2",
|
"react-native-country-flag": "^2.0.2",
|
||||||
"react-native-device-info": "^14.0.4",
|
"react-native-device-info": "^14.0.4",
|
||||||
"react-native-edge-to-edge": "^1.4.3",
|
"react-native-gesture-handler": "~2.24.0",
|
||||||
"react-native-gesture-handler": "2.22.0",
|
"react-native-google-cast": "^4.9.0",
|
||||||
"react-native-get-random-values": "^1.11.0",
|
|
||||||
"react-native-google-cast": "^4.8.3",
|
|
||||||
"react-native-image-colors": "^2.4.0",
|
"react-native-image-colors": "^2.4.0",
|
||||||
"react-native-ios-context-menu": "^3.1.0",
|
"react-native-ios-context-menu": "^3.1.0",
|
||||||
"react-native-ios-utilities": "5.1.8",
|
"react-native-ios-utilities": "5.1.8",
|
||||||
"react-native-mmkv": "^2.12.2",
|
"react-native-mmkv": "2.12.2",
|
||||||
"react-native-pager-view": "6.5.1",
|
|
||||||
"react-native-progress": "^5.0.1",
|
|
||||||
"react-native-reanimated": "~3.16.7",
|
"react-native-reanimated": "~3.16.7",
|
||||||
"react-native-reanimated-carousel": "^4",
|
"react-native-reanimated-carousel": "4.0.2",
|
||||||
"react-native-safe-area-context": "5.5.0",
|
"react-native-safe-area-context": "5.4.0",
|
||||||
"react-native-screens": "~4.5.0",
|
"react-native-screens": "~4.11.1",
|
||||||
"react-native-svg": "15.11.1",
|
"react-native-svg": "15.11.2",
|
||||||
"react-native-tab-view": "^4.0.5",
|
|
||||||
"react-native-udp": "^4.1.7",
|
"react-native-udp": "^4.1.7",
|
||||||
"react-native-uitextview": "^1.4.0",
|
|
||||||
"react-native-url-polyfill": "^2.0.0",
|
"react-native-url-polyfill": "^2.0.0",
|
||||||
"react-native-uuid": "^2.0.3",
|
"react-native-uuid": "^2.0.3",
|
||||||
"react-native-video": "6.16.1",
|
"react-native-video": "6.14.1",
|
||||||
"react-native-volume-manager": "^2.0.8",
|
"react-native-volume-manager": "^2.0.8",
|
||||||
"react-native-web": "~0.19.13",
|
"react-native-web": "^0.20.0",
|
||||||
"react-native-webview": "13.13.2",
|
|
||||||
"sonner-native": "^0.21.0",
|
"sonner-native": "^0.21.0",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4",
|
||||||
"uuid": "^11.0.5",
|
"zeego": "^3.0.6",
|
||||||
"zeego": "^3",
|
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.26.8",
|
"@babel/core": "^7.20.0",
|
||||||
"@biomejs/biome": "^2.1.2",
|
"@biomejs/biome": "^2.1.4",
|
||||||
"@react-native-community/cli": "^19",
|
"@react-native-community/cli": "^19",
|
||||||
"@react-native-tvos/config-tv": "^0.1.1",
|
"@react-native-tvos/config-tv": "^0.1.1",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^29.5.12",
|
||||||
"@types/lodash": "^4.17.15",
|
"@types/lodash": "^4.17.15",
|
||||||
"@types/react": "~18.3.12",
|
"@types/react": "~19.0.10",
|
||||||
"@types/react-native-vector-icons": "^6.4.18",
|
|
||||||
"@types/react-test-renderer": "^19.0.0",
|
"@types/react-test-renderer": "^19.0.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"cross-env": "^10.0.0",
|
||||||
"cross-env": "^10",
|
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.1.2",
|
"lint-staged": "^16.1.5",
|
||||||
"postinstall-postinstall": "^2.1.0",
|
"postinstall-postinstall": "^2.1.0",
|
||||||
"react-test-renderer": "19.1.1",
|
"react-test-renderer": "19.1.1",
|
||||||
"typescript": "~5.8.0"
|
"typescript": "~5.8.3"
|
||||||
},
|
},
|
||||||
"private": true,
|
|
||||||
"expo": {
|
"expo": {
|
||||||
"install": {
|
"install": {
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"react-native"
|
"react-native",
|
||||||
|
"@shopify/flash-list",
|
||||||
|
"react-native-reanimated"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"doctor": {
|
||||||
|
"reactNativeDirectoryCheck": {
|
||||||
|
"exclude": [
|
||||||
|
"react-native-google-cast",
|
||||||
|
"react-native-udp",
|
||||||
|
"@bottom-tabs/react-navigation",
|
||||||
|
"@jellyfin/sdk",
|
||||||
|
"expo-doctor"
|
||||||
|
],
|
||||||
|
"listUnknownPackages": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"private": true,
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,jsx,ts,tsx}": [
|
"*.{js,jsx,ts,tsx}": [
|
||||||
"biome check --write --unsafe --no-errors-on-unmatched"
|
"biome check --write --unsafe --no-errors-on-unmatched"
|
||||||
|
|||||||
66
plugins/with-runtime-framework-headers.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
const { withPodfile } = require("expo/config-plugins");
|
||||||
|
|
||||||
|
const PATCH_START = "## >>> runtime-framework headers";
|
||||||
|
const PATCH_END = "## <<< runtime-framework headers";
|
||||||
|
|
||||||
|
const EXTRA_HDRS = [
|
||||||
|
`\${PODS_CONFIGURATION_BUILD_DIR}/React-RuntimeApple/React_RuntimeApple.framework/Headers`,
|
||||||
|
`\${PODS_CONFIGURATION_BUILD_DIR}/React-RuntimeCore/React_RuntimeCore.framework/Headers`,
|
||||||
|
`\${PODS_CONFIGURATION_BUILD_DIR}/React-jserrorhandler/React_jserrorhandler.framework/Headers`,
|
||||||
|
`\${PODS_CONFIGURATION_BUILD_DIR}/React-jsinspector/jsinspector_modern.framework/Headers`,
|
||||||
|
`\${PODS_CONFIGURATION_BUILD_DIR}/React-runtimescheduler/React_runtimescheduler.framework/Headers`,
|
||||||
|
`\${PODS_CONFIGURATION_BUILD_DIR}/React-performancetimeline/React_performancetimeline.framework/Headers`,
|
||||||
|
`\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`,
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildPatch() {
|
||||||
|
return [
|
||||||
|
PATCH_START,
|
||||||
|
" extra_hdrs = [",
|
||||||
|
...EXTRA_HDRS.map((h) => ` "${h}",`),
|
||||||
|
" ]",
|
||||||
|
"",
|
||||||
|
" installer.pods_project.targets.each do |t|",
|
||||||
|
" t.build_configurations.each do |cfg|",
|
||||||
|
" cfg.build_settings['HEADER_SEARCH_PATHS'] ||= '$(inherited)'",
|
||||||
|
" cfg.build_settings['HEADER_SEARCH_PATHS'] << \" #{extra_hdrs.join(' ')}\"",
|
||||||
|
" end",
|
||||||
|
" end",
|
||||||
|
PATCH_END,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function withRuntimeFrameworkHeaders(config) {
|
||||||
|
return withPodfile(config, (config) => {
|
||||||
|
let podfile = config.modResults.contents;
|
||||||
|
|
||||||
|
// 1️⃣ ensure there's a post_install block
|
||||||
|
if (!/^\s*post_install\s+do\s+\|installer\|/m.test(podfile)) {
|
||||||
|
podfile += `
|
||||||
|
|
||||||
|
post_install do |installer|
|
||||||
|
end
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = buildPatch();
|
||||||
|
|
||||||
|
if (podfile.includes(PATCH_START)) {
|
||||||
|
// 🔄 update existing patch
|
||||||
|
podfile = podfile.replace(
|
||||||
|
new RegExp(`${PATCH_START}[\\s\\S]*?${PATCH_END}`),
|
||||||
|
patch,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// ➕ insert right after the post_install opening line
|
||||||
|
podfile = podfile.replace(
|
||||||
|
/^\s*post_install\s+do\s+\|installer\|.*$/m,
|
||||||
|
(match) => `${match}\n\n${patch}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ with-runtime-framework-headers: Podfile updated");
|
||||||
|
config.modResults.contents = podfile;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,48 +1,66 @@
|
|||||||
const { withAppDelegate } = require("@expo/config-plugins");
|
const { withAppDelegate, withXcodeProject } = require("@expo/config-plugins");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
function withRNBackgroundDownloader(expoConfig) {
|
/** @param {import("@expo/config-plugins").ExpoConfig} config */
|
||||||
return withAppDelegate(expoConfig, async (appDelegateConfig) => {
|
function withRNBackgroundDownloader(config) {
|
||||||
const { modResults: appDelegate } = appDelegateConfig;
|
/* 1️⃣ Add handleEventsForBackgroundURLSession to AppDelegate.swift */
|
||||||
const appDelegateLines = appDelegate.contents.split("\n");
|
config = withAppDelegate(config, (mod) => {
|
||||||
|
const tag = "handleEventsForBackgroundURLSession";
|
||||||
// Define the code to be added to AppDelegate.mm
|
if (!mod.modResults.contents.includes(tag)) {
|
||||||
const backgroundDownloaderImport =
|
mod.modResults.contents = mod.modResults.contents.replace(
|
||||||
"#import <RNBackgroundDownloader.h> // Required by react-native-background-downloader. Generated by expoPlugins/withRNBackgroundDownloader.js";
|
/\}\s*$/, // insert before final }
|
||||||
const backgroundDownloaderDelegate = `\n// Delegate method required by react-native-background-downloader. Generated by expoPlugins/withRNBackgroundDownloader.js
|
`
|
||||||
- (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)(void))completionHandler
|
func application(
|
||||||
{
|
_ application: UIApplication,
|
||||||
[RNBackgroundDownloader setCompletionHandlerWithIdentifier:identifier completionHandler:completionHandler];
|
handleEventsForBackgroundURLSession identifier: String,
|
||||||
}`;
|
completionHandler: @escaping () -> Void
|
||||||
|
) {
|
||||||
// Find the index of the AppDelegate import statement
|
RNBackgroundDownloader.setCompletionHandlerWithIdentifier(identifier, completionHandler: completionHandler)
|
||||||
const importIndex = appDelegateLines.findIndex((line) =>
|
}
|
||||||
/^#import "AppDelegate.h"/.test(line),
|
}`,
|
||||||
);
|
|
||||||
|
|
||||||
// Find the index of the last line before the @end statement
|
|
||||||
const endStatementIndex = appDelegateLines.findIndex((line) =>
|
|
||||||
/@end/.test(line),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Insert the import statement if it's not already present
|
|
||||||
if (!appDelegate.contents.includes(backgroundDownloaderImport)) {
|
|
||||||
appDelegateLines.splice(importIndex + 1, 0, backgroundDownloaderImport);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert the delegate method above the @end statement
|
|
||||||
if (!appDelegate.contents.includes(backgroundDownloaderDelegate)) {
|
|
||||||
appDelegateLines.splice(
|
|
||||||
endStatementIndex,
|
|
||||||
0,
|
|
||||||
backgroundDownloaderDelegate,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return mod;
|
||||||
// Update the contents of the AppDelegate file
|
|
||||||
appDelegate.contents = appDelegateLines.join("\n");
|
|
||||||
|
|
||||||
return appDelegateConfig;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* 2️⃣ Ensure bridging header exists & is attached to *every* app target */
|
||||||
|
config = withXcodeProject(config, (mod) => {
|
||||||
|
const project = mod.modResults;
|
||||||
|
const projectName = config.name || "App";
|
||||||
|
// Fix: Go up one more directory to get to ios/, not ios/ProjectName.xcodeproj/
|
||||||
|
const iosDir = path.dirname(path.dirname(project.filepath));
|
||||||
|
const headerRel = `${projectName}/${projectName}-Bridging-Header.h`;
|
||||||
|
const headerAbs = path.join(iosDir, headerRel);
|
||||||
|
|
||||||
|
// create / append import if missing
|
||||||
|
let headerText = "";
|
||||||
|
try {
|
||||||
|
headerText = fs.readFileSync(headerAbs, "utf8");
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== "ENOENT") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!headerText.includes("RNBackgroundDownloader.h")) {
|
||||||
|
fs.mkdirSync(path.dirname(headerAbs), { recursive: true });
|
||||||
|
fs.appendFileSync(headerAbs, '#import "RNBackgroundDownloader.h"\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expo 53's xcode‑js doesn't expose pbxTargets().
|
||||||
|
// Setting the property once at the project level is sufficient.
|
||||||
|
["Debug", "Release"].forEach((cfg) =>
|
||||||
|
project.updateBuildProperty(
|
||||||
|
"SWIFT_OBJC_BRIDGING_HEADER",
|
||||||
|
"Streamyfin/Streamyfin-Bridging-Header.h",
|
||||||
|
cfg,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return mod;
|
||||||
|
});
|
||||||
|
|
||||||
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = withRNBackgroundDownloader;
|
module.exports = withRNBackgroundDownloader;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import BackGroundDownloader from "@kesha-antonov/react-native-background-downloader";
|
|
||||||
import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import * as Application from "expo-application";
|
import * as Application from "expo-application";
|
||||||
@@ -42,6 +41,10 @@ import {
|
|||||||
import { Bitrate } from "../components/BitrateSelector";
|
import { Bitrate } from "../components/BitrateSelector";
|
||||||
import { apiAtom } from "./JellyfinProvider";
|
import { apiAtom } from "./JellyfinProvider";
|
||||||
|
|
||||||
|
const BackGroundDownloader = !Platform.isTV
|
||||||
|
? require("@kesha-antonov/react-native-background-downloader")
|
||||||
|
: null;
|
||||||
|
|
||||||
export type DownloadedItem = {
|
export type DownloadedItem = {
|
||||||
item: Partial<BaseItemDto>;
|
item: Partial<BaseItemDto>;
|
||||||
mediaSource: MediaSourceInfo;
|
mediaSource: MediaSourceInfo;
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.29.6" },
|
clientInfo: { name: "Streamyfin", version: "0.29.13" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -93,7 +93,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="0.29.6"`,
|
}, DeviceId="${deviceId}", Version="0.29.13"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
20
react-native.config.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// react-native.config.js
|
||||||
|
//https://docs.expo.dev/modules/autolinking/
|
||||||
|
|
||||||
|
const isTV = process.env?.EXPO_TV === "1";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
dependencies: {
|
||||||
|
"react-native-volume-manager": !isTV
|
||||||
|
? {
|
||||||
|
platforms: {
|
||||||
|
// leaving this blank seems to enable auto-linking which is what we want for mobile
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
platforms: {
|
||||||
|
android: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
484
translations/ca.json
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
{
|
||||||
|
"login": {
|
||||||
|
"username_required": "Nom d'usuari requerit",
|
||||||
|
"error_title": "Error",
|
||||||
|
"login_title": "Inicia sessió",
|
||||||
|
"login_to_title": "Inicia sessió a",
|
||||||
|
"username_placeholder": "Nom d'usuari",
|
||||||
|
"password_placeholder": "Contrasenya",
|
||||||
|
"login_button": "Inicia sessió",
|
||||||
|
"quick_connect": "Connexió ràpida",
|
||||||
|
"enter_code_to_login": "Introdueix el codi {{code}} per iniciar sessió",
|
||||||
|
"failed_to_initiate_quick_connect": "No s'ha pogut iniciar la connexió ràpida",
|
||||||
|
"got_it": "Entesos",
|
||||||
|
"connection_failed": "Ha fallat la connexió",
|
||||||
|
"could_not_connect_to_server": "No s'ha pogut connectar amb el servidor. Comproveu l'URL i la connexió de xarxa.",
|
||||||
|
"an_unexpected_error_occured": "S'ha produït un error inesperat",
|
||||||
|
"change_server": "Canvia el servidor",
|
||||||
|
"invalid_username_or_password": "Nom d'usuari o contrasenya incorrectes",
|
||||||
|
"user_does_not_have_permission_to_log_in": "L'usuari no té permís per iniciar sessió",
|
||||||
|
"server_is_taking_too_long_to_respond_try_again_later": "El servidor triga massa a respondre, torneu-ho a provar més tard",
|
||||||
|
"server_received_too_many_requests_try_again_later": "El servidor ha rebut massa sol·licituds, torneu-ho a provar més tard.",
|
||||||
|
"there_is_a_server_error": "Error del servidor",
|
||||||
|
"an_unexpected_error_occured_did_you_enter_the_correct_url": "S'ha produït un error inesperat. Heu introduït correctament l'URL del servidor?"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"enter_url_to_jellyfin_server": "Introdueix l'URL del vostre servidor Jellyfin",
|
||||||
|
"server_url_placeholder": "http(s)://el-vostre-servidor.com",
|
||||||
|
"connect_button": "Connecta",
|
||||||
|
"previous_servers": "servidors anteriors",
|
||||||
|
"clear_button": "Esborra",
|
||||||
|
"search_for_local_servers": "Cercar servidors locals",
|
||||||
|
"searching": "Cercant...",
|
||||||
|
"servers": "Servidors"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"no_internet": "Sense internet",
|
||||||
|
"no_items": "No hi ha elements",
|
||||||
|
"no_internet_message": "No us preocupeu, encara podeu veure\nel contingut descarregat.",
|
||||||
|
"go_to_downloads": "Anar a les descàrregues",
|
||||||
|
"oops": "Oops!",
|
||||||
|
"error_message": "Alguna cosa ha anat malament.\nTanqueu la sessió i torneu-la a iniciar.",
|
||||||
|
"continue_watching": "Continua veient",
|
||||||
|
"next_up": "A continuació",
|
||||||
|
"recently_added_in": "Afegit recentment a {{libraryName}}",
|
||||||
|
"suggested_movies": "Pel·lícules suggerides",
|
||||||
|
"suggested_episodes": "Episodis suggerits",
|
||||||
|
"intro": {
|
||||||
|
"welcome_to_streamyfin": "Benvingut a Streamyfin",
|
||||||
|
"a_free_and_open_source_client_for_jellyfin": "Un client gratuït i de codi obert per a Jellyfin.",
|
||||||
|
"features_title": "Funcionalitats",
|
||||||
|
"features_description": "Streamyfin té moltes funcionalitats i s'integra amb una gran varietat de programari que podeu trobar al menú de configuració, això inclou:",
|
||||||
|
"jellyseerr_feature_description": "Connecteu-vos a la vostra instància de Jellyseerr i sol·liciteu pel·lícules directament des de l'aplicació.",
|
||||||
|
"downloads_feature_title": "Descàrregues",
|
||||||
|
"downloads_feature_description": "Descarregueu pel·lícules i sèries per veure-les sense connexió. Utilitzeu el mètode per defecte o instal·leu el servidor optimitzat per descarregar fitxers en segon pla.",
|
||||||
|
"chromecast_feature_description": "Envieu pel·lícules i sèries als vostres dispositius Chromecast.",
|
||||||
|
"centralised_settings_plugin_title": "Plugin de configuració centralitzada",
|
||||||
|
"centralised_settings_plugin_description": "Configureu els ajustos des d'una ubicació centralitzada al vostre servidor Jellyfin. Tots els ajustos del client per a tots els usuaris se sincronitzaran automàticament.",
|
||||||
|
"done_button": "Fet",
|
||||||
|
"go_to_settings_button": "Ves a la configuració",
|
||||||
|
"read_more": "Mostra més"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"settings_title": "Configuració",
|
||||||
|
"log_out_button": "Tanca sessió",
|
||||||
|
"user_info": {
|
||||||
|
"user_info_title": "Informació de l'usuari",
|
||||||
|
"user": "Usuari",
|
||||||
|
"server": "Servidor",
|
||||||
|
"token": "Token",
|
||||||
|
"app_version": "Versió de l'aplicació"
|
||||||
|
},
|
||||||
|
"quick_connect": {
|
||||||
|
"quick_connect_title": "Connexió ràpida",
|
||||||
|
"authorize_button": "Autoritza connexió ràpida",
|
||||||
|
"enter_the_quick_connect_code": "Introdueix el codi de connexió ràpida...",
|
||||||
|
"success": "Èxit",
|
||||||
|
"quick_connect_autorized": "Connexió ràpida autoritzada",
|
||||||
|
"error": "Error",
|
||||||
|
"invalid_code": "Codi invàlid",
|
||||||
|
"authorize": "Autoritza"
|
||||||
|
},
|
||||||
|
"media_controls": {
|
||||||
|
"media_controls_title": "Controls multimèdia",
|
||||||
|
"forward_skip_length": "Durada del salt endavant",
|
||||||
|
"rewind_length": "Durada del rebobinat",
|
||||||
|
"seconds_unit": "s"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"audio_title": "Àudio",
|
||||||
|
"set_audio_track": "Establir pista d'àudio de l'element anterior",
|
||||||
|
"audio_language": "Idioma de l'àudio",
|
||||||
|
"audio_hint": "Trieu un idioma d'àudio per defecte.",
|
||||||
|
"none": "Cap",
|
||||||
|
"language": "Idioma"
|
||||||
|
},
|
||||||
|
"subtitles": {
|
||||||
|
"subtitle_title": "Subtítols",
|
||||||
|
"subtitle_language": "Idioma dels subtítols",
|
||||||
|
"subtitle_mode": "Mode dels subtítols",
|
||||||
|
"set_subtitle_track": "Establir pista de subtítols de l'element anterior",
|
||||||
|
"subtitle_size": "Mida dels subtítols",
|
||||||
|
"subtitle_hint": "Configureu les preferències dels subtítols.",
|
||||||
|
"none": "Cap",
|
||||||
|
"language": "Idioma",
|
||||||
|
"loading": "Carregant",
|
||||||
|
"modes": {
|
||||||
|
"Default": "Per defecte",
|
||||||
|
"Smart": "Intel·ligent",
|
||||||
|
"Always": "Sempre",
|
||||||
|
"None": "Cap",
|
||||||
|
"OnlyForced": "Només els forçats"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"other_title": "Altres",
|
||||||
|
"follow_device_orientation": "Rotació automàtica",
|
||||||
|
"video_orientation": "Orientació del vídeo",
|
||||||
|
"orientation": "Orientació",
|
||||||
|
"orientations": {
|
||||||
|
"DEFAULT": "Per defecte",
|
||||||
|
"ALL": "Totes",
|
||||||
|
"PORTRAIT": "Vertical",
|
||||||
|
"PORTRAIT_UP": "Vertical amunt",
|
||||||
|
"PORTRAIT_DOWN": "Vertical avall",
|
||||||
|
"LANDSCAPE": "Horitzontal",
|
||||||
|
"LANDSCAPE_LEFT": "Horitzontal esquerra",
|
||||||
|
"LANDSCAPE_RIGHT": "Horitzontal dreta",
|
||||||
|
"OTHER": "Altra",
|
||||||
|
"UNKNOWN": "Desconeguda"
|
||||||
|
},
|
||||||
|
"safe_area_in_controls": "Àrea segura als controls",
|
||||||
|
"video_player": "Reproductor de vídeo",
|
||||||
|
"video_players": {
|
||||||
|
"VLC_3": "VLC 3",
|
||||||
|
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||||
|
},
|
||||||
|
"show_custom_menu_links": "Mostrar enllaços del menú personalitzats",
|
||||||
|
"hide_libraries": "Oculta biblioteques",
|
||||||
|
"select_liraries_you_want_to_hide": "Seleccioneu les biblioteques que voleu ocultar de la pestanya Biblioteca i de les seccions de la pàgina d'inici.",
|
||||||
|
"disable_haptic_feedback": "Desactiva la resposta hàptica",
|
||||||
|
"default_quality": "Qualitat per defecte",
|
||||||
|
"max_auto_play_episode_count": "Nombre màxim d'episodis de reproducció automàtica",
|
||||||
|
"disabled": "Desactivat"
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"downloads_title": "Descàrregues",
|
||||||
|
"download_method": "Mètode de descàrrega",
|
||||||
|
"remux_max_download": "Màxima descàrrega remux",
|
||||||
|
"auto_download": "Descàrrega automàtica",
|
||||||
|
"optimized_versions_server": "Servidor de versions optimitzades",
|
||||||
|
"save_button": "Desa",
|
||||||
|
"optimized_server": "Servidor optimitzat",
|
||||||
|
"optimized": "Optimitzat",
|
||||||
|
"default": "Per defecte",
|
||||||
|
"optimized_version_hint": "Introdueix l'URL del servidor d'optimització. L'URL ha d'incloure http o https i opcionalment el port.",
|
||||||
|
"read_more_about_optimized_server": "Mostra més sobre el servidor d'optimització.",
|
||||||
|
"url": "URL",
|
||||||
|
"server_url_placeholder": "http(s)://domini.org:port"
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"plugins_title": "Connectors",
|
||||||
|
"jellyseerr": {
|
||||||
|
"jellyseerr_warning": "Aquesta integració es troba en una versió primerenca. Espereu que les coses canviïn.",
|
||||||
|
"server_url": "URL del servidor",
|
||||||
|
"server_url_hint": "Exemple: http(s)://el-vostre-domini.url\n(afegiu el port si és necessari)",
|
||||||
|
"server_url_placeholder": "URL de Jellyseerr...",
|
||||||
|
"password": "Contrasenya",
|
||||||
|
"password_placeholder": "Introdueix la contrasenya per a l'usuari de Jellyfin {{username}}",
|
||||||
|
"save_button": "Desa",
|
||||||
|
"clear_button": "Esborra",
|
||||||
|
"login_button": "Inicia sessió",
|
||||||
|
"total_media_requests": "Sol·licituds totals de contingut",
|
||||||
|
"movie_quota_limit": "Límit de quota de pel·lícules",
|
||||||
|
"movie_quota_days": "Dies de quota de pel·lícules",
|
||||||
|
"tv_quota_limit": "Límit de quota de sèries",
|
||||||
|
"tv_quota_days": "Dies de quota de sèries",
|
||||||
|
"reset_jellyseerr_config_button": "Restalbeix la configuració de Jellyseerr",
|
||||||
|
"unlimited": "Il·limitat",
|
||||||
|
"plus_n_more": "+{{n}} més",
|
||||||
|
"order_by": {
|
||||||
|
"DEFAULT": "Per defecte",
|
||||||
|
"VOTE_COUNT_AND_AVERAGE": "Recompte de vots i mitjana",
|
||||||
|
"POPULARITY": "Popularitat"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"marlin_search": {
|
||||||
|
"enable_marlin_search": "Activa la cerca de Marlin",
|
||||||
|
"url": "URL",
|
||||||
|
"server_url_placeholder": "http(s)://domini.org:port",
|
||||||
|
"marlin_search_hint": "Introdueix l'URL del servidor Marlin. L'URL ha d'incloure http o https i opcionalment el port.",
|
||||||
|
"read_more_about_marlin": "Mostra més sobre Marlin.",
|
||||||
|
"save_button": "Desa",
|
||||||
|
"toasts": {
|
||||||
|
"saved": "Desat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"storage_title": "Emmagatzematge",
|
||||||
|
"app_usage": "Aplicació {{usedSpace}}%",
|
||||||
|
"device_usage": "Dispositiu {{availableSpace}}%",
|
||||||
|
"size_used": "{{used}} de {{total}} utilitzat",
|
||||||
|
"delete_all_downloaded_files": "Suprimeix tots els fitxers descarregats"
|
||||||
|
},
|
||||||
|
"intro": {
|
||||||
|
"show_intro": "Mostra la introducció",
|
||||||
|
"reset_intro": "Restableix la introducció"
|
||||||
|
},
|
||||||
|
"logs": {
|
||||||
|
"logs_title": "Registres",
|
||||||
|
"export_logs": "Exporta registres",
|
||||||
|
"click_for_more_info": "Feu clic per obtenir més informació",
|
||||||
|
"level": "Nivell",
|
||||||
|
"no_logs_available": "No hi ha registres disponibles",
|
||||||
|
"delete_all_logs": "Suprimeix tots els registres"
|
||||||
|
},
|
||||||
|
"languages": {
|
||||||
|
"title": "Idiomes",
|
||||||
|
"app_language": "Idioma de l'aplicació",
|
||||||
|
"app_language_description": "Seleccioneu l'idioma de l'aplicació.",
|
||||||
|
"system": "Sistema"
|
||||||
|
},
|
||||||
|
"toasts": {
|
||||||
|
"error_deleting_files": "Error en suprimir fitxers",
|
||||||
|
"background_downloads_enabled": "Descàrregues en segon pla activades",
|
||||||
|
"background_downloads_disabled": "Descàrregues en segon pla desactivades",
|
||||||
|
"connected": "Connectat",
|
||||||
|
"could_not_connect": "No s'ha pogut connectar",
|
||||||
|
"invalid_url": "URL invàlida"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"title": "Sessions",
|
||||||
|
"no_active_sessions": "No hi ha sessions actives"
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"downloads_title": "Descàrregues",
|
||||||
|
"tvseries": "Sèries",
|
||||||
|
"movies": "Pel·lícules",
|
||||||
|
"queue": "Cua",
|
||||||
|
"queue_hint": "La cua i les descàrregues es perdran en reiniciar l'aplicació",
|
||||||
|
"no_items_in_queue": "No hi ha elements a la cua",
|
||||||
|
"no_downloaded_items": "No hi ha elements descarregats",
|
||||||
|
"delete_all_movies_button": "Suprimeix totes les pel·lícules",
|
||||||
|
"delete_all_tvseries_button": "Suprimeix totes les sèries",
|
||||||
|
"delete_all_button": "Suprimeix-ho tot",
|
||||||
|
"active_download": "Descàrrega activa",
|
||||||
|
"no_active_downloads": "No hi ha descàrregues actives",
|
||||||
|
"active_downloads": "Descàrregues actives",
|
||||||
|
"new_app_version_requires_re_download": "La nova versió de l'aplicació requereix tornar a descarregar",
|
||||||
|
"new_app_version_requires_re_download_description": "L'actualització nova requereix que el contingut es torni a descarregar. Suprimiu tot el contingut descarregat i torneu-ho a provar.",
|
||||||
|
"back": "Enrere",
|
||||||
|
"delete": "Suprimeix",
|
||||||
|
"something_went_wrong": "Alguna cosa ha anat malament",
|
||||||
|
"could_not_get_stream_url_from_jellyfin": "No s'ha pogut obtenir l'URL del flux de Jellyfin",
|
||||||
|
"eta": "ETA {{eta}}",
|
||||||
|
"methods": "Mètodes",
|
||||||
|
"toasts": {
|
||||||
|
"you_are_not_allowed_to_download_files": "No teniu permís per descarregar fitxers.",
|
||||||
|
"deleted_all_movies_successfully": "S'han suprimit totes les pel·lícules correctament!",
|
||||||
|
"failed_to_delete_all_movies": "No s'han pogut suprimir totes les pel·lícules",
|
||||||
|
"deleted_all_tvseries_successfully": "S'han suprimit totes les sèries correctament!",
|
||||||
|
"failed_to_delete_all_tvseries": "No s'han pogut suprimir totes les sèries",
|
||||||
|
"download_cancelled": "Descàrrega cancel·lada",
|
||||||
|
"could_not_cancel_download": "No s'ha pogut cancel·lar la descàrrega",
|
||||||
|
"download_completed": "Descàrrega completada",
|
||||||
|
"download_started_for": "S'ha iniciat la descàrrega per a {{item}}",
|
||||||
|
"item_is_ready_to_be_downloaded": "{{item}} està preparat per ser descarregat",
|
||||||
|
"download_stated_for_item": "S'ha iniciat la descàrrega per a {{item}}",
|
||||||
|
"download_failed_for_item": "Ha fallat la descàrrega per a {{item}} - {{error}}",
|
||||||
|
"download_completed_for_item": "S'ha completat la descàrrega per a {{item}}",
|
||||||
|
"queued_item_for_optimization": "S'ha afegit {{item}} a la cua per a l'optimització",
|
||||||
|
"failed_to_start_download_for_item": "No s'ha pogut iniciar la descàrrega per a {{item}}: {{message}}",
|
||||||
|
"server_responded_with_status_code": "El servidor ha respost amb l'estat {{statusCode}}",
|
||||||
|
"no_response_received_from_server": "No s'ha rebut resposta del servidor",
|
||||||
|
"error_setting_up_the_request": "Error en configurar la sol·licitud",
|
||||||
|
"failed_to_start_download_for_item_unexpected_error": "No s'ha pogut iniciar la descàrrega per a {{item}}: Error inesperat",
|
||||||
|
"all_files_folders_and_jobs_deleted_successfully": "Tots els fitxers, carpetes i treballs s'han suprimit correctament",
|
||||||
|
"an_error_occured_while_deleting_files_and_jobs": "S'ha produït un error en suprimir fitxers i treballs",
|
||||||
|
"go_to_downloads": "Ves a les descàrregues"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"search_here": "Cerca aquí...",
|
||||||
|
"search": "Cerca...",
|
||||||
|
"x_items": "{{count}} elements",
|
||||||
|
"library": "Biblioteca",
|
||||||
|
"discover": "Descobreix",
|
||||||
|
"no_results": "No hi ha resultats",
|
||||||
|
"no_results_found_for": "No s'han trobat resultats per a",
|
||||||
|
"movies": "Pel·lícules",
|
||||||
|
"series": "Sèries",
|
||||||
|
"episodes": "Episodis",
|
||||||
|
"collections": "Col·leccions",
|
||||||
|
"actors": "Actors",
|
||||||
|
"request_movies": "Sol·licita pel·lícules",
|
||||||
|
"request_series": "Sol·licita sèries",
|
||||||
|
"recently_added": "Afegit recentment",
|
||||||
|
"recent_requests": "Sol·licituds recents",
|
||||||
|
"plex_watchlist": "Llista de seguiment de Plex",
|
||||||
|
"trending": "Tendències",
|
||||||
|
"popular_movies": "Pel·lícules populars",
|
||||||
|
"movie_genres": "Gèneres de pel·lícules",
|
||||||
|
"upcoming_movies": "Pròximes pel·lícules",
|
||||||
|
"studios": "Estudis",
|
||||||
|
"popular_tv": "Sèries populars",
|
||||||
|
"tv_genres": "Gèneres de sèries",
|
||||||
|
"upcoming_tv": "Pròximes sèries",
|
||||||
|
"networks": "Cadenes",
|
||||||
|
"tmdb_movie_keyword": "Paraula clau de pel·lícula TMDB",
|
||||||
|
"tmdb_movie_genre": "Gènere de pel·lícula TMDB",
|
||||||
|
"tmdb_tv_keyword": "Paraula clau de sèrie TMDB",
|
||||||
|
"tmdb_tv_genre": "Gènere de sèrie TMDB",
|
||||||
|
"tmdb_search": "Cerca TMDB",
|
||||||
|
"tmdb_studio": "Estudi TMDB",
|
||||||
|
"tmdb_network": "Cadena TMDB",
|
||||||
|
"tmdb_movie_streaming_services": "Serveis de reproducció de pel·lícules TMDB",
|
||||||
|
"tmdb_tv_streaming_services": "Serveis de reproducció de sèries TMDB"
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"no_items_found": "No s'han trobat elements",
|
||||||
|
"no_results": "No hi ha resultats",
|
||||||
|
"no_libraries_found": "No s'han trobat biblioteques",
|
||||||
|
"item_types": {
|
||||||
|
"movies": "pel·lícules",
|
||||||
|
"series": "sèries",
|
||||||
|
"boxsets": "col·leccions",
|
||||||
|
"items": "elements"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"display": "Visualització",
|
||||||
|
"row": "Fila",
|
||||||
|
"list": "Llista",
|
||||||
|
"image_style": "Estil d'imatge",
|
||||||
|
"poster": "Cartell",
|
||||||
|
"cover": "Coberta",
|
||||||
|
"show_titles": "Mostrar títols",
|
||||||
|
"show_stats": "Mostrar estadístiques"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"genres": "Gèneres",
|
||||||
|
"years": "Anys",
|
||||||
|
"sort_by": "Ordenar per",
|
||||||
|
"sort_order": "Ordre",
|
||||||
|
"asc": "Ascendent",
|
||||||
|
"desc": "Descendent",
|
||||||
|
"tags": "Etiquetes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"favorites": {
|
||||||
|
"series": "Sèries",
|
||||||
|
"movies": "Pel·lícules",
|
||||||
|
"episodes": "Episodis",
|
||||||
|
"videos": "Vídeos",
|
||||||
|
"boxsets": "Col·leccions",
|
||||||
|
"playlists": "Llistes de reproducció",
|
||||||
|
"noDataTitle": "Encara no hi ha preferits",
|
||||||
|
"noData": "Marqueu elements com a preferits per veure'ls aparèixer aquí per a un accés ràpid."
|
||||||
|
},
|
||||||
|
"custom_links": {
|
||||||
|
"no_links": "No hi ha enllaços"
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"error": "Error",
|
||||||
|
"failed_to_get_stream_url": "No s'ha pogut obtenir l'URL del flux",
|
||||||
|
"an_error_occured_while_playing_the_video": "S'ha produït un error en reproduir el vídeo. Consulteu els registres a la configuració.",
|
||||||
|
"client_error": "Error del client",
|
||||||
|
"could_not_create_stream_for_chromecast": "No s'ha pogut crear un flux per a Chromecast",
|
||||||
|
"message_from_server": "Missatge del servidor: {{message}}",
|
||||||
|
"video_has_finished_playing": "El vídeo ha acabat de reproduir-se!",
|
||||||
|
"no_video_source": "No hi ha font de vídeo...",
|
||||||
|
"next_episode": "Episodi següent",
|
||||||
|
"refresh_tracks": "Actualitzar pistes",
|
||||||
|
"subtitle_tracks": "Pistes de subtítols:",
|
||||||
|
"audio_tracks": "Pistes d'àudio:",
|
||||||
|
"playback_state": "Estat de reproducció:",
|
||||||
|
"no_data_available": "No hi ha dades disponibles",
|
||||||
|
"index": "Índex:",
|
||||||
|
"continue_watching": "Continuar veient",
|
||||||
|
"go_back": "Enrere"
|
||||||
|
},
|
||||||
|
"item_card": {
|
||||||
|
"next_up": "A continuació",
|
||||||
|
"no_items_to_display": "No hi ha elements per mostrar",
|
||||||
|
"cast_and_crew": "Repartiment i equip",
|
||||||
|
"series": "Sèries",
|
||||||
|
"seasons": "Temporades",
|
||||||
|
"season": "Temporada",
|
||||||
|
"no_episodes_for_this_season": "No hi ha episodis per a aquesta temporada",
|
||||||
|
"overview": "Descripció general",
|
||||||
|
"more_with": "Més amb {{name}}",
|
||||||
|
"similar_items": "Elements similars",
|
||||||
|
"no_similar_items_found": "No s'han trobat elements similars",
|
||||||
|
"video": "Vídeo",
|
||||||
|
"more_details": "Més detalls",
|
||||||
|
"quality": "Qualitat",
|
||||||
|
"audio": "Àudio",
|
||||||
|
"subtitles": "Subtítols",
|
||||||
|
"show_more": "Mostra més",
|
||||||
|
"show_less": "Mostra menys",
|
||||||
|
"appeared_in": "Va aparèixer a",
|
||||||
|
"could_not_load_item": "No s'ha pogut carregar l'element",
|
||||||
|
"none": "Cap",
|
||||||
|
"download": {
|
||||||
|
"download_season": "Descarrega la temporada",
|
||||||
|
"download_series": "Descarrega la sèrie",
|
||||||
|
"download_episode": "Descarrega l'episodi",
|
||||||
|
"download_movie": "Descarrega la pel·lícula",
|
||||||
|
"download_x_item": "Descarrega {{item_count}} elements",
|
||||||
|
"download_button": "Descarrega",
|
||||||
|
"using_optimized_server": "Utilitzant servidor optimitzat",
|
||||||
|
"using_default_method": "Utilitzant mètode per defecte"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"live_tv": {
|
||||||
|
"next": "Següent",
|
||||||
|
"previous": "Anterior",
|
||||||
|
"live_tv": "TV en directe",
|
||||||
|
"coming_soon": "Pròximament",
|
||||||
|
"on_now": "Ara en emissió",
|
||||||
|
"shows": "Programes",
|
||||||
|
"movies": "Pel·lícules",
|
||||||
|
"sports": "Esports",
|
||||||
|
"for_kids": "Infantil",
|
||||||
|
"news": "Notícies"
|
||||||
|
},
|
||||||
|
"jellyseerr": {
|
||||||
|
"confirm": "Confirma",
|
||||||
|
"cancel": "Cancel·la",
|
||||||
|
"yes": "Sí",
|
||||||
|
"whats_wrong": "Què està passant?",
|
||||||
|
"issue_type": "Tipus d'incidència",
|
||||||
|
"select_an_issue": "Seleccioneu una incidència",
|
||||||
|
"types": "Tipus",
|
||||||
|
"describe_the_issue": "(opcional) Descriviu la incidència...",
|
||||||
|
"submit_button": "Envia",
|
||||||
|
"report_issue_button": "Informa d'una incidència",
|
||||||
|
"request_button": "Sol·licita",
|
||||||
|
"are_you_sure_you_want_to_request_all_seasons": "Esteu segur que voleu sol·licitar totes les temporades?",
|
||||||
|
"failed_to_login": "No s'ha pogut iniciar sessió",
|
||||||
|
"cast": "Repartiment",
|
||||||
|
"details": "Detalls",
|
||||||
|
"status": "Estat",
|
||||||
|
"original_title": "Títol original",
|
||||||
|
"series_type": "Tipus de sèrie",
|
||||||
|
"release_dates": "Dates d'estrena",
|
||||||
|
"first_air_date": "Primera data d'emissió",
|
||||||
|
"next_air_date": "Propera data d'emissió",
|
||||||
|
"revenue": "Ingressos",
|
||||||
|
"budget": "Pressupost",
|
||||||
|
"original_language": "Idioma original",
|
||||||
|
"production_country": "País de producció",
|
||||||
|
"studios": "Estudis",
|
||||||
|
"network": "Cadena",
|
||||||
|
"currently_streaming_on": "Actualment en reproducció a",
|
||||||
|
"advanced": "Avançat",
|
||||||
|
"request_as": "Sol·licita com a",
|
||||||
|
"tags": "Etiquetes",
|
||||||
|
"quality_profile": "Perfil de qualitat",
|
||||||
|
"root_folder": "Carpeta arrel",
|
||||||
|
"season_all": "Temporada (totes)",
|
||||||
|
"season_number": "Temporada {{season_number}}",
|
||||||
|
"number_episodes": "{{episode_number}} episodis",
|
||||||
|
"born": "Nascut",
|
||||||
|
"appearances": "Aparicions",
|
||||||
|
"toasts": {
|
||||||
|
"jellyseer_does_not_meet_requirements": "El servidor Jellyseerr no compleix els requisits mínims de versió! Actualitzeu-lo almenys a la versió 2.0.0",
|
||||||
|
"jellyseerr_test_failed": "Ha fallat la prova de Jellyseerr. Torneu-ho a provar.",
|
||||||
|
"failed_to_test_jellyseerr_server_url": "No s'ha pogut provar l'URL del servidor de Jellyseerr",
|
||||||
|
"issue_submitted": "Incidència enviada!",
|
||||||
|
"requested_item": "S'ha sol·licitat {{item}}!",
|
||||||
|
"you_dont_have_permission_to_request": "No teniu permís per sol·licitar!",
|
||||||
|
"something_went_wrong_requesting_media": "Alguna cosa ha anat malament en sol·licitar contingut!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"home": "Inici",
|
||||||
|
"search": "Cercar",
|
||||||
|
"library": "Biblioteca",
|
||||||
|
"custom_links": "Enllaços personalitzats",
|
||||||
|
"favorites": "Preferits"
|
||||||
|
}
|
||||||
|
}
|
||||||
484
translations/vi.json
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
{
|
||||||
|
"login": {
|
||||||
|
"username_required": "Cần nhập tên người dùng",
|
||||||
|
"error_title": "Lỗi",
|
||||||
|
"login_title": "Đăng nhập",
|
||||||
|
"login_to_title": "Đăng nhập vào",
|
||||||
|
"username_placeholder": "Tên người dùng",
|
||||||
|
"password_placeholder": "Mật khẩu",
|
||||||
|
"login_button": "Đăng nhập",
|
||||||
|
"quick_connect": "Kết nối nhanh",
|
||||||
|
"enter_code_to_login": "Nhập mã {{code}} để đăng nhập",
|
||||||
|
"failed_to_initiate_quick_connect": "Không thể bắt đầu Kết nối nhanh",
|
||||||
|
"got_it": "Đã hiểu",
|
||||||
|
"connection_failed": "Kết nối thất bại",
|
||||||
|
"could_not_connect_to_server": "Không thể kết nối tới máy chủ. Vui lòng kiểm tra URL và kết nối mạng.",
|
||||||
|
"an_unexpected_error_occured": "Đã xảy ra lỗi không mong muốn",
|
||||||
|
"change_server": "Đổi máy chủ",
|
||||||
|
"invalid_username_or_password": "Tên đăng nhập hoặc mật khẩu không đúng",
|
||||||
|
"user_does_not_have_permission_to_log_in": "Người dùng không có quyền đăng nhập",
|
||||||
|
"server_is_taking_too_long_to_respond_try_again_later": "Máy chủ phản hồi quá lâu, vui lòng thử lại sau",
|
||||||
|
"server_received_too_many_requests_try_again_later": "Máy chủ nhận quá nhiều yêu cầu, vui lòng thử lại sau.",
|
||||||
|
"there_is_a_server_error": "Đã xảy ra lỗi ở máy chủ",
|
||||||
|
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Đã xảy ra lỗi không mong muốn. Bạn có chắc đã nhập đúng URL máy chủ?"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"enter_url_to_jellyfin_server": "Nhập URL máy chủ Jellyfin của bạn",
|
||||||
|
"server_url_placeholder": "http(s)://your-server.com",
|
||||||
|
"connect_button": "Kết nối",
|
||||||
|
"previous_servers": "Máy chủ trước đó",
|
||||||
|
"clear_button": "Xóa",
|
||||||
|
"search_for_local_servers": "Tìm máy chủ trong mạng",
|
||||||
|
"searching": "Đang tìm...",
|
||||||
|
"servers": "Máy chủ"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"no_internet": "Không có Internet",
|
||||||
|
"no_items": "Không có nội dung",
|
||||||
|
"no_internet_message": "Không sao, bạn vẫn có thể xem được nội dung đã tải xuống.",
|
||||||
|
"go_to_downloads": "Tới phần tải về",
|
||||||
|
"oops": "Ối!",
|
||||||
|
"error_message": "Có lỗi xảy ra.\nVui lòng đăng xuất rồi đăng nhập lại.",
|
||||||
|
"continue_watching": "Tiếp tục xem",
|
||||||
|
"next_up": "Tiếp theo",
|
||||||
|
"recently_added_in": "Mới thêm trong {{libraryName}}",
|
||||||
|
"suggested_movies": "Phim gợi ý",
|
||||||
|
"suggested_episodes": "Tập gợi ý",
|
||||||
|
"intro": {
|
||||||
|
"welcome_to_streamyfin": "Chào mừng đến với Streamyfin",
|
||||||
|
"a_free_and_open_source_client_for_jellyfin": "Một ứng dụng miễn phí và mã nguồn mở cho Jellyfin.",
|
||||||
|
"features_title": "Tính năng",
|
||||||
|
"features_description": "Streamyfin có nhiều tính năng và tích hợp với nhiều phần mềm, xem trong mục cài đặt, bao gồm:",
|
||||||
|
"jellyseerr_feature_description": "Kết nối với Jellyseerr và yêu cầu phim ngay trong ứng dụng.",
|
||||||
|
"downloads_feature_title": "Tải xuống",
|
||||||
|
"downloads_feature_description": "Tải phim và chương trình để xem ngoại tuyến. Có thể dùng phương pháp mặc định hoặc cài đặt máy chủ tối ưu để hỗ trợ tải trong nền.",
|
||||||
|
"chromecast_feature_description": "Phát phim lên Chromecast.",
|
||||||
|
"centralised_settings_plugin_title": "Plugin cấu hình tập trung",
|
||||||
|
"centralised_settings_plugin_description": "Cài đặt đồng bộ hóa từ máy chủ Jellyfin. Tất cả cài đặt người dùng sẽ được đồng bộ.",
|
||||||
|
"done_button": "Xong",
|
||||||
|
"go_to_settings_button": "Tới cài đặt",
|
||||||
|
"read_more": "Xem thêm"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"settings_title": "Cài đặt",
|
||||||
|
"log_out_button": "Đăng xuất",
|
||||||
|
"user_info": {
|
||||||
|
"user_info_title": "Thông tin người dùng",
|
||||||
|
"user": "Người dùng",
|
||||||
|
"server": "Máy chủ",
|
||||||
|
"token": "Token",
|
||||||
|
"app_version": "Phiên bản ứng dụng"
|
||||||
|
},
|
||||||
|
"quick_connect": {
|
||||||
|
"quick_connect_title": "Kết nối nhanh",
|
||||||
|
"authorize_button": "Cho phép Kết nối nhanh",
|
||||||
|
"enter_the_quick_connect_code": "Nhập mã kết nối nhanh...",
|
||||||
|
"success": "Thành công",
|
||||||
|
"quick_connect_autorized": "Kết nối nhanh đã được cho phép",
|
||||||
|
"error": "Lỗi",
|
||||||
|
"invalid_code": "Mã không hợp lệ",
|
||||||
|
"authorize": "Cho phép"
|
||||||
|
},
|
||||||
|
"media_controls": {
|
||||||
|
"media_controls_title": "Điều khiển đa phương tiện",
|
||||||
|
"forward_skip_length": "Thời gian tua tới",
|
||||||
|
"rewind_length": "Thời gian tua lui",
|
||||||
|
"seconds_unit": "s"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"audio_title": "Âm thanh",
|
||||||
|
"set_audio_track": "Chọn track âm thanh từ mục trước",
|
||||||
|
"audio_language": "Ngôn ngữ âm thanh",
|
||||||
|
"audio_hint": "Chọn ngôn ngữ âm thanh mặc định.",
|
||||||
|
"none": "Không có",
|
||||||
|
"language": "Ngôn ngữ"
|
||||||
|
},
|
||||||
|
"subtitles": {
|
||||||
|
"subtitle_title": "Phụ đề",
|
||||||
|
"subtitle_language": "Ngôn ngữ phụ đề",
|
||||||
|
"subtitle_mode": "Chế độ phụ đề",
|
||||||
|
"set_subtitle_track": "Chọn phụ đề từ mục trước",
|
||||||
|
"subtitle_size": "Cỡ chữ",
|
||||||
|
"subtitle_hint": "Cấu hình tùy chọn phụ đề.",
|
||||||
|
"none": "Không có",
|
||||||
|
"language": "Ngôn ngữ",
|
||||||
|
"loading": "Đang tải",
|
||||||
|
"modes": {
|
||||||
|
"Default": "Mặc định",
|
||||||
|
"Smart": "Thông minh",
|
||||||
|
"Always": "Luôn hiện",
|
||||||
|
"None": "Không hiển thị",
|
||||||
|
"OnlyForced": "Bắt buộc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"other_title": "Khác",
|
||||||
|
"follow_device_orientation": "Tự xoay màn hình",
|
||||||
|
"video_orientation": "Hướng video",
|
||||||
|
"orientation": "Hướng",
|
||||||
|
"orientations": {
|
||||||
|
"DEFAULT": "Mặc định",
|
||||||
|
"ALL": "Tất cả",
|
||||||
|
"PORTRAIT": "Chân dung",
|
||||||
|
"PORTRAIT_UP": "Chân dung hướng lên",
|
||||||
|
"PORTRAIT_DOWN": "Chân dung hướng xuống",
|
||||||
|
"LANDSCAPE": "Ngang",
|
||||||
|
"LANDSCAPE_LEFT": "Ngang trái",
|
||||||
|
"LANDSCAPE_RIGHT": "Ngang phải",
|
||||||
|
"OTHER": "Khác",
|
||||||
|
"UNKNOWN": "Không rõ"
|
||||||
|
},
|
||||||
|
"safe_area_in_controls": "Vùng an toàn trong điều khiển",
|
||||||
|
"video_player": "Trình phát video",
|
||||||
|
"video_players": {
|
||||||
|
"VLC_3": "VLC 3",
|
||||||
|
"VLC_4": "VLC 4 (Thử nghiệm + PiP)"
|
||||||
|
},
|
||||||
|
"show_custom_menu_links": "Hiện liên kết tùy chỉnh",
|
||||||
|
"hide_libraries": "Ẩn thư viện",
|
||||||
|
"select_liraries_you_want_to_hide": "Chọn các thư viện muốn ẩn khỏi mục Thư viện và Trang chủ.",
|
||||||
|
"disable_haptic_feedback": "Tắt phản hồi rung",
|
||||||
|
"default_quality": "Chất lượng mặc định",
|
||||||
|
"max_auto_play_episode_count": "Số tập tự chạy tối đa",
|
||||||
|
"disabled": "Đã tắt"
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"downloads_title": "Tải xuống",
|
||||||
|
"download_method": "Phương pháp tải",
|
||||||
|
"remux_max_download": "Giới hạn tải Remux tối đa",
|
||||||
|
"auto_download": "Tự động tải",
|
||||||
|
"optimized_versions_server": "Máy chủ phiên bản tối ưu",
|
||||||
|
"save_button": "Lưu",
|
||||||
|
"optimized_server": "Máy chủ tối ưu",
|
||||||
|
"optimized": "Tối ưu",
|
||||||
|
"default": "Mặc định",
|
||||||
|
"optimized_version_hint": "Nhập URL máy chủ tối ưu. Phải có http hoặc https và cổng nếu cần.",
|
||||||
|
"read_more_about_optimized_server": "Tìm hiểu thêm về máy chủ tối ưu.",
|
||||||
|
"url": "URL",
|
||||||
|
"server_url_placeholder": "http(s)://domain.org:port"
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"plugins_title": "Plugin",
|
||||||
|
"jellyseerr": {
|
||||||
|
"jellyseerr_warning": "Tích hợp đang trong giai đoạn thử nghiệm. Nội dung có thể thay đổi.",
|
||||||
|
"server_url": "URL máy chủ",
|
||||||
|
"server_url_hint": "Ví dụ: http(s)://your-host.url (có port nếu cần)",
|
||||||
|
"server_url_placeholder": "Jellyseerr URL...",
|
||||||
|
"password": "Mật khẩu",
|
||||||
|
"password_placeholder": "Nhập mật khẩu cho người dùng {{username}} Jellyfin",
|
||||||
|
"save_button": "Lưu",
|
||||||
|
"clear_button": "Xóa",
|
||||||
|
"login_button": "Đăng nhập",
|
||||||
|
"total_media_requests": "Tổng số lượt yêu cầu nội dung",
|
||||||
|
"movie_quota_limit": "Giới hạn phim",
|
||||||
|
"movie_quota_days": "Số ngày giới hạn yêu cầu phim",
|
||||||
|
"tv_quota_limit": "Giới hạn TV",
|
||||||
|
"tv_quota_days": "Số ngày giới hạn yêu cầu TV",
|
||||||
|
"reset_jellyseerr_config_button": "Đặt lại cấu hình Jellyseerr",
|
||||||
|
"unlimited": "Không giới hạn",
|
||||||
|
"plus_n_more": "+{{n}} thêm",
|
||||||
|
"order_by": {
|
||||||
|
"DEFAULT": "Mặc định",
|
||||||
|
"VOTE_COUNT_AND_AVERAGE": "Dựa trên số lượt và điểm đánh giá",
|
||||||
|
"POPULARITY": "Theo độ phổ biến"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"marlin_search": {
|
||||||
|
"enable_marlin_search": "Bật Marlin Search",
|
||||||
|
"url": "URL",
|
||||||
|
"server_url_placeholder": "http(s)://domain.org:port",
|
||||||
|
"marlin_search_hint": "Nhập URL máy chủ Marlin. Phải có http/https và port nếu cần.",
|
||||||
|
"read_more_about_marlin": "Tìm hiểu thêm về Marlin.",
|
||||||
|
"save_button": "Lưu",
|
||||||
|
"toasts": {
|
||||||
|
"saved": "Đã lưu"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"storage_title": "Lưu trữ",
|
||||||
|
"app_usage": "Ứng dụng sử dụng {{usedSpace}}%",
|
||||||
|
"device_usage": "Thiết bị sử dụng {{availableSpace}}%",
|
||||||
|
"size_used": "{{used}} / {{total}} đã dùng",
|
||||||
|
"delete_all_downloaded_files": "Xóa toàn bộ tập tin đã tải"
|
||||||
|
},
|
||||||
|
"intro": {
|
||||||
|
"show_intro": "Hiện giới thiệu",
|
||||||
|
"reset_intro": "Đặt lại giới thiệu"
|
||||||
|
},
|
||||||
|
"logs": {
|
||||||
|
"logs_title": "Nhật ký",
|
||||||
|
"export_logs": "Xuất nhật ký",
|
||||||
|
"click_for_more_info": "Nhấn để xem thêm thông tin",
|
||||||
|
"level": "Mức độ",
|
||||||
|
"no_logs_available": "Không có nhật ký",
|
||||||
|
"delete_all_logs": "Xóa tất cả nhật ký"
|
||||||
|
},
|
||||||
|
"languages": {
|
||||||
|
"title": "Ngôn ngữ",
|
||||||
|
"app_language": "Ngôn ngữ ứng dụng",
|
||||||
|
"app_language_description": "Chọn ngôn ngữ cho ứng dụng.",
|
||||||
|
"system": "Hệ thống"
|
||||||
|
},
|
||||||
|
"toasts": {
|
||||||
|
"error_deleting_files": "Lỗi khi xóa tập tin",
|
||||||
|
"background_downloads_enabled": "Tải trong nền đã bật",
|
||||||
|
"background_downloads_disabled": "Tải trong nền đã tắt",
|
||||||
|
"connected": "Đã kết nối",
|
||||||
|
"could_not_connect": "Không thể kết nối",
|
||||||
|
"invalid_url": "URL không hợp lệ"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"title": "Phiên hoạt động",
|
||||||
|
"no_active_sessions": "Không có phiên đang hoạt động"
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"downloads_title": "Tải xuống",
|
||||||
|
"tvseries": "Chương trình TV",
|
||||||
|
"movies": "Phim",
|
||||||
|
"queue": "Hàng đợi",
|
||||||
|
"queue_hint": "Hàng đợi và tải xuống sẽ bị mất khi khởi động lại ứng dụng",
|
||||||
|
"no_items_in_queue": "Không có mục trong hàng đợi",
|
||||||
|
"no_downloaded_items": "Không có mục đã tải",
|
||||||
|
"delete_all_movies_button": "Xóa tất cả phim",
|
||||||
|
"delete_all_tvseries_button": "Xóa tất cả chương trình TV",
|
||||||
|
"delete_all_button": "Xóa tất cả",
|
||||||
|
"active_download": "Đang tải xuống",
|
||||||
|
"no_active_downloads": "Không có tải xuống đang diễn ra",
|
||||||
|
"active_downloads": "Đang tải xuống",
|
||||||
|
"new_app_version_requires_re_download": "Phiên bản ứng dụng mới yêu cầu tải lại",
|
||||||
|
"new_app_version_requires_re_download_description": "Cập nhật mới yêu cầu phải tải lại nội dung. Vui lòng xóa toàn bộ nội dung đã tải và thử lại.",
|
||||||
|
"back": "Quay lại",
|
||||||
|
"delete": "Xóa",
|
||||||
|
"something_went_wrong": "Đã xảy ra lỗi",
|
||||||
|
"could_not_get_stream_url_from_jellyfin": "Không thể lấy URL phát trực tiếp từ Jellyfin",
|
||||||
|
"eta": "Thời gian còn lại {{eta}}",
|
||||||
|
"methods": "Phương pháp",
|
||||||
|
"toasts": {
|
||||||
|
"you_are_not_allowed_to_download_files": "Bạn không có quyền tải nội dung.",
|
||||||
|
"deleted_all_movies_successfully": "Đã xóa tất cả phim thành công!",
|
||||||
|
"failed_to_delete_all_movies": "Xóa phim thất bại",
|
||||||
|
"deleted_all_tvseries_successfully": "Đã xóa tất cả chương trình TV thành công!",
|
||||||
|
"failed_to_delete_all_tvseries": "Xóa chương trình TV thất bại",
|
||||||
|
"download_cancelled": "Tải xuống bị hủy",
|
||||||
|
"could_not_cancel_download": "Không thể hủy tải xuống",
|
||||||
|
"download_completed": "Tải xuống hoàn tất",
|
||||||
|
"download_started_for": "Bắt đầu tải {{item}}",
|
||||||
|
"item_is_ready_to_be_downloaded": "{{item}} đã sẵn sàng để tải",
|
||||||
|
"download_stated_for_item": "Bắt đầu tải {{item}}",
|
||||||
|
"download_failed_for_item": "Tải {{item}} thất bại – {{error}}",
|
||||||
|
"download_completed_for_item": "Tải xong {{item}}",
|
||||||
|
"queued_item_for_optimization": "Đã đưa {{item}} vào hàng đợi tối ưu hóa",
|
||||||
|
"failed_to_start_download_for_item": "Không thể bắt đầu tải {{item}}: {{message}}",
|
||||||
|
"server_responded_with_status_code": "Máy chủ phản hồi mã {{statusCode}}",
|
||||||
|
"no_response_received_from_server": "Không nhận được phản hồi từ máy chủ",
|
||||||
|
"error_setting_up_the_request": "Lỗi khi thiết lập yêu cầu",
|
||||||
|
"failed_to_start_download_for_item_unexpected_error": "Không thể bắt đầu tải {{item}}: Lỗi không mong muốn",
|
||||||
|
"all_files_folders_and_jobs_deleted_successfully": "Đã xóa thành công tất cả tập tin, thư mục và công việc",
|
||||||
|
"an_error_occured_while_deleting_files_and_jobs": "Đã xảy ra lỗi khi xóa tập tin và công việc",
|
||||||
|
"go_to_downloads": "Tới phần tải về"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"search_here": "Tìm tại đây...",
|
||||||
|
"search": "Tìm...",
|
||||||
|
"x_items": "{{count}} mục",
|
||||||
|
"library": "Thư viện",
|
||||||
|
"discover": "Khám phá",
|
||||||
|
"no_results": "Không có kết quả",
|
||||||
|
"no_results_found_for": "Không tìm thấy kết quả cho",
|
||||||
|
"movies": "Phim",
|
||||||
|
"series": "Chương trình",
|
||||||
|
"episodes": "Tập",
|
||||||
|
"collections": "Bộ sưu tập",
|
||||||
|
"actors": "Diễn viên",
|
||||||
|
"request_movies": "Yêu cầu phim",
|
||||||
|
"request_series": "Yêu cầu chương trình",
|
||||||
|
"recently_added": "Mới thêm",
|
||||||
|
"recent_requests": "Yêu cầu gần đây",
|
||||||
|
"plex_watchlist": "Danh sách xem Plex",
|
||||||
|
"trending": "Thịnh hành",
|
||||||
|
"popular_movies": "Phim phổ biến",
|
||||||
|
"movie_genres": "Thể loại phim",
|
||||||
|
"upcoming_movies": "Phim sắp chiếu",
|
||||||
|
"studios": "Hãng phim",
|
||||||
|
"popular_tv": "TV phổ biến",
|
||||||
|
"tv_genres": "Thể loại TV",
|
||||||
|
"upcoming_tv": "TV sắp chiếu",
|
||||||
|
"networks": "Mạng phát",
|
||||||
|
"tmdb_movie_keyword": "Từ khóa phim TMDB",
|
||||||
|
"tmdb_movie_genre": "Thể loại phim TMDB",
|
||||||
|
"tmdb_tv_keyword": "Từ khóa TV TMDB",
|
||||||
|
"tmdb_tv_genre": "Thể loại TV TMDB",
|
||||||
|
"tmdb_search": "Tìm TMDB",
|
||||||
|
"tmdb_studio": "Hãng phim TMDB",
|
||||||
|
"tmdb_network": "Mạng TMDB",
|
||||||
|
"tmdb_movie_streaming_services": "Dịch vụ streaming phim TMDB",
|
||||||
|
"tmdb_tv_streaming_services": "Dịch vụ streaming TV TMDB"
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"no_items_found": "Không tìm thấy nội dung",
|
||||||
|
"no_results": "Không có kết quả",
|
||||||
|
"no_libraries_found": "Không tìm thấy thư viện",
|
||||||
|
"item_types": {
|
||||||
|
"movies": "phim",
|
||||||
|
"series": "chương trình",
|
||||||
|
"boxsets": "bộ sưu tập",
|
||||||
|
"items": "nội dung"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"display": "Hiển thị",
|
||||||
|
"row": "Hàng ngang",
|
||||||
|
"list": "Danh sách",
|
||||||
|
"image_style": "Kiểu hình ảnh",
|
||||||
|
"poster": "Ảnh bìa dọc",
|
||||||
|
"cover": "Bìa",
|
||||||
|
"show_titles": "Hiển thị tiêu đề",
|
||||||
|
"show_stats": "Hiện thống kê"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"genres": "Thể loại",
|
||||||
|
"years": "Năm",
|
||||||
|
"sort_by": "Sắp xếp theo",
|
||||||
|
"sort_order": "Thứ tự",
|
||||||
|
"asc": "Tăng dần",
|
||||||
|
"desc": "Giảm dần",
|
||||||
|
"tags": "Thẻ"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"favorites": {
|
||||||
|
"series": "Chương trình",
|
||||||
|
"movies": "Phim",
|
||||||
|
"episodes": "Tập",
|
||||||
|
"videos": "Video",
|
||||||
|
"boxsets": "Bộ sưu tập",
|
||||||
|
"playlists": "Danh sách phát",
|
||||||
|
"noDataTitle": "Chưa có mục yêu thích",
|
||||||
|
"noData": "Đánh dấu mục yêu thích để xem ở đây nhanh hơn."
|
||||||
|
},
|
||||||
|
"custom_links": {
|
||||||
|
"no_links": "Chưa có liên kết"
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"error": "Lỗi",
|
||||||
|
"failed_to_get_stream_url": "Không thể lấy URL phát trực tiếp",
|
||||||
|
"an_error_occured_while_playing_the_video": "Có lỗi khi phát video. Xem nhật ký trong cài đặt.",
|
||||||
|
"client_error": "Lỗi phía máy khách",
|
||||||
|
"could_not_create_stream_for_chromecast": "Không thể tạo luồng cho Chromecast",
|
||||||
|
"message_from_server": "Thông báo từ máy chủ: {{message}}",
|
||||||
|
"video_has_finished_playing": "Video đã phát xong!",
|
||||||
|
"no_video_source": "Không có nguồn video...",
|
||||||
|
"next_episode": "Tập tiếp theo",
|
||||||
|
"refresh_tracks": "Làm mới các track",
|
||||||
|
"subtitle_tracks": "Track phụ đề:",
|
||||||
|
"audio_tracks": "Track âm thanh:",
|
||||||
|
"playback_state": "Trạng thái phát:",
|
||||||
|
"no_data_available": "Không có dữ liệu",
|
||||||
|
"index": "Chỉ mục:",
|
||||||
|
"continue_watching": "Tiếp tục xem",
|
||||||
|
"go_back": "Quay lại"
|
||||||
|
},
|
||||||
|
"item_card": {
|
||||||
|
"next_up": "Tiếp theo",
|
||||||
|
"no_items_to_display": "Không có nội dung để hiển thị",
|
||||||
|
"cast_and_crew": "Diễn viên & Đội ngũ",
|
||||||
|
"series": "Chương trình",
|
||||||
|
"seasons": "Mùa",
|
||||||
|
"season": "Mùa",
|
||||||
|
"no_episodes_for_this_season": "Không có tập cho mùa này",
|
||||||
|
"overview": "Giới thiệu",
|
||||||
|
"more_with": "Thêm với {{name}}",
|
||||||
|
"similar_items": "Nội dung tương tự",
|
||||||
|
"no_similar_items_found": "Không tìm thấy nội dung tương tự",
|
||||||
|
"video": "Video",
|
||||||
|
"more_details": "Xem thêm chi tiết",
|
||||||
|
"quality": "Chất lượng",
|
||||||
|
"audio": "Âm thanh",
|
||||||
|
"subtitles": "Phụ đề",
|
||||||
|
"show_more": "Xem thêm",
|
||||||
|
"show_less": "Thu gọn",
|
||||||
|
"appeared_in": "Xuất hiện trong",
|
||||||
|
"could_not_load_item": "Không thể tải nội dung",
|
||||||
|
"none": "Không có",
|
||||||
|
"download": {
|
||||||
|
"download_season": "Tải mùa",
|
||||||
|
"download_series": "Tải chương trình",
|
||||||
|
"download_episode": "Tải tập",
|
||||||
|
"download_movie": "Tải phim",
|
||||||
|
"download_x_item": "Tải {{item_count}} nội dung",
|
||||||
|
"download_button": "Tải",
|
||||||
|
"using_optimized_server": "Đang dùng máy chủ tối ưu",
|
||||||
|
"using_default_method": "Đang dùng phương pháp mặc định"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"live_tv": {
|
||||||
|
"next": "Tiếp theo",
|
||||||
|
"previous": "Trước đó",
|
||||||
|
"live_tv": "TV trực tiếp",
|
||||||
|
"coming_soon": "Sắp chiếu",
|
||||||
|
"on_now": "Đang phát",
|
||||||
|
"shows": "Chương trình",
|
||||||
|
"movies": "Phim",
|
||||||
|
"sports": "Thể thao",
|
||||||
|
"for_kids": "Dành cho trẻ em",
|
||||||
|
"news": "Tin tức"
|
||||||
|
},
|
||||||
|
"jellyseerr": {
|
||||||
|
"confirm": "Xác nhận",
|
||||||
|
"cancel": "Hủy",
|
||||||
|
"yes": "Có",
|
||||||
|
"whats_wrong": "Có vấn đề gì?",
|
||||||
|
"issue_type": "Loại sự cố",
|
||||||
|
"select_an_issue": "Chọn sự cố",
|
||||||
|
"types": "Loại",
|
||||||
|
"describe_the_issue": "(tuỳ chọn) Mô tả sự cố...",
|
||||||
|
"submit_button": "Gửi",
|
||||||
|
"report_issue_button": "Báo lỗi",
|
||||||
|
"request_button": "Yêu cầu",
|
||||||
|
"are_you_sure_you_want_to_request_all_seasons": "Chắc chắn muốn yêu cầu tất cả các mùa?",
|
||||||
|
"failed_to_login": "Đăng nhập thất bại",
|
||||||
|
"cast": "Diễn viên",
|
||||||
|
"details": "Chi tiết",
|
||||||
|
"status": "Trạng thái",
|
||||||
|
"original_title": "Tiêu đề gốc",
|
||||||
|
"series_type": "Loại chương trình",
|
||||||
|
"release_dates": "Ngày phát hành",
|
||||||
|
"first_air_date": "Phát sóng lần đầu",
|
||||||
|
"next_air_date": "Phát sóng tiếp theo",
|
||||||
|
"revenue": "Doanh thu",
|
||||||
|
"budget": "Ngân sách",
|
||||||
|
"original_language": "Ngôn ngữ gốc",
|
||||||
|
"production_country": "Quốc gia sản xuất",
|
||||||
|
"studios": "Hãng sản xuất",
|
||||||
|
"network": "Đài phát sóng",
|
||||||
|
"currently_streaming_on": "Đang phát trên",
|
||||||
|
"advanced": "Nâng cao",
|
||||||
|
"request_as": "Yêu cầu dưới tên",
|
||||||
|
"tags": "Thẻ",
|
||||||
|
"quality_profile": "Hồ sơ chất lượng",
|
||||||
|
"root_folder": "Thư mục gốc",
|
||||||
|
"season_all": "Toàn bộ mùa",
|
||||||
|
"season_number": "Mùa {{season_number}}",
|
||||||
|
"number_episodes": "{{episode_number}} tập",
|
||||||
|
"born": "Ngày sinh",
|
||||||
|
"appearances": "Lần xuất hiện",
|
||||||
|
"toasts": {
|
||||||
|
"jellyseer_does_not_meet_requirements": "Máy chủ Jellyseerr không đạt yêu cầu tối thiểu! Vui lòng cập nhật lên ít nhất 2.0.0",
|
||||||
|
"jellyseerr_test_failed": "Kiểm tra Jellyseerr thất bại. Vui lòng thử lại.",
|
||||||
|
"failed_to_test_jellyseerr_server_url": "Không thể kiểm tra URL Jellyseerr",
|
||||||
|
"issue_submitted": "Đã gửi báo lỗi!",
|
||||||
|
"requested_item": "Đã yêu cầu {{item}}!",
|
||||||
|
"you_dont_have_permission_to_request": "Bạn không có quyền để yêu cầu!",
|
||||||
|
"something_went_wrong_requesting_media": "Có lỗi khi thực hiện yêu cầu!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"home": "Trang chính",
|
||||||
|
"search": "Tìm kiếm",
|
||||||
|
"library": "Thư viện",
|
||||||
|
"custom_links": "Liên kết tùy chỉnh",
|
||||||
|
"favorites": "Yêu thích"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,6 +139,7 @@
|
|||||||
"select_liraries_you_want_to_hide": "選擇您想從媒體庫頁面和主頁隱藏的媒體庫。",
|
"select_liraries_you_want_to_hide": "選擇您想從媒體庫頁面和主頁隱藏的媒體庫。",
|
||||||
"disable_haptic_feedback": "禁用觸覺回饋",
|
"disable_haptic_feedback": "禁用觸覺回饋",
|
||||||
"default_quality": "預設品質",
|
"default_quality": "預設品質",
|
||||||
|
"max_auto_play_episode_count": "自動播放劇集的最大次數",
|
||||||
"disabled": "已停用"
|
"disabled": "已停用"
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
@@ -228,6 +229,10 @@
|
|||||||
"invalid_url": "無效的 URL"
|
"invalid_url": "無效的 URL"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sessions": {
|
||||||
|
"title": "會話",
|
||||||
|
"no_active_sessions": "沒有使用中的會話"
|
||||||
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "下載",
|
"downloads_title": "下載",
|
||||||
"tvseries": "電視劇",
|
"tvseries": "電視劇",
|
||||||
|
|||||||
@@ -237,25 +237,38 @@ const loadSettings = (): Partial<Settings> => {
|
|||||||
return loadedValues;
|
return loadedValues;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load settings:", error);
|
console.error("Failed to load settings:", error);
|
||||||
return defaultValues;
|
return {};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const EXCLUDE_FROM_SAVE = ["home"];
|
const EXCLUDE_FROM_SAVE = ["home"];
|
||||||
|
|
||||||
const saveSettings = (settings: Settings) => {
|
const saveSettings = (settings: Settings) => {
|
||||||
for (const key of Object.keys(settings)) {
|
try {
|
||||||
if (EXCLUDE_FROM_SAVE.includes(key)) {
|
for (const key of Object.keys(settings)) {
|
||||||
delete settings[key as keyof Settings];
|
if (EXCLUDE_FROM_SAVE.includes(key)) {
|
||||||
|
delete settings[key as keyof Settings];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
const jsonValue = JSON.stringify(settings);
|
||||||
|
storage.set("settings", jsonValue);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save settings:", error);
|
||||||
}
|
}
|
||||||
const jsonValue = JSON.stringify(settings);
|
|
||||||
storage.set("settings", jsonValue);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const settingsAtom = atom<Partial<Settings> | null>(null);
|
export const settingsAtom = atom<Partial<Settings> | null>(null);
|
||||||
export const pluginSettingsAtom = atom(
|
const loadPluginSettings = () => {
|
||||||
storage.get<PluginLockableSettings>(STREAMYFIN_PLUGIN_SETTINGS),
|
try {
|
||||||
|
return storage.get<PluginLockableSettings>(STREAMYFIN_PLUGIN_SETTINGS);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load plugin settings:", error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pluginSettingsAtom = atom<PluginLockableSettings | undefined>(
|
||||||
|
loadPluginSettings(),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const useSettings = () => {
|
export const useSettings = () => {
|
||||||
@@ -317,7 +330,7 @@ export const useSettings = () => {
|
|||||||
// If admin sets locked to false but provides a value,
|
// If admin sets locked to false but provides a value,
|
||||||
// use user settings first and fallback on admin setting if required.
|
// use user settings first and fallback on admin setting if required.
|
||||||
const settings: Settings = useMemo(() => {
|
const settings: Settings = useMemo(() => {
|
||||||
const unlockedPluginDefaults = {} as Settings;
|
const unlockedPluginDefaults: Partial<Settings> = {};
|
||||||
const overrideSettings = Object.entries(pluginSettings ?? {}).reduce<
|
const overrideSettings = Object.entries(pluginSettings ?? {}).reduce<
|
||||||
Partial<Settings>
|
Partial<Settings>
|
||||||
>((acc, [key, setting]) => {
|
>((acc, [key, setting]) => {
|
||||||
@@ -331,14 +344,12 @@ export const useSettings = () => {
|
|||||||
value !== undefined &&
|
value !== undefined &&
|
||||||
_settings?.[settingsKey] !== value
|
_settings?.[settingsKey] !== value
|
||||||
) {
|
) {
|
||||||
Object.assign(unlockedPluginDefaults, {
|
(unlockedPluginDefaults as any)[settingsKey] = value;
|
||||||
[settingsKey]: value,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.assign(acc, {
|
(acc as any)[settingsKey] = locked
|
||||||
[settingsKey]: locked ? value : (_settings?.[settingsKey] ?? value),
|
? value
|
||||||
});
|
: (_settings?.[settingsKey] ?? value);
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export const reportPlaybackProgress = async ({
|
|||||||
itemId,
|
itemId,
|
||||||
positionTicks,
|
positionTicks,
|
||||||
IsPaused = false,
|
IsPaused = false,
|
||||||
deviceProfile,
|
|
||||||
}: ReportPlaybackProgressParams): Promise<void> => {
|
}: ReportPlaybackProgressParams): Promise<void> => {
|
||||||
if (!api || !sessionId || !itemId || !positionTicks) {
|
if (!api || !sessionId || !itemId || !positionTicks) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
import { MMKV } from "react-native-mmkv";
|
import { MMKV } from "react-native-mmkv";
|
||||||
|
|
||||||
|
// Create a single MMKV instance following the official documentation
|
||||||
|
// https://github.com/mrousavy/react-native-mmkv
|
||||||
export const storage = new MMKV();
|
export const storage = new MMKV();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type {
|
|||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { MMKV } from "react-native-mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { writeToLog } from "./log";
|
import { writeToLog } from "./log";
|
||||||
|
|
||||||
interface IJobInput {
|
interface IJobInput {
|
||||||
@@ -173,8 +173,6 @@ export function saveDownloadItemInfoToDiskTmp(
|
|||||||
url: string,
|
url: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
try {
|
try {
|
||||||
const storage = new MMKV();
|
|
||||||
|
|
||||||
const downloadInfo = JSON.stringify({
|
const downloadInfo = JSON.stringify({
|
||||||
item,
|
item,
|
||||||
mediaSource,
|
mediaSource,
|
||||||
@@ -206,7 +204,6 @@ export function getDownloadItemInfoFromDiskTmp(itemId: string): {
|
|||||||
url: string;
|
url: string;
|
||||||
} | null {
|
} | null {
|
||||||
try {
|
try {
|
||||||
const storage = new MMKV();
|
|
||||||
const rawInfo = storage.getString(`tmp_download_info_${itemId}`);
|
const rawInfo = storage.getString(`tmp_download_info_${itemId}`);
|
||||||
|
|
||||||
if (rawInfo) {
|
if (rawInfo) {
|
||||||
@@ -227,7 +224,6 @@ export function getDownloadItemInfoFromDiskTmp(itemId: string): {
|
|||||||
*/
|
*/
|
||||||
export function deleteDownloadItemInfoFromDiskTmp(itemId: string): boolean {
|
export function deleteDownloadItemInfoFromDiskTmp(itemId: string): boolean {
|
||||||
try {
|
try {
|
||||||
const storage = new MMKV();
|
|
||||||
storage.delete(`tmp_download_info_${itemId}`);
|
storage.delete(`tmp_download_info_${itemId}`);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||