Compare commits

..

58 Commits

Author SHA1 Message Date
renovate[bot]
a29c37b1d4 fix(deps): update dependency @shopify/flash-list to v1.8.2 2025-06-04 12:06:12 +00:00
Gauvino
d6c7246cd1 fix: put @main instead of v8 to fix cache problem 2025-06-04 13:31:06 +02:00
Gauvain
973d226c49 feat: update bun version (#745) 2025-06-04 12:25:01 +02:00
Gauvain
dd849b532b refactor: fix the ios-build action (#742) 2025-06-04 11:54:01 +02:00
Fredrik Burmester
1a58df27d2 chore: version 2025-06-03 08:26:23 +02:00
Fredrik Burmester
68b5fe3599 chore 2025-06-03 08:20:43 +02:00
Fredrik Burmester
67f73bfa39 fix: format 2025-06-03 08:20:35 +02:00
Fredrik Burmester
5de7cab285 Merge branch 'master' into develop 2025-06-03 08:20:32 +02:00
Fredrik Burmester
67d39c39ea fix: android bug 2025-06-03 08:06:49 +02:00
Gauvain
9d8e227609 fix: remove description of pr in message (#737) 2025-06-02 16:28:28 +02:00
renovate[bot]
962323a75c chore(deps): update github/codeql-action action to v3.28.18 (#727)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-02 16:18:42 +02:00
Gauvain
fc23201b4f fix: biome check, remove spell-check (#731) 2025-06-02 16:17:34 +02:00
lance chant
f0519ea88d fix: tv home screen navigation (#732) 2025-06-02 15:15:31 +02:00
renovate[bot]
f9f21606ff chore(deps): pin dependencies (#722)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-02 14:18:04 +02:00
Gauvain
c4d026f4d8 fix: remove cache bun (#730) 2025-06-02 14:15:46 +02:00
Gauvain
577827303e fix: correct name of dictonnary and use correct version in action (#728) 2025-06-02 14:13:44 +02:00
Gauvain
3e0a1af9fa fix: fix error on pr title for renovate bot (#729) 2025-06-02 14:13:36 +02:00
lance chant
63bc806a06 fix: made tv os compile (#721) 2025-06-02 14:13:13 +02:00
Jaakko Rantamäki
f05496a458 feat: Adaptive icons for iOS 18 and Android (#606) 2025-06-02 14:04:41 +02:00
Gauvain
d3660b45b1 fix: rename merge conflict label (#725) 2025-06-02 13:52:12 +02:00
Sim
1b812ebed5 fix: Recently Added isn't updating correctly. (#686) 2025-06-02 13:21:50 +02:00
Chris
6703299da9 docs: fix typo in README.md (#719) 2025-06-02 13:17:37 +02:00
Kamil Kosek
80d63c0219 feat: remotecontrol (#705) 2025-06-02 13:16:15 +02:00
Nyanmisaka
c2f8145e74 fix: Fixed container name mp4 in transcoding profiles (#696) 2025-06-02 13:14:31 +02:00
Gauvain
ce00aeb5f1 feat(ci/cd): Add Android builds and quality checks (#694) 2025-06-02 13:14:20 +02:00
Fredrik Burmester
5899cc8625 chore: version code 2025-05-30 21:00:02 +02:00
sarendsen
90217bb495 fix: error race conditions 2025-05-29 16:39:46 +02:00
lostb1t
16e88cca8c fix: error race conditions 2025-05-29 16:04:22 +02:00
lostb1t
e8e62061ae fix: remove unwanted detail calls on search (#707) 2025-05-28 14:24:22 +02:00
retardgerman
3adc4d2a21 fix(lang): uk.json 2025-05-19 13:44:19 +02:00
Chris
185524c06c feat(lang): add Klingon and Esperanto localization support (#672) 2025-05-19 12:39:02 +02:00
Simon Eklundh
6a208ee201 fix: improve readme to reduce the questions we tend to receive rather… (#699) 2025-05-18 20:36:08 +02:00
Ahmed Sbai
99938ddf5a feat: add "Are you still watching" modal overlay with configurable options (#663)
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2025-05-18 09:21:50 +02:00
lostb1t
963a54a36c fix: cancel for direct downloads 2025-05-14 21:18:48 +02:00
sarendsen
e939c9b933 Revert "style: horizontal width"
This reverts commit 31f662a582.
2025-05-14 21:07:18 +02:00
lostb1t
2ffd569bba chore: fix build 2025-05-14 19:42:56 +02:00
storm1er
c8ea494d6f fix: downgrade expo-sharing version until expo 53 (#688) 2025-05-14 19:18:46 +02:00
Danylo Kozhushko
577a61a452 fix: Fixed Ukrainian translation filename and also some typos (#682) 2025-05-14 19:09:54 +02:00
retardgerman
a731c4eebd fix: remove Feature that doesn’t exist. 2025-05-12 20:55:37 +02:00
Fredrik Burmester
8a664757b8 fix: android popup crash patch 2025-05-05 11:19:06 +02:00
sarendsen
655a78900d fix: try to enable ios background downloads plugin 2025-05-04 18:38:27 +02:00
sarendsen
87a33af8d1 fix: restore downloads if missing 2025-05-04 18:12:16 +02:00
sarendsen
36b1c48fdd fix: use ts for downloads 2025-05-04 12:50:21 +02:00
lostb1t
0454ba9f29 Update DownloadProvider.tsx 2025-05-04 12:01:51 +02:00
lostb1t
b55ed6349c Update DownloadProvider.tsx 2025-05-04 11:56:47 +02:00
Ryan
0c34add45a fix: update search functionality to set text in search bar on press (#669) 2025-05-04 11:48:53 +02:00
lostb1t
1c1345a3b7 feat: move to custom download handler with background download support (#675) 2025-05-04 11:46:34 +02:00
Chris
9f706a348e chore: Update README.md - Sessions View (#673) 2025-05-03 17:29:51 +02:00
sarendsen
f4750e781d refactor: getstreamurl 2025-05-02 19:02:35 +02:00
lance chant
0b574cc047 fix: dolby vision on supported devices, specifically profile 5 (#660) 2025-05-01 12:11:29 +02:00
Alec Warren
4a816470d1 feat: improve jellyseer item page buttons (#634) 2025-04-29 18:40:43 +02:00
Ryan
0d43b57f55 fix: improve empty state layout in library view (#665) 2025-04-28 18:11:24 +02:00
sarendsen
31f662a582 style: horizontal width 2025-04-21 12:28:12 +02:00
Alex
23e0ec9774 Remove Alamofire (#656) 2025-04-20 00:25:51 +10:00
Alex
d6ac8569a8 Fix/external subtitle support vlc3 (#655) 2025-04-20 00:25:35 +10:00
Fredrik Burmester
205715ae29 chore 2025-03-25 13:13:46 +01:00
Fredrik Burmester
ffbaaa81a8 Merge branch 'develop' 2025-03-19 11:33:51 +01:00
Chris
7201be6f02 Update README.md (#605) 2025-03-14 14:47:09 +01:00
87 changed files with 3812 additions and 4081 deletions

79
.github/workflows/build-android.yml vendored Normal file
View File

@@ -0,0 +1,79 @@
name: 🤖 Android APK Build
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
workflow_dispatch:
pull_request:
branches: [develop, master]
push:
branches: [develop, master]
jobs:
build:
runs-on: ubuntu-24.04
name: 🏗️ Build Android APK
permissions:
contents: read
steps:
- name: 📥 Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: '1.2.15'
- name: ☕ Setup JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: 'zulu'
java-version: '17'
- name: 💾 Cache Bun dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache-
- name: 📦 Install dependencies
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 💾 Cache Android dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: |
android/.gradle
key: ${{ runner.os }}-android-deps-${{ hashFiles('android/**/build.gradle') }}
restore-keys: |
${{ runner.os }}-android-deps-
- name: 🛠️ Generate project files
run: bun run prebuild
- name: 🚀 Build APK via Bun
run: bun run build:android:local
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: streamyfin-apk-${{ github.sha }}-${{ env.DATE_TAG }}
path: |
android/app/build/outputs/apk/release/*.apk
android/app/build/outputs/bundle/release/*.aab
retention-days: 7

View File

@@ -1,49 +0,0 @@
name: Automatic Build and Deploy
on:
workflow_dispatch:
push:
branches:
- main
jobs:
build:
runs-on: macos-15
name: Build IOS
steps:
- uses: actions/checkout@v2
name: Check out repository
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: |
bun i && bun run submodule-reload
npx expo prebuild
- uses: sparkfabrik/ios-build-action@v2.3.0
with:
upload-to-testflight: false
increment-build-number: false
build-pods: true
pods-path: "ios/Podfile"
configuration: Release
# Change later to app-store if wanted
export-method: appstore
#export-method: ad-hoc
workspace-path: "ios/Streamyfin.xcodeproj/project.xcworkspace/"
project-path: "ios/Streamyfin.xcodeproj"
scheme: Streamyfin
apple-key-id: ${{ secrets.APPLE_KEY_ID }}
apple-key-issuer-id: ${{ secrets.APPLE_KEY_ISSUER_ID }}
apple-key-content: ${{ secrets.APPLE_KEY_CONTENT }}
team-id: ${{ secrets.TEAM_ID }}
team-name: ${{ secrets.TEAM_NAME }}
#match-password: ${{ secrets.MATCH_PASSWORD }}
#match-git-url: ${{ secrets.MATCH_GIT_URL }}
#match-git-basic-authorization: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
#match-build-type: "appstore"
#browserstack-upload: true
#browserstack-username: ${{ secrets.BROWSERSTACK_USERNAME }}
#browserstack-access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
#fastlane-env: stage
ios-app-id: com.stetsed.teststreamyfin
output-path: build-${{ github.sha }}.ipa

70
.github/workflows/build-ios.yml vendored Normal file
View File

@@ -0,0 +1,70 @@
name: 🤖 iOS IPA Build
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
workflow_dispatch:
pull_request:
branches: [develop, master]
push:
branches: [develop, master]
jobs:
build:
runs-on: macos-15
name: 🏗️ Build iOS IPA
permissions:
contents: read
steps:
- name: 📥 Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: '1.2.15'
- name: 💾 Cache Bun dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache-
- name: 📦 Install & Prepare
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 🛠️ Generate project files
run: bun run prebuild
- name: 🏗 Setup EAS
uses: expo/expo-github-action@main
with:
eas-version: 16.7.1
token: ${{ secrets.EXPO_TOKEN }}
- name: 🏗️ Build iOS app
run: |
eas build -p ios --local --non-interactive
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: streamyfin-ipa-${{ github.sha }}-${{ env.DATE_TAG }}
path: |
build-*.ipa
retention-days: 7

46
.github/workflows/check-lockfile.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: 🔒 Lockfile Consistency Check
on:
pull_request:
branches: [develop, master]
push:
branches: [develop, master]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check-lockfile:
name: 🔍 Check bun.lock and package.json consistency
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: 📥 Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: '1.2.15'
- name: 💾 Cache Bun dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
- name: 🛡️ Verify lockfile consistency
run: |
set -euxo pipefail
echo "➡️ Checking for discrepancies between bun.lock and package.json..."
bun install --frozen-lockfile --dry-run --ignore-scripts
echo "✅ Lockfile is consistent with package.json!"

43
.github/workflows/ci-codeql.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: 🛡️ CodeQL Analysis
on:
push:
branches: [master, develop]
pull_request:
branches: [master, develop]
schedule:
- cron: '24 2 * * *'
jobs:
analyze:
name: 🔎 Analyze with CodeQL
runs-on: ubuntu-24.04
permissions:
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript-typescript' ]
steps:
- name: 📥 Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
fetch-depth: 0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18

24
.github/workflows/conflict.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: 🏷🔀Merge Conflict Labeler
on:
push:
branches: [develop]
pull_request_target:
branches: [develop]
types: [synchronize]
jobs:
label:
name: 🏷️ Labeling Merge Conflicts
runs-on: ubuntu-24.04
if: ${{ github.repository == 'streamyfin/streamyfin' }}
permissions:
contents: read
pull-requests: write
steps:
- name: 🚩 Apply merge conflict label
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
with:
dirtyLabel: 'merge-conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
repoToken: '${{ secrets.GITHUB_TOKEN }}'

View File

@@ -1,41 +0,0 @@
name: "Lint PR"
on:
pull_request_target:
types:
- opened
- edited
- synchronize
- reopened
permissions:
pull-requests: write
jobs:
main:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@v2
if: always() && (steps.lint_pr_title.outputs.error_message != null)
with:
header: pr-title-lint-error
message: |
Hey there and thank you for opening this pull request! 👋🏼
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
Details:
```
${{ steps.lint_pr_title.outputs.error_message }}
```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@v2
with:
header: pr-title-lint-error
delete: true

View File

@@ -1,28 +0,0 @@
name: Lint
on:
pull_request:
branches: [ develop, master ]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20.x'
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Run linting checks
run: bun run check

95
.github/workflows/linting.yml vendored Normal file
View File

@@ -0,0 +1,95 @@
name: 🚦 Security & Quality Gate
on:
pull_request_target:
types: [opened, edited, synchronize, reopened]
branches: [develop, master]
workflow_dispatch:
permissions:
contents: read
jobs:
validate_pr_title:
name: "📝 Validate PR Title"
runs-on: ubuntu-24.04
permissions:
pull-requests: write
contents: read
steps:
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
if: always() && (steps.lint_pr_title.outputs.error_message != null)
with:
header: pr-title-lint-error
message: |
Hey there and thank you for opening this pull request! 👋🏼
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
**Error details:**
```
${{ steps.lint_pr_title.outputs.error_message }}
```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
with:
header: pr-title-lint-error
delete: true
dependency-review:
name: 🔍 Vulnerable Dependencies
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: Dependency Review
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
with:
fail-on-severity: high
deny-licenses: GPL-3.0, AGPL-3.0
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
code_quality:
name: "🔍 Lint & Test (${{ matrix.command }})"
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
command:
- "lint"
- "check"
steps:
- name: "📥 Checkout PR code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
submodules: recursive
fetch-depth: 0
- name: "🟢 Setup Node.js"
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '20.x'
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: '1.2.15'
- name: "📦 Install dependencies"
run: bun install --frozen-lockfile
- name: "🚨 Run ${{ matrix.command }}"
run: bun run ${{ matrix.command }}

View File

@@ -1,39 +0,0 @@
name: Handle Stale Issues
on:
schedule:
- cron: "30 1 * * *" # Runs at 1:30 UTC every day
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:
# Issue specific settings
days-before-issue-stale: 90
days-before-issue-close: 7
stale-issue-label: "stale"
stale-issue-message: |
This issue has been automatically marked as stale because it has had no activity in the last 30 days.
If this issue is still relevant, please leave a comment to keep it open.
Otherwise, it will be closed in 7 days if no further activity occurs.
Thank you for your contributions!
close-issue-message: |
This issue has been automatically closed because it has been inactive for 7 days since being marked as stale.
If you believe this issue is still relevant, please feel free to reopen it and add a comment explaining the current status.
# Pull request settings (disabled)
days-before-pr-stale: -1
days-before-pr-close: -1
# Other settings
repo-token: ${{ secrets.GITHUB_TOKEN }}
operations-per-run: 100
exempt-issue-labels: "Roadmap v1,help needed,enhancement"

View File

@@ -1,18 +0,0 @@
name: Discord Pull Request Notification
on:
pull_request:
types: [opened, reopened]
jobs:
notify:
runs-on: ubuntu-latest
steps:
- uses: joelwmale/webhook-action@master
with:
url: ${{ secrets.DISCORD_WEBHOOK_URL }}
body: |
{
"content": "New Pull Request: ${{ github.event.pull_request.title }}\nBy: ${{ github.event.pull_request.user.login }}\n\n${{ github.event.pull_request.html_url }}",
"avatar_url": "https://avatars.githubusercontent.com/u/193271640"
}

23
.github/workflows/notification.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: 🛎️ Discord Pull Request Notification
on:
pull_request:
types: [opened, reopened]
branches: [develop]
jobs:
notify:
runs-on: ubuntu-24.04
steps:
- name: 🛎️ Notify Discord
uses: Ilshidur/action-discord@0c4b27844ba47cb1c7bee539c8eead5284ce9fa9 # 0.3.2
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640
with:
args: |
📢 New Pull Request in **${{ github.repository }}**
**Title:** ${{ github.event.pull_request.title }}
**By:** ${{ github.event.pull_request.user.login }}
**Branch:** ${{ github.event.pull_request.head.ref }}
🔗 ${{ github.event.pull_request.html_url }}

49
.github/workflows/stale.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: 🕒 Handle Stale Issues
on:
schedule:
# Runs daily at 1:30 AM UTC (3:30 AM CEST - France time)
- cron: "30 1 * * *"
jobs:
stale-issues:
name: 🗑️ Cleanup Stale Issues
runs-on: ubuntu-24.04
permissions:
issues: write
pull-requests: write
steps:
- name: 🔄 Mark/Close Stale Issues
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
with:
# Global settings
repo-token: ${{ secrets.GITHUB_TOKEN }}
operations-per-run: 500 # Increase if you have >1000 issues
log-level: debug
# Issue configuration
days-before-issue-stale: 90
days-before-issue-close: 7
stale-issue-label: "stale"
exempt-issue-labels: "Roadmap v1,help needed,enhancement"
# Notifications messages
stale-issue-message: |
⏳ This issue has been automatically marked as **stale** because it has had no activity for 90 days.
**Next steps:**
- If this is still relevant, add a comment to keep it open
- Otherwise, it will be closed in 7 days
Thank you for your contributions! 🙌
close-issue-message: |
🚮 This issue has been automatically closed due to inactivity (7 days since being marked stale).
**Need to reopen?**
Click "Reopen" and add a comment explaining why this should stay open.
# Disable PR handling
days-before-pr-stale: -1
days-before-pr-close: -1

6
.gitignore vendored
View File

@@ -11,7 +11,6 @@ npm-debug.*
web-build/
modules/vlc-player/android/build
modules/vlc-player/android/.gradle
bun.lockb
# macOS
.DS_Store
@@ -20,9 +19,7 @@ expo-env.d.ts
Streamyfin.app
build-*
*.mp4
build-*
Streamyfin.app
package-lock.json
@@ -47,4 +44,5 @@ credentials.json
modules/hls-downloader/android/build
streamyfin-4fec1-firebase-adminsdk.json
.env
.env.local
.env.local
*.aab

View File

@@ -9,7 +9,7 @@
},
"prettier.printWidth": 120,
"[swift]": {
"editor.defaultFormatter": "swiftlang.swift-vscode"
"editor.defaultFormatter": "sswg.swift-lang"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome",

View File

@@ -2,7 +2,7 @@
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
Welcome to Streamyfin, a simple and user-friendly Jellyfin video streaming client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
<div style="display: flex; flex-direction: row; gap: 8px">
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
@@ -15,11 +15,11 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
- 🚀 **Skip Intro / Credits Support**
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
- 🤖 **Jellyseerr integration**: Request media directly in the app.
- 👁️ **Sessions View:** View all active sessions currently streaming on your server.
## 🧪 Experimental Features
@@ -31,16 +31,16 @@ Downloading works by using ffmpeg to convert an HLS stream into a video file on
### Chromecast
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features.
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos, but we're working on adding support for subtitles and other features.
### Streamyfin Plugin
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that hold all settings for the client Streamyfin. This allows you to syncronize settings accross all your users, like:
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
- Auto log in to Jellyseerr without the user having to do anythin
- Auto log in to Jellyseerr without the user having to do anything
- Choose the default languages
- Set download method and search provider
- Customize homescreen
- Customize home screen
- And more...
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
@@ -66,7 +66,7 @@ Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/r
### Beta testing
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the ⁠🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the ⁠🧪-public-beta channel on Discord and I'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that I can manually add you.
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
@@ -118,6 +118,13 @@ If you have questions or need support, feel free to reach out:
- GitHub Issues: Report bugs or request features here.
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
## FAQ
1. Q: Why can't I see my libraries in Streamyfin?
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only.
2. Q: Why can't I see my music library?
A: We don't currently support music and are unlikely to support music in the near future.
## 📝 Credits
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.

View File

@@ -27,13 +27,19 @@
"usesNonExemptEncryption": false
},
"supportsTablet": true,
"bundleIdentifier": "com.fredrikburmester.streamyfin"
"bundleIdentifier": "com.fredrikburmester.streamyfin",
"icon": {
"dark": "./assets/images/icon-plain.png",
"light": "./assets/images/icon-ios-light.png",
"tinted": "./assets/images/icon-ios-tinted.png"
}
},
"android": {
"jsEngine": "hermes",
"versionCode": 54,
"versionCode": 56,
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive_icon.png",
"foregroundImage": "./assets/images/icon-plain.png",
"monochromeImage": "./assets/images/icon-mono.png",
"backgroundColor": "#464646"
},
"package": "com.fredrikburmester.streamyfin",
@@ -48,7 +54,6 @@
"@react-native-tvos/config-tv",
"expo-router",
"expo-font",
"@config-plugins/ffmpeg-kit-react-native",
[
"react-native-video",
{
@@ -113,6 +118,7 @@
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
["./plugins/withRNBackgroundDownloader.js"],
[
"expo-splash-screen",
{
@@ -127,6 +133,12 @@
"icon": "./assets/images/notification.png",
"color": "#9333EA"
}
],
[
"react-native-google-cast",
{
"useDefaultExpandedMediaControls": true
}
]
],
"experiments": {

View File

@@ -18,12 +18,18 @@ import {
HardwareAccelerationType,
type SessionInfoDto,
} from "@jellyfin/sdk/lib/generated-client";
import {
GeneralCommandType,
PlaystateCommand,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { get } from "lodash";
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { TouchableOpacity, View } from "react-native";
export default function page() {
const { sessions, isLoading } = useSessions({} as useSessionsProps);
@@ -110,6 +116,77 @@ const SessionCard = ({ session }: SessionCardProps) => {
},
});
// Handle session controls
const [isControlLoading, setIsControlLoading] = useState<
Record<string, boolean>
>({});
const handleSystemCommand = async (command: GeneralCommandType) => {
if (!api || !session.Id) return false;
setIsControlLoading({ ...isControlLoading, [command]: true });
try {
getSessionApi(api).sendSystemCommand({
sessionId: session.Id,
command,
});
return true;
} catch (error) {
console.error(`Error sending ${command} command:`, error);
return false;
} finally {
setIsControlLoading({ ...isControlLoading, [command]: false });
}
};
const handlePlaystateCommand = async (command: PlaystateCommand) => {
if (!api || !session.Id) return false;
setIsControlLoading({ ...isControlLoading, [command]: true });
try {
getSessionApi(api).sendPlaystateCommand({
sessionId: session.Id,
command,
});
return true;
} catch (error) {
console.error(`Error sending playstate ${command} command:`, error);
return false;
} finally {
setIsControlLoading({ ...isControlLoading, [command]: false });
}
};
const handlePlayPause = async () => {
console.log("handlePlayPause");
await handlePlaystateCommand(PlaystateCommand.PlayPause);
};
const handleStop = async () => {
await handlePlaystateCommand(PlaystateCommand.Stop);
};
const handlePrevious = async () => {
await handlePlaystateCommand(PlaystateCommand.PreviousTrack);
};
const handleNext = async () => {
await handlePlaystateCommand(PlaystateCommand.NextTrack);
};
const handleToggleMute = async () => {
await handleSystemCommand(GeneralCommandType.ToggleMute);
};
const handleVolumeUp = async () => {
await handleSystemCommand(GeneralCommandType.VolumeUp);
};
const handleVolumeDown = async () => {
await handleSystemCommand(GeneralCommandType.VolumeDown);
};
useInterval(tick, 1000);
return (
@@ -181,6 +258,107 @@ const SessionCard = ({ session }: SessionCardProps) => {
}}
/>
</View>
{/* Session controls */}
<View className='flex flex-row mt-2 space-x-4 justify-center'>
<TouchableOpacity
onPress={handlePrevious}
disabled={isControlLoading[PlaystateCommand.PreviousTrack]}
style={{
opacity: isControlLoading[PlaystateCommand.PreviousTrack]
? 0.5
: 1,
}}
>
<MaterialCommunityIcons
name='skip-previous'
size={24}
color='white'
/>
</TouchableOpacity>
<TouchableOpacity
onPress={handlePlayPause}
disabled={isControlLoading[PlaystateCommand.PlayPause]}
style={{
opacity: isControlLoading[PlaystateCommand.PlayPause]
? 0.5
: 1,
}}
>
{session.PlayState?.IsPaused ? (
<Ionicons name='play' size={24} color='white' />
) : (
<Ionicons name='pause' size={24} color='white' />
)}
</TouchableOpacity>
<TouchableOpacity
onPress={handleStop}
disabled={isControlLoading[PlaystateCommand.Stop]}
style={{
opacity: isControlLoading[PlaystateCommand.Stop] ? 0.5 : 1,
}}
>
<Ionicons name='stop' size={24} color='white' />
</TouchableOpacity>
<TouchableOpacity
onPress={handleNext}
disabled={isControlLoading[PlaystateCommand.NextTrack]}
style={{
opacity: isControlLoading[PlaystateCommand.NextTrack]
? 0.5
: 1,
}}
>
<MaterialCommunityIcons
name='skip-next'
size={24}
color='white'
/>
</TouchableOpacity>
<TouchableOpacity
onPress={handleVolumeDown}
disabled={isControlLoading[GeneralCommandType.VolumeDown]}
style={{
opacity: isControlLoading[GeneralCommandType.VolumeDown]
? 0.5
: 1,
}}
>
<Ionicons name='volume-low' size={24} color='white' />
</TouchableOpacity>
<TouchableOpacity
onPress={handleToggleMute}
disabled={isControlLoading[GeneralCommandType.ToggleMute]}
style={{
opacity: isControlLoading[GeneralCommandType.ToggleMute]
? 0.5
: 1,
}}
>
<Ionicons
name='volume-mute'
size={24}
color={session.PlayState?.IsMuted ? "red" : "white"}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={handleVolumeUp}
disabled={isControlLoading[GeneralCommandType.VolumeUp]}
style={{
opacity: isControlLoading[GeneralCommandType.VolumeUp]
? 0.5
: 1,
}}
>
<Ionicons name='volume-high' size={24} color='white' />
</TouchableOpacity>
</View>
</View>
</View>
</View>

View File

@@ -30,7 +30,7 @@ import {
} from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -46,6 +46,7 @@ const Page: React.FC = () => {
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
const { t } = useTranslation();
const router = useRouter();
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } =
params as unknown as {
@@ -236,30 +237,65 @@ const Page: React.FC = () => {
}}
/>
</View>
<View className='mb-4'>
<View>
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
</View>
{isLoading || isFetching ? (
<Button loading={true} disabled={true} color='purple' />
<Button
loading={true}
disabled={true}
color='purple'
className='mt-4'
/>
) : canRequest ? (
<Button color='purple' onPress={request}>
<Button color='purple' onPress={request} className='mt-4'>
{t("jellyseerr.request_button")}
</Button>
) : (
<Button
className='bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
color='transparent'
onPress={() => bottomSheetModalRef?.current?.present()}
iconLeft={
<Ionicons name='warning-outline' size={24} color='white' />
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
{t("jellyseerr.report_issue_button")}
</Button>
details?.mediaInfo?.jellyfinMediaId && (
<View className='flex flex-row space-x-2 mt-4'>
<Button
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
color='transparent'
onPress={() => bottomSheetModalRef?.current?.present()}
iconLeft={
<Ionicons
name='warning-outline'
size={20}
color='white'
/>
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>
{t("jellyseerr.report_issue_button")}
</Text>
</Button>
<Button
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
onPress={() => {
const url =
mediaType === MediaType.MOVIE
? `/(auth)/(tabs)/(search)/items/page?id=${details?.mediaInfo.jellyfinMediaId}`
: `/(auth)/(tabs)/(search)/series/${details?.mediaInfo.jellyfinMediaId}`;
// @ts-expect-error
router.push(url);
}}
iconLeft={
<Ionicons name='play-outline' size={20} color='white' />
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>Play</Text>
</Button>
</View>
)
)}
<OverviewText text={result.overview} className='mt-4' />
</View>

View File

@@ -367,7 +367,15 @@ const Page = () => {
className='mr-1'
id={libraryId}
queryKey='sortBy'
queryFn={async () => sortOptions.map((s) => s.key)}
queryFn={async () =>
sortOptions
.filter(
(s) =>
library?.CollectionType !== "movies" ||
s.key !== SortByOption.DateLastContentAdded,
)
.map((s) => s.key)
}
set={setSortBy}
values={sortBy}
title={t("library.filters.sort_by")}
@@ -433,15 +441,6 @@ const Page = () => {
</View>
);
if (flatData.length === 0)
return (
<View className='h-full w-full flex justify-center items-center'>
<Text className='text-lg text-neutral-500'>
{t("library.no_items_found")}
</Text>
</View>
);
return (
<FlashList
key={orientation}

View File

@@ -331,7 +331,7 @@ export default function search() {
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
<SearchItemWrapper
header={t("search.movies")}
ids={movies?.map((m) => m.Id!)}
items={movies}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
@@ -349,7 +349,7 @@ export default function search() {
)}
/>
<SearchItemWrapper
ids={series?.map((m) => m.Id!)}
items={series}
header={t("search.series")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
@@ -368,7 +368,7 @@ export default function search() {
)}
/>
<SearchItemWrapper
ids={episodes?.map((m) => m.Id!)}
items={episodes}
header={t("search.episodes")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
@@ -382,7 +382,7 @@ export default function search() {
)}
/>
<SearchItemWrapper
ids={collections?.map((m) => m.Id!)}
items={collections}
header={t("search.collections")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
@@ -398,7 +398,7 @@ export default function search() {
)}
/>
<SearchItemWrapper
ids={actors?.map((m) => m.Id!)}
items={actors}
header={t("search.actors")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
@@ -434,7 +434,10 @@ export default function search() {
<View className='mt-4 flex flex-col items-center space-y-2'>
{exampleSearches.map((e) => (
<TouchableOpacity
onPress={() => setSearch(e)}
onPress={() => {
setSearch(e);
searchBarRef.current?.setText(e);
}}
key={e}
className='mb-2'
>

View File

@@ -6,24 +6,18 @@ import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useHaptic } from "@/hooks/useHaptic";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import { MpvPlayerView, ProgressUpdatePayload, VlcPlayerView } from "@/modules";
// import type {
// PipStartedPayload,
// PlaybackStatePayload,
// ProgressUpdatePayload,
// VlcPlayerViewRef,
// } from "@/modules/VlcPlayer.types";
import { VlcPlayerView } from "@/modules";
import type {
MpvPlayerViewRef,
PipStartedPayload,
PlaybackStatePayload,
} from "@/modules/MpvPlayer.types";
ProgressUpdatePayload,
VlcPlayerViewRef,
} from "@/modules/VlcPlayer.types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log";
import native from "@/utils/profiles/native";
import generateDeviceProfile from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
import {
type BaseItemDto,
@@ -56,7 +50,7 @@ const downloadProvider = !Platform.isTV
: null;
export default function page() {
const videoRef = useRef<MpvPlayerViewRef>(null);
const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const { t } = useTranslation();
@@ -66,6 +60,7 @@ export default function page() {
const [showControls, _setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [isPipStarted, setIsPipStarted] = useState(false);
@@ -73,6 +68,10 @@ export default function page() {
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const VolumeManager = Platform.isTV
? null
: require("react-native-volume-manager");
let getDownloadedItem = null;
if (!Platform.isTV) {
getDownloadedItem = downloadProvider.useDownload();
@@ -138,11 +137,10 @@ export default function page() {
fetchedItem = res.data;
}
setItem(fetchedItem);
setItemStatus({ isLoading: false, isError: false });
} catch (error) {
console.error("Failed to fetch item:", error);
setItemStatus({ isLoading: false, isError: true });
} finally {
setItemStatus({ isLoading: false, isError: false });
}
};
@@ -165,6 +163,8 @@ export default function page() {
useEffect(() => {
const fetchStreamData = async () => {
setStreamStatus({ isLoading: true, isError: false });
const native = await generateDeviceProfile();
try {
let result: Stream | null = null;
if (offline && !Platform.isTV) {
@@ -198,11 +198,10 @@ export default function page() {
result = { mediaSource, sessionId, url };
}
setStream(result);
setStreamStatus({ isLoading: false, isError: false });
} catch (error) {
console.error("Failed to fetch stream:", error);
setStreamStatus({ isLoading: false, isError: true });
} finally {
setStreamStatus({ isLoading: false, isError: false });
}
};
fetchStreamData();
@@ -225,7 +224,7 @@ export default function page() {
setIsPlaying(!isPlaying);
if (isPlaying) {
await videoRef.current?.pause();
reportPlaybackStopped();
reportPlaybackProgress();
} else {
videoRef.current?.play();
await getPlaystateApi(api!).reportPlaybackStart({
@@ -245,7 +244,15 @@ export default function page() {
});
revalidateProgressCache();
}, [api, item, mediaSourceId, stream]);
}, [
api,
item,
mediaSourceId,
stream,
progress,
offline,
revalidateProgressCache,
]);
const stop = useCallback(() => {
reportPlaybackStopped();
@@ -271,7 +278,7 @@ export default function page() {
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId,
isMuted: false,
isMuted: isMuted,
canSeek: true,
repeatMode: RepeatMode.RepeatNone,
playbackOrder: PlaybackOrder.Default,
@@ -335,13 +342,84 @@ export default function page() {
return item?.UserData?.PlaybackPositionTicks
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
: 0;
}, [item]);
}, [item, offline]);
const volumeUpCb = useCallback(async () => {
if (Platform.isTV) return;
try {
const { volume: currentVolume } = await VolumeManager.getVolume();
const newVolume = Math.min(currentVolume + 0.1, 1.0);
await VolumeManager.setVolume(newVolume);
} catch (error) {
console.error("Error adjusting volume:", error);
}
}, []);
const [previousVolume, setPreviousVolume] = useState<number | null>(null);
const toggleMuteCb = useCallback(async () => {
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 () => {
if (Platform.isTV) return;
try {
const { volume: currentVolume } = await VolumeManager.getVolume();
const newVolume = Math.max(currentVolume - 0.1, 0); // Decrease by 10%
console.log(
"Volume Down",
Math.round(currentVolume * 100),
"→",
Math.round(newVolume * 100),
);
await VolumeManager.setVolume(newVolume);
} catch (error) {
console.error("Error adjusting volume:", error);
}
}, []);
const setVolumeCb = useCallback(async (newVolume: number) => {
if (Platform.isTV) return;
try {
const clampedVolume = Math.max(0, Math.min(newVolume, 100));
console.log("Setting volume to", clampedVolume);
await VolumeManager.setVolume(clampedVolume / 100);
} catch (error) {
console.error("Error setting volume:", error);
}
}, []);
useWebSocket({
isPlaying: isPlaying,
togglePlay: togglePlay,
stopPlayback: stop,
offline,
toggleMute: toggleMuteCb,
volumeUp: volumeUpCb,
volumeDown: volumeDownCb,
setVolume: setVolumeCb,
});
const onPlaybackStateChanged = useCallback(
@@ -397,23 +475,20 @@ export default function page() {
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
const initOptions = [
`--sub-text-scale=${settings.subtitleSize}`,
`--start=${startPosition}`,
];
// if (
// chosenSubtitleTrack &&
// (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
// ) {
// const finalIndex = notTranscoding
// ? allSubs.indexOf(chosenSubtitleTrack)
// : textSubs.indexOf(chosenSubtitleTrack);
// initOptions.push(`--sub-track=${finalIndex}`);
// }
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)}`);
// }
if (notTranscoding && chosenAudioTrack) {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
}
const [isMounted, setIsMounted] = useState(false);
@@ -431,7 +506,7 @@ export default function page() {
);
}
if (!item || !stream || itemStatus.isError || streamStatus.isError)
if (itemStatus.isError || streamStatus.isError)
return (
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
<Text className='text-white'>{t("player.error")}</Text>
@@ -452,7 +527,7 @@ export default function page() {
paddingRight: ignoreSafeAreas ? 0 : insets.right,
}}
>
<MpvPlayerView
<VlcPlayerView
ref={videoRef}
source={{
uri: stream?.url || "",
@@ -496,6 +571,7 @@ export default function page() {
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef?.current?.startPictureInPicture}
play={videoRef.current?.play}
pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo}

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

BIN
assets/images/icon-mono.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

View File

@@ -5,9 +5,8 @@
"name": "streamyfin",
"dependencies": {
"@bottom-tabs/react-navigation": "0.8.6",
"@config-plugins/ffmpeg-kit-react-native": "^9.0.0",
"@expo/config-plugins": "~9.0.15",
"@expo/react-native-action-sheet": "^4.1.0",
"@expo/react-native-action-sheet": "^4.1.1",
"@expo/vector-icons": "^14.0.4",
"@futurejj/react-native-visibility-sensor": "^1.3.10",
"@gorhom/bottom-sheet": "^5.1.0",
@@ -18,7 +17,7 @@
"@react-navigation/bottom-tabs": "^7.2.0",
"@react-navigation/material-top-tabs": "^7.1.0",
"@react-navigation/native": "^7.0.14",
"@shopify/flash-list": "1.7.3",
"@shopify/flash-list": "1.8.2",
"@tanstack/react-query": "^5.66.0",
"add": "^2.0.6",
"axios": "^1.7.9",
@@ -44,7 +43,7 @@
"expo-router": "~4.0.17",
"expo-screen-orientation": "~8.0.4",
"expo-sensors": "~14.0.2",
"expo-sharing": "~13.1.0",
"expo-sharing": "~13.0.1",
"expo-splash-screen": "~0.29.22",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.8",
@@ -58,7 +57,7 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"react-i18next": "^15.4.0",
"react-native": "npm:react-native-tvos@~0.77.0-0",
"react-native": "npm:react-native-tvos@~0.77.2-0",
"react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "0.8.6",
"react-native-circular-progress": "^1.4.1",
@@ -108,6 +107,7 @@
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "^19.0.0",
"@types/uuid": "^10.0.0",
"cross-env": "^7.0.3",
"husky": "^9.1.7",
"lint-staged": "^15.5.0",
"postinstall-postinstall": "^2.1.0",
@@ -399,8 +399,6 @@
"@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@0.8.6", "", { "dependencies": { "color": "^4.2.3" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-hLlyBAUz4ahaVK2Op2VcJeAkCSpm3KKho4IojkPyXsos4WEHtO44EYWC71TDbVGeOP5HQ9k7FSwAW3IiZs0wHw=="],
"@config-plugins/ffmpeg-kit-react-native": ["@config-plugins/ffmpeg-kit-react-native@9.0.0", "", { "dependencies": { "semver": "^7.3.5" }, "peerDependencies": { "expo": "^52" } }, "sha512-04bXwdq7pmUPoGqYV0YGsrW/8Db+TNicn2Hznb5t+Dl740z9QkNGP4A38y1Mdz7mCU2EW0riASwl/JTH+6rBvw=="],
"@dominicstop/ts-event-emitter": ["@dominicstop/ts-event-emitter@1.1.0", "", {}, "sha512-CcxmJIvUb1vsFheuGGVSQf4KdPZC44XolpUT34+vlal+LyQoBUOn31pjFET5M9ctOxEpt8xa0M3/2M7uUiAoJw=="],
"@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="],
@@ -439,7 +437,7 @@
"@expo/prebuild-config": ["@expo/prebuild-config@8.0.28", "", { "dependencies": { "@expo/config": "~10.0.10", "@expo/config-plugins": "~9.0.15", "@expo/config-types": "^52.0.4", "@expo/image-utils": "^0.6.5", "@expo/json-file": "^9.0.2", "@react-native/normalize-colors": "0.76.7", "debug": "^4.3.1", "fs-extra": "^9.0.0", "resolve-from": "^5.0.0", "semver": "^7.6.0", "xml2js": "0.6.0" } }, "sha512-SDDgCKKS1wFNNm3de2vBP8Q5bnxcabuPDE9Mnk9p7Gb4qBavhwMbAtrLcAyZB+WRb4QM+yan3z3K95vvCfI/+A=="],
"@expo/react-native-action-sheet": ["@expo/react-native-action-sheet@4.1.0", "", { "dependencies": { "@types/hoist-non-react-statics": "^3.3.1", "hoist-non-react-statics": "^3.3.0" }, "peerDependencies": { "react": ">=18.0.0" } }, "sha512-RILoWhREgjMdr1NUSmZa/cHg8onV2YPDAMOy0iIP1c3H7nT9QQZf5dQNHK8ehcLM82sarVxriBJyYSSHAx7j6w=="],
"@expo/react-native-action-sheet": ["@expo/react-native-action-sheet@4.1.1", "", { "dependencies": { "@types/hoist-non-react-statics": "^3.3.1", "hoist-non-react-statics": "^3.3.0" }, "peerDependencies": { "react": ">=18.0.0" } }, "sha512-4KRaba2vhqDRR7ObBj6nrD5uJw8ePoNHdIOMETTpgGTX7StUbrF4j/sfrP1YUyaPEa1P8FXdwG6pB+2WtrJd1A=="],
"@expo/rudder-sdk-node": ["@expo/rudder-sdk-node@1.1.1", "", { "dependencies": { "@expo/bunyan": "^4.0.0", "@segment/loosely-validate-event": "^2.0.0", "fetch-retry": "^4.1.1", "md5": "^2.2.1", "node-fetch": "^2.6.1", "remove-trailing-slash": "^0.1.0", "uuid": "^8.3.2" } }, "sha512-uy/hS/awclDJ1S88w9UGpc6Nm9XnNUjzOAAib1A3PVAnGQIwebg8DpFqOthFBTlZxeuV/BKbZ5jmTbtNZkp1WQ=="],
@@ -627,27 +625,27 @@
"@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.1", "", { "dependencies": { "getenv": "^1.0.0" }, "peerDependencies": { "expo": "^52" } }, "sha512-Le/5wGElcNarDcoafCbvk/HMxcG3s0/468xXMWqAsOtBhGAdGtyXtjWEgp/uEr4GgZJlEIdM3ZqiuB8P7p8sjw=="],
"@react-native-tvos/virtualized-lists": ["@react-native-tvos/virtualized-lists@0.77.0-0", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^18.2.6", "react": "*", "react-native-tvos": "*" }, "optionalPeers": ["@types/react"] }, "sha512-em0PMjOD8XQvlygbFoNT4R76rSIRxekZ9TL6EbTIC/kJUDrSPB3W9RafA6n6p4OLoWgEF7MIJ9W+zfibdiVXbw=="],
"@react-native-tvos/virtualized-lists": ["@react-native-tvos/virtualized-lists@0.77.2-0", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^18.2.6", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-9l51YsjgrUv6f3Q8bmQPIPRuID6gLfc29CjLLQ3+RIeHFF1xzT/xwOp0+s7JMhDdZOZ5mcn9RiN7BbmcPej08A=="],
"@react-native/assets-registry": ["@react-native/assets-registry@0.77.0", "", {}, "sha512-Ms4tYYAMScgINAXIhE4riCFJPPL/yltughHS950l0VP5sm5glbimn9n7RFn9Tc8cipX74/ddbk19+ydK2iDMmA=="],
"@react-native/assets-registry": ["@react-native/assets-registry@0.77.2", "", {}, "sha512-AcEhFjndzBWVVhaHaASk36vhA83iDVkQbFYb0D0vATzjuJ67vhhHVLae0+JtHl5jhghotUFDg4Vj/1QbZNDyyQ=="],
"@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.76.7", "", { "dependencies": { "@react-native/codegen": "0.76.7" } }, "sha512-+8H4DXJREM4l/pwLF/wSVMRzVhzhGDix5jLezNrMD9J1U1AMfV2aSkWA1XuqR7pjPs/Vqf6TaPL7vJMZ4LU05Q=="],
"@react-native/babel-preset": ["@react-native/babel-preset@0.76.7", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-arrow-functions": "^7.24.7", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-computed-properties": "^7.24.7", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-function-name": "^7.25.1", "@babel/plugin-transform-literals": "^7.25.2", "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-numeric-separator": "^7.24.7", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.25.2", "@babel/plugin-transform-react-jsx-self": "^7.24.7", "@babel/plugin-transform-react-jsx-source": "^7.24.7", "@babel/plugin-transform-regenerator": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-shorthand-properties": "^7.24.7", "@babel/plugin-transform-spread": "^7.24.7", "@babel/plugin-transform-sticky-regex": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/template": "^7.25.0", "@react-native/babel-plugin-codegen": "0.76.7", "babel-plugin-syntax-hermes-parser": "^0.25.1", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.14.0" } }, "sha512-/c5DYZ6y8tyg+g8tgXKndDT7mWnGmkZ9F+T3qNDfoE3Qh7ucrNeC2XWvU9h5pk8eRtj9l4SzF4aO1phzwoibyg=="],
"@react-native/codegen": ["@react-native/codegen@0.77.0", "", { "dependencies": { "@babel/parser": "^7.25.3", "glob": "^7.1.1", "hermes-parser": "0.25.1", "invariant": "^2.2.4", "jscodeshift": "^17.0.0", "nullthrows": "^1.1.1", "yargs": "^17.6.2" }, "peerDependencies": { "@babel/preset-env": "^7.1.6" } }, "sha512-rE9lXx41ZjvE8cG7e62y/yGqzUpxnSvJ6me6axiX+aDewmI4ZrddvRGYyxCnawxy5dIBHSnrpZse3P87/4Lm7w=="],
"@react-native/codegen": ["@react-native/codegen@0.77.2", "", { "dependencies": { "@babel/parser": "^7.25.3", "glob": "^7.1.1", "hermes-parser": "0.25.1", "invariant": "^2.2.4", "jscodeshift": "^17.0.0", "nullthrows": "^1.1.1", "yargs": "^17.6.2" }, "peerDependencies": { "@babel/preset-env": "^7.1.6" } }, "sha512-uJSGm9Sp9K5XAhb17cty6iOc2lZpORQKMpS61/B3gYwe9LNz9TJpcfq1L2+3Mv6lppqsulOH9+fslapo0OTfSQ=="],
"@react-native/community-cli-plugin": ["@react-native/community-cli-plugin@0.77.0", "", { "dependencies": { "@react-native/dev-middleware": "0.77.0", "@react-native/metro-babel-transformer": "0.77.0", "chalk": "^4.0.0", "debug": "^2.2.0", "invariant": "^2.2.4", "metro": "^0.81.0", "metro-config": "^0.81.0", "metro-core": "^0.81.0", "readline": "^1.3.0", "semver": "^7.1.3" }, "peerDependencies": { "@react-native-community/cli-server-api": "*" }, "optionalPeers": ["@react-native-community/cli-server-api"] }, "sha512-GRshwhCHhtupa3yyCbel14SlQligV8ffNYN5L1f8HCo2SeGPsBDNjhj2U+JTrMPnoqpwowPGvkCwyqwqYff4MQ=="],
"@react-native/community-cli-plugin": ["@react-native/community-cli-plugin@0.77.2", "", { "dependencies": { "@react-native/dev-middleware": "0.77.2", "@react-native/metro-babel-transformer": "0.77.2", "chalk": "^4.0.0", "debug": "^2.2.0", "invariant": "^2.2.4", "metro": "^0.81.3", "metro-config": "^0.81.3", "metro-core": "^0.81.3", "readline": "^1.3.0", "semver": "^7.1.3" }, "peerDependencies": { "@react-native-community/cli": "*" }, "optionalPeers": ["@react-native-community/cli"] }, "sha512-Dc93eXHhzhnRy+vF3wOdM8C4dplLpT7ItpUpYrDeA1ffHUImwWpcupB6vpX9+l3UaaJ1cPfdxTjB2d1ACVKOaA=="],
"@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.76.7", "", {}, "sha512-89ZtZXt7ZxE94i7T94qzZMhp4Gfcpr/QVpGqEaejAxZD+gvDCH21cYSF+/Rz2ttBazm0rk5MZ0mFqb0Iqp1jmw=="],
"@react-native/dev-middleware": ["@react-native/dev-middleware@0.76.7", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.76.7", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.2.0", "connect": "^3.6.5", "debug": "^2.2.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "selfsigned": "^2.4.1", "serve-static": "^1.13.1", "ws": "^6.2.3" } }, "sha512-Jsw8g9DyLPnR9yHEGuT09yHZ7M88/GL9CtU9WmyChlBwdXSeE3AmRqLegsV3XcgULQ1fqdemokaOZ/MwLYkjdA=="],
"@react-native/gradle-plugin": ["@react-native/gradle-plugin@0.77.0", "", {}, "sha512-rmfh93jzbndSq7kihYHUQ/EGHTP8CCd3GDCmg5SbxSOHAaAYx2HZ28ZG7AVcGUsWeXp+e/90zGIyfOzDRx0Zaw=="],
"@react-native/gradle-plugin": ["@react-native/gradle-plugin@0.77.2", "", {}, "sha512-M3kU6xnn/06CGdezd31wn64v/BuKdw19K3GjOcRe1L+zKYEeezRovEVgzCNsXLcNtXUfJvmrIN4uYnqmgrJGfg=="],
"@react-native/js-polyfills": ["@react-native/js-polyfills@0.77.0", "", {}, "sha512-kHFcMJVkGb3ptj3yg1soUsMHATqal4dh0QTGAbYihngJ6zy+TnP65J3GJq4UlwqFE9K1RZkeCmTwlmyPFHOGvA=="],
"@react-native/js-polyfills": ["@react-native/js-polyfills@0.77.2", "", {}, "sha512-qwKeYqRANL8CKzeVWOdhRZJ7LBqqoiXR+cb5yGwVKQxqesrx5Y7gYyq6GP1zRMnhv9iQAY7Rwub8TvDxi2YP6Q=="],
"@react-native/metro-babel-transformer": ["@react-native/metro-babel-transformer@0.77.0", "", { "dependencies": { "@babel/core": "^7.25.2", "@react-native/babel-preset": "0.77.0", "hermes-parser": "0.25.1", "nullthrows": "^1.1.1" } }, "sha512-19GfvhBRKCU3UDWwCnDR4QjIzz3B2ZuwhnxMRwfAgPxz7QY9uKour9RGmBAVUk1Wxi/SP7dLEvWnmnuBO39e2A=="],
"@react-native/metro-babel-transformer": ["@react-native/metro-babel-transformer@0.77.2", "", { "dependencies": { "@babel/core": "^7.25.2", "@react-native/babel-preset": "0.77.2", "hermes-parser": "0.25.1", "nullthrows": "^1.1.1" } }, "sha512-vSG1/d5peUo50aqaBbNnVGE5QxQTSY3j0OWmixfJqiX11wwO3tR2niKxH8OjB3WuSsROgJzosMe9kMsQJQ3ONA=="],
"@react-native/normalize-colors": ["@react-native/normalize-colors@0.76.7", "", {}, "sha512-ST1xxBuYVIXPdD81dR6+tzIgso7m3pa9+6rOBXTh5Xm7KEEFik7tnQX+GydXYMp3wr1gagJjragdXkPnxK6WNg=="],
@@ -683,7 +681,7 @@
"@segment/loosely-validate-event": ["@segment/loosely-validate-event@2.0.0", "", { "dependencies": { "component-type": "^1.2.1", "join-component": "^1.1.0" } }, "sha512-ZMCSfztDBqwotkl848ODgVcAmN4OItEWDCkshcKz0/W6gGSQayuuCtWV/MlodFivAZD793d6UgANd6wCXUfrIw=="],
"@shopify/flash-list": ["@shopify/flash-list@1.7.3", "", { "dependencies": { "recyclerlistview": "4.2.1", "tslib": "2.8.1" }, "peerDependencies": { "@babel/runtime": "*", "react": "*", "react-native": "*" } }, "sha512-RLhNptm02aqpqZvjj9pJPcU+EVYxOAJhPRCmDOaUbUP86+636w+plsbjpBPSYGvPZhPj56RtZ9FBlvolPeEmYA=="],
"@shopify/flash-list": ["@shopify/flash-list@1.8.2", "", { "dependencies": { "recyclerlistview": "4.2.3", "tslib": "2.8.1" }, "peerDependencies": { "@babel/runtime": "*", "react": "*", "react-native": "*" } }, "sha512-CJRkaMkwbDJtOyO9RgjXIXDKgIpgXPt2fncK7zcNvTPbvZHWnXcnS7Avl+TzAtvpPThV+fl3oxjnOYpszMil2A=="],
"@sideway/address": ["@sideway/address@4.1.5", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q=="],
@@ -987,6 +985,8 @@
"cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="],
"cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="],
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
@@ -1173,7 +1173,7 @@
"expo-haptics": ["expo-haptics@14.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-V81FZ7xRUfqM6uSI6FA1KnZ+QpEKnISqafob/xEfcx1ymwhm4V3snuLWWFjmAz+XaZQTqlYa8z3QbqEXz7G63w=="],
"expo-image": ["expo-image@2.0.7", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-kv40OIJOkItwznhdqFmKxTMC5O8GkpyTf8ng7Py4Hy6IBiH59dkeP6vUZQhzPhJOm5v1kZK4XldbskBosqzOug=="],
"expo-image": ["expo-image@2.0.6", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-NHpIZmGnrPbyDadil6eK+sUgyFMQfapEVb7YaGgxSFWBUQ1rSpjqdIQrCD24IZTO9uSH8V+hMh2ROxrAjAixzQ=="],
"expo-json-utils": ["expo-json-utils@0.14.0", "", {}, "sha512-xjGfK9dL0B1wLnOqNkX0jM9p48Y0I5xEPzHude28LY67UmamUyAACkqhZGaPClyPNfdzczk7Ej6WaRMT3HfXvw=="],
@@ -1201,7 +1201,7 @@
"expo-sensors": ["expo-sensors@14.0.2", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-nCb1Q3ctb0oVTZ9p6eFmQ2fINa6KoxXXIhagPpdN0qR82p00YosP27IuyxjVB3fnCJFeC4TffNxNjBxwAUk+nA=="],
"expo-sharing": ["expo-sharing@13.1.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-7O29Bdm95v6aBXBhrbKx9FBqL5loQcK0nvCMFSbZHMy1r7Z6vb6sTMsaGbvknfOH+tEzn+LIleTw5TreoxNT9g=="],
"expo-sharing": ["expo-sharing@13.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-qych3Nw65wlFcnzE/gRrsdtvmdV0uF4U4qVMZBJYPG90vYyWh2QM9rp1gVu0KWOBc7N8CC2dSVYn4/BXqJy6Xw=="],
"expo-splash-screen": ["expo-splash-screen@0.29.22", "", { "dependencies": { "@expo/prebuild-config": "^8.0.27" }, "peerDependencies": { "expo": "*" } }, "sha512-f+bPpF06bqiuW1Fbrd3nxeaSsmTVTBEKEYe3epYt4IE6y4Ulli3qEUamMLlRQiDGuIXPU6zQlscpy2mdBUI5cA=="],
@@ -1577,33 +1577,33 @@
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"metro": ["metro@0.81.1", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^2.2.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.25.1", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.6.3", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.81.1", "metro-cache": "0.81.1", "metro-cache-key": "0.81.1", "metro-config": "0.81.1", "metro-core": "0.81.1", "metro-file-map": "0.81.1", "metro-resolver": "0.81.1", "metro-runtime": "0.81.1", "metro-source-map": "0.81.1", "metro-symbolicate": "0.81.1", "metro-transform-plugins": "0.81.1", "metro-transform-worker": "0.81.1", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-fqRu4fg8ONW7VfqWFMGgKAcOuMzyoQah2azv9Y3VyFXAmG+AoTU6YIFWqAADESCGVWuWEIvxTJhMf3jxU6jwjA=="],
"metro": ["metro@0.81.5", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^2.2.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.25.1", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.81.5", "metro-cache": "0.81.5", "metro-cache-key": "0.81.5", "metro-config": "0.81.5", "metro-core": "0.81.5", "metro-file-map": "0.81.5", "metro-resolver": "0.81.5", "metro-runtime": "0.81.5", "metro-source-map": "0.81.5", "metro-symbolicate": "0.81.5", "metro-transform-plugins": "0.81.5", "metro-transform-worker": "0.81.5", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-YpFF0DDDpDVygeca2mAn7K0+us+XKmiGk4rIYMz/CRdjFoCGqAei/IQSpV0UrGfQbToSugpMQeQJveaWSH88Hg=="],
"metro-babel-transformer": ["metro-babel-transformer@0.81.1", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.25.1", "nullthrows": "^1.1.1" } }, "sha512-JECKDrQaUnDmj0x/Q/c8c5YwsatVx38Lu+BfCwX9fR8bWipAzkvJocBpq5rOAJRDXRgDcPv2VO4Q4nFYrpYNQg=="],
"metro-babel-transformer": ["metro-babel-transformer@0.81.5", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.25.1", "nullthrows": "^1.1.1" } }, "sha512-oKCQuajU5srm+ZdDcFg86pG/U8hkSjBlkyFjz380SZ4TTIiI5F+OQB830i53D8hmqmcosa4wR/pnKv8y4Q3dLw=="],
"metro-cache": ["metro-cache@0.81.1", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "metro-core": "0.81.1" } }, "sha512-Uqcmn6sZ+Y0VJHM88VrG5xCvSeU7RnuvmjPmSOpEcyJJBe02QkfHL05MX2ZyGDTyZdbKCzaX0IijrTe4hN3F0Q=="],
"metro-cache": ["metro-cache@0.81.5", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "metro-core": "0.81.5" } }, "sha512-wOsXuEgmZMZ5DMPoz1pEDerjJ11AuMy9JifH4yNW7NmWS0ghCRqvDxk13LsElzLshey8C+my/tmXauXZ3OqZgg=="],
"metro-cache-key": ["metro-cache-key@0.81.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-5fDaHR1yTvpaQuwMAeEoZGsVyvjrkw9IFAS7WixSPvaNY5YfleqoJICPc6hbXFJjvwCCpwmIYFkjqzR/qJ6yqA=="],
"metro-cache-key": ["metro-cache-key@0.81.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-lGWnGVm1UwO8faRZ+LXQUesZSmP1LOg14OVR+KNPBip8kbMECbQJ8c10nGesw28uQT7AE0lwQThZPXlxDyCLKQ=="],
"metro-config": ["metro-config@0.81.1", "", { "dependencies": { "connect": "^3.6.5", "cosmiconfig": "^5.0.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.6.3", "metro": "0.81.1", "metro-cache": "0.81.1", "metro-core": "0.81.1", "metro-runtime": "0.81.1" } }, "sha512-VAAJmxsKIZ+Fz5/z1LVgxa32gE6+2TvrDSSx45g85WoX4EtLmdBGP3DSlpQW3DqFUfNHJCGwMLGXpJnxifd08g=="],
"metro-config": ["metro-config@0.81.5", "", { "dependencies": { "connect": "^3.6.5", "cosmiconfig": "^5.0.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.81.5", "metro-cache": "0.81.5", "metro-core": "0.81.5", "metro-runtime": "0.81.5" } }, "sha512-oDRAzUvj6RNRxratFdcVAqtAsg+T3qcKrGdqGZFUdwzlFJdHGR9Z413sW583uD2ynsuOjA2QB6US8FdwiBdNKg=="],
"metro-core": ["metro-core@0.81.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.81.1" } }, "sha512-4d2/+02IYqOwJs4dmM0dC8hIZqTzgnx2nzN4GTCaXb3Dhtmi/SJ3v6744zZRnithhN4lxf8TTJSHnQV75M7SSA=="],
"metro-core": ["metro-core@0.81.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.81.5" } }, "sha512-+2R0c8ByfV2N7CH5wpdIajCWa8escUFd8TukfoXyBq/vb6yTCsznoA25FhNXJ+MC/cz1L447Zj3vdUfCXIZBwg=="],
"metro-file-map": ["metro-file-map@0.81.1", "", { "dependencies": { "debug": "^2.2.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.6.3", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-aY72H2ujmRfFxcsbyh83JgqFF+uQ4HFN1VhV2FmcfQG4s1bGKf2Vbkk+vtZ1+EswcBwDZFbkpvAjN49oqwGzAA=="],
"metro-file-map": ["metro-file-map@0.81.5", "", { "dependencies": { "debug": "^2.2.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-mW1PKyiO3qZvjeeVjj1brhkmIotObA3/9jdbY1fQQYvEWM6Ml7bN/oJCRDGn2+bJRlG+J8pwyJ+DgdrM4BsKyg=="],
"metro-minify-terser": ["metro-minify-terser@0.81.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-p/Qz3NNh1nebSqMlxlUALAnESo6heQrnvgHtAuxufRPtKvghnVDq9hGGex8H7z7YYLsqe42PWdt4JxTA3mgkvg=="],
"metro-minify-terser": ["metro-minify-terser@0.81.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-/mn4AxjANnsSS3/Bb+zA1G5yIS5xygbbz/OuPaJYs0CPcZCaWt66D+65j4Ft/nJkffUxcwE9mk4ubpkl3rjgtw=="],
"metro-resolver": ["metro-resolver@0.81.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-E61t6fxRoYRkl6Zo3iUfCKW4DYfum/bLjcejXBMt1y3I7LFkK84TCR/Rs9OAwsMCY/7GOPB4+CREYZOtCC7CNA=="],
"metro-resolver": ["metro-resolver@0.81.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-6BX8Nq3g3go3FxcyXkVbWe7IgctjDTk6D9flq+P201DfHHQ28J+DWFpVelFcrNTn4tIfbP/Bw7u/0g2BGmeXfQ=="],
"metro-runtime": ["metro-runtime@0.81.1", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-pqu5j5d01rjF85V/K8SDDJ0NR3dRp6bE3z5bKVVb5O2Rx0nbR9KreUxYALQCRCcQHaYySqCg5fYbGKBHC295YQ=="],
"metro-runtime": ["metro-runtime@0.81.5", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-M/Gf71ictUKP9+77dV/y8XlAWg7xl76uhU7ggYFUwEdOHHWPG6gLBr1iiK0BmTjPFH8yRo/xyqMli4s3oGorPQ=="],
"metro-source-map": ["metro-source-map@0.81.1", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.81.1", "nullthrows": "^1.1.1", "ob1": "0.81.1", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-1i8ROpNNiga43F0ZixAXoFE/SS3RqcRDCCslpynb+ytym0VI7pkTH1woAN2HI9pczYtPrp3Nq0AjRpsuY35ieA=="],
"metro-source-map": ["metro-source-map@0.81.5", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.81.5", "nullthrows": "^1.1.1", "ob1": "0.81.5", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-Jz+CjvCKLNbJZYJTBeN3Kq9kIJf6b61MoLBdaOQZJ5Ajhw6Pf95Nn21XwA8BwfUYgajsi6IXsp/dTZsYJbN00Q=="],
"metro-symbolicate": ["metro-symbolicate@0.81.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.81.1", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-Lgk0qjEigtFtsM7C0miXITbcV47E1ZYIfB+m/hCraihiwRWkNUQEPCWvqZmwXKSwVE5mXA0EzQtghAvQSjZDxw=="],
"metro-symbolicate": ["metro-symbolicate@0.81.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.81.5", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-X3HV3n3D6FuTE11UWFICqHbFMdTavfO48nXsSpnNGFkUZBexffu0Xd+fYKp+DJLNaQr3S+lAs8q9CgtDTlRRuA=="],
"metro-transform-plugins": ["metro-transform-plugins@0.81.1", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-7L1lI44/CyjIoBaORhY9fVkoNe8hrzgxjSCQ/lQlcfrV31cZb7u0RGOQrKmUX7Bw4FpejrB70ArQ7Mse9mk7+Q=="],
"metro-transform-plugins": ["metro-transform-plugins@0.81.5", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-MmHhVx/1dJC94FN7m3oHgv5uOjKH8EX8pBeu1pnPMxbJrx6ZuIejO0k84zTSaQTZ8RxX1wqwzWBpXAWPjEX8mA=="],
"metro-transform-worker": ["metro-transform-worker@0.81.1", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "metro": "0.81.1", "metro-babel-transformer": "0.81.1", "metro-cache": "0.81.1", "metro-cache-key": "0.81.1", "metro-minify-terser": "0.81.1", "metro-source-map": "0.81.1", "metro-transform-plugins": "0.81.1", "nullthrows": "^1.1.1" } }, "sha512-M+2hVT3rEy5K7PBmGDgQNq3Zx53TjScOcO/CieyLnCRFtBGWZiSJ2+bLAXXOKyKa/y3bI3i0owxtyxuPGDwbZg=="],
"metro-transform-worker": ["metro-transform-worker@0.81.5", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "metro": "0.81.5", "metro-babel-transformer": "0.81.5", "metro-cache": "0.81.5", "metro-cache-key": "0.81.5", "metro-minify-terser": "0.81.5", "metro-source-map": "0.81.5", "metro-transform-plugins": "0.81.5", "nullthrows": "^1.1.1" } }, "sha512-lUFyWVHa7lZFRSLJEv+m4jH8WrR5gU7VIjUlg4XmxQfV8ngY4V10ARKynLhMYPeQGl7Qvf+Ayg0eCZ272YZ4Mg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
@@ -1679,7 +1679,7 @@
"nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="],
"ob1": ["ob1@0.81.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-1PEbvI+AFvOcgdNcO79FtDI1TUO8S3lhiKOyAiyWQF3sFDDKS+aw2/BZvGlArFnSmqckwOOB9chQuIX0/OahoQ=="],
"ob1": ["ob1@0.81.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-iNpbeXPLmaiT9I5g16gFFFjsF3sGxLpYG2EGP3dfFB4z+l9X60mp/yRzStHhMtuNt8qmf7Ww80nOPQHngHhnIQ=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -1851,7 +1851,7 @@
"react-is": ["react-is@19.0.0", "", {}, "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g=="],
"react-native": ["react-native-tvos@0.77.0-0", "", { "dependencies": { "@jest/create-cache-key-function": "^29.6.3", "@react-native-tvos/virtualized-lists": "0.77.0-0", "@react-native/assets-registry": "0.77.0", "@react-native/codegen": "0.77.0", "@react-native/community-cli-plugin": "0.77.0", "@react-native/gradle-plugin": "0.77.0", "@react-native/js-polyfills": "0.77.0", "@react-native/normalize-colors": "0.77.0", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.25.1", "base64-js": "^1.5.1", "chalk": "^4.0.0", "commander": "^12.0.0", "event-target-shim": "^5.0.1", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.6.3", "jsc-android": "^250231.0.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.81.0", "metro-source-map": "^0.81.0", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.0.1", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.24.0-canary-efb381bbf-20230505", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^18.2.6", "react": "^18.2.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-edIOqGrPadpXHmt5R/LuhekHHLx/0DyrfY5A9odS2AlS+03S0ada7H5oDvusOUVcyq1vc3isrwZpUSQzudoR1g=="],
"react-native": ["react-native-tvos@0.77.2-0", "", { "dependencies": { "@jest/create-cache-key-function": "^29.6.3", "@react-native-tvos/virtualized-lists": "0.77.2-0", "@react-native/assets-registry": "0.77.2", "@react-native/codegen": "0.77.2", "@react-native/community-cli-plugin": "0.77.2", "@react-native/gradle-plugin": "0.77.2", "@react-native/js-polyfills": "0.77.2", "@react-native/normalize-colors": "0.77.2", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.25.1", "base64-js": "^1.5.1", "chalk": "^4.0.0", "commander": "^12.0.0", "event-target-shim": "^5.0.1", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.6.3", "jsc-android": "^250231.0.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.81.3", "metro-source-map": "^0.81.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.0.1", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.24.0-canary-efb381bbf-20230505", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^18.2.6", "react": "^18.2.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-Ys0tka4VRxClE8oGV4itR0CaeQwtI7jQ51uO7DedmUpt3m8I5uUUFQANgH8IhdEeTtvyPFbnCUffbpcFm59jKg=="],
"react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="],
@@ -1903,8 +1903,6 @@
"react-native-tab-view": ["react-native-tab-view@4.0.5", "", { "dependencies": { "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-Xn3TpYo4yvKRC/f4+cOcvsXlitdnSaYkacshckrEI3JiDmFKNFIRVNxtZFggm4MwbJafq2RzuzR6xrgKoxgkTw=="],
"react-native-tvos": ["react-native-tvos@0.77.0-0", "", { "dependencies": { "@jest/create-cache-key-function": "^29.6.3", "@react-native-tvos/virtualized-lists": "0.77.0-0", "@react-native/assets-registry": "0.77.0", "@react-native/codegen": "0.77.0", "@react-native/community-cli-plugin": "0.77.0", "@react-native/gradle-plugin": "0.77.0", "@react-native/js-polyfills": "0.77.0", "@react-native/normalize-colors": "0.77.0", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.25.1", "base64-js": "^1.5.1", "chalk": "^4.0.0", "commander": "^12.0.0", "event-target-shim": "^5.0.1", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.6.3", "jsc-android": "^250231.0.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.81.0", "metro-source-map": "^0.81.0", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.0.1", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.24.0-canary-efb381bbf-20230505", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^18.2.6", "react": "^18.2.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-edIOqGrPadpXHmt5R/LuhekHHLx/0DyrfY5A9odS2AlS+03S0ada7H5oDvusOUVcyq1vc3isrwZpUSQzudoR1g=="],
"react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="],
"react-native-uitextview": ["react-native-uitextview@1.4.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-itm/frzkn/ma3+lwmKn2CkBOXPNo4bL8iVwQwjlzix5gVO59T2+axdfoj/Wi+Ra6F76KzNKxSah+7Y8dYmCHbQ=="],
@@ -1943,7 +1941,7 @@
"recast": ["recast@0.23.9", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-Hx/BGIbwj+Des3+xy5uAtAbdCyqK9y9wbBcDFDYanLS9JnMqf7OeF87HQwUimE87OEc72mr6tkKUKMBBL+hF9Q=="],
"recyclerlistview": ["recyclerlistview@4.2.1", "", { "dependencies": { "lodash.debounce": "4.0.8", "prop-types": "15.8.1", "ts-object-utils": "0.0.5" }, "peerDependencies": { "react": ">= 15.2.1", "react-native": ">= 0.30.0" } }, "sha512-NtVYjofwgUCt1rEsTp6jHQg/47TWjnO92TU2kTVgJ9wsc/ely4HnizHHa+f/dI7qaw4+zcSogElrLjhMltN2/g=="],
"recyclerlistview": ["recyclerlistview@4.2.3", "", { "dependencies": { "lodash.debounce": "4.0.8", "prop-types": "15.8.1", "ts-object-utils": "0.0.5" }, "peerDependencies": { "react": ">= 15.2.1", "react-native": ">= 0.30.0" } }, "sha512-STR/wj/FyT8EMsBzzhZ1l2goYirMkIgfV3gYEPxI3Kf3lOnu6f7Dryhyw7/IkQrgX5xtTcDrZMqytvteH9rL3g=="],
"regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="],
@@ -2315,8 +2313,6 @@
"@babel/runtime/regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="],
"@config-plugins/ffmpeg-kit-react-native/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"@expo/bunyan/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"@expo/cli/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
@@ -2427,7 +2423,7 @@
"@react-native/codegen/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"@react-native/community-cli-plugin/@react-native/dev-middleware": ["@react-native/dev-middleware@0.77.0", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.77.0", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.2.0", "connect": "^3.6.5", "debug": "^2.2.0", "nullthrows": "^1.1.1", "open": "^7.0.3", "selfsigned": "^2.4.1", "serve-static": "^1.16.2", "ws": "^6.2.3" } }, "sha512-DAlEYujm43O+Dq98KP2XfLSX5c/TEGtt+JBDEIOQewk374uYY52HzRb1+Gj6tNaEj/b33no4GibtdxbO5zmPhg=="],
"@react-native/community-cli-plugin/@react-native/dev-middleware": ["@react-native/dev-middleware@0.77.2", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.77.2", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.2.0", "connect": "^3.6.5", "debug": "^2.2.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "selfsigned": "^2.4.1", "serve-static": "^1.16.2", "ws": "^6.2.3" } }, "sha512-LBK0kY4XxE4vHVHJ3TwBGXmjl2ad9dsbbwnVgXwYNL/mkkWb2MHlmgHj6xlCMe1gtLtem2TpEF17TKg50ykPJw=="],
"@react-native/community-cli-plugin/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@@ -2437,7 +2433,7 @@
"@react-native/dev-middleware/open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
"@react-native/metro-babel-transformer/@react-native/babel-preset": ["@react-native/babel-preset@0.77.0", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-arrow-functions": "^7.24.7", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-computed-properties": "^7.24.7", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-function-name": "^7.25.1", "@babel/plugin-transform-literals": "^7.25.2", "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-numeric-separator": "^7.24.7", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.25.2", "@babel/plugin-transform-react-jsx-self": "^7.24.7", "@babel/plugin-transform-react-jsx-source": "^7.24.7", "@babel/plugin-transform-regenerator": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-shorthand-properties": "^7.24.7", "@babel/plugin-transform-spread": "^7.24.7", "@babel/plugin-transform-sticky-regex": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/template": "^7.25.0", "@react-native/babel-plugin-codegen": "0.77.0", "babel-plugin-syntax-hermes-parser": "0.25.1", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.14.0" } }, "sha512-Z4yxE66OvPyQ/iAlaETI1ptRLcDm7Tk6ZLqtCPuUX3AMg+JNgIA86979T4RSk486/JrBUBH5WZe2xjj7eEHXsA=="],
"@react-native/metro-babel-transformer/@react-native/babel-preset": ["@react-native/babel-preset@0.77.2", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-arrow-functions": "^7.24.7", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-computed-properties": "^7.24.7", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-function-name": "^7.25.1", "@babel/plugin-transform-literals": "^7.25.2", "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-numeric-separator": "^7.24.7", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.25.2", "@babel/plugin-transform-react-jsx-self": "^7.24.7", "@babel/plugin-transform-react-jsx-source": "^7.24.7", "@babel/plugin-transform-regenerator": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-shorthand-properties": "^7.24.7", "@babel/plugin-transform-spread": "^7.24.7", "@babel/plugin-transform-sticky-regex": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/template": "^7.25.0", "@react-native/babel-plugin-codegen": "0.77.2", "babel-plugin-syntax-hermes-parser": "0.25.1", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.14.0" } }, "sha512-If6X4I0z6W5aVzqZS4JOrN7sh08w1QzEL8Q66i3g0wI8K8ZK+V+/ARlEmboy14VtcOYlmmjXEqSCv+Z2o9cuKg=="],
"@react-navigation/core/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
@@ -2595,7 +2591,7 @@
"react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"react-native/@react-native/normalize-colors": ["@react-native/normalize-colors@0.77.0", "", {}, "sha512-qjmxW3xRZe4T0ZBEaXZNHtuUbRgyfybWijf1yUuQwjBt24tSapmIslwhCjpKidA0p93ssPcepquhY0ykH25mew=="],
"react-native/@react-native/normalize-colors": ["@react-native/normalize-colors@0.77.2", "", {}, "sha512-knKStQKX4KM8GkieeayotcSTO7I7PIZxwI71nhK/zBeRPqhDTJMNJQh5TnZJ63fO1Y+EZclWkRIKEj+aFRsssw=="],
"react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
@@ -2605,16 +2601,6 @@
"react-native/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"react-native-tvos/@react-native/normalize-colors": ["@react-native/normalize-colors@0.77.0", "", {}, "sha512-qjmxW3xRZe4T0ZBEaXZNHtuUbRgyfybWijf1yUuQwjBt24tSapmIslwhCjpKidA0p93ssPcepquhY0ykH25mew=="],
"react-native-tvos/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
"react-native-tvos/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"react-native-tvos/scheduler": ["scheduler@0.24.0-canary-efb381bbf-20230505", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-ABvovCDe/k9IluqSh4/ISoq8tIJnW8euVAWYt5j/bg6dRnqwQwiGO1F/V4AyK96NGF/FB04FhOUDuWj8IKfABA=="],
"react-native-tvos/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"react-native-web/@react-native/normalize-colors": ["@react-native/normalize-colors@0.74.89", "", {}, "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg=="],
"react-native-web/memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="],
@@ -2761,7 +2747,7 @@
"@react-native/codegen/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"@react-native/community-cli-plugin/@react-native/dev-middleware/@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.77.0", "", {}, "sha512-glOvSEjCbVXw+KtfiOAmrq21FuLE1VsmBsyT7qud4KWbXP43aUEhzn70mWyFuiIdxnzVPKe2u8iWTQTdJksR1w=="],
"@react-native/community-cli-plugin/@react-native/dev-middleware/@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.77.2", "", {}, "sha512-MRLjQLJr9C0M/TggoycEgYR7lUEZph4cg5PhUwBoNyRquV7lGHqMKNkfMBYBT09cuwKn9O+cFvQOmMNVqsPLxw=="],
"@react-native/community-cli-plugin/@react-native/dev-middleware/open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
@@ -2769,7 +2755,7 @@
"@react-native/dev-middleware/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"@react-native/metro-babel-transformer/@react-native/babel-preset/@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.77.0", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.77.0" } }, "sha512-5TYPn1k+jdDOZJU4EVb1kZ0p9TCVICXK3uplRev5Gul57oWesAaiWGZOzfRS3lonWeuR4ij8v8PFfIHOaq0vmA=="],
"@react-native/metro-babel-transformer/@react-native/babel-preset/@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.77.2", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.77.2" } }, "sha512-2PShbsfsa4NZS+Zt0y2tl1AoWza5podKFmPE5qcYjJoN915VoH3BRkiTVlSpYNKmdvs31o1aQuXAMQDTh7DZ/g=="],
"ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
@@ -2853,8 +2839,6 @@
"pkg-dir/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="],
"react-native-tvos/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"react-native/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
@@ -2947,8 +2931,6 @@
"pkg-dir/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
"react-native-tvos/glob/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"react-native/glob/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],

View File

@@ -1,4 +1,3 @@
//import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
@@ -152,18 +151,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
}
closeModal();
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
else {
queueActions.enqueue(
queue,
setQueue,
...itemsNotDownloaded.map((item) => ({
id: item.Id!,
execute: async () => await initiateDownload(item),
item,
})),
);
}
initiateDownload(...itemsNotDownloaded);
} else {
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
@@ -203,7 +191,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
mediaSource = defaults.mediaSource;
audioIndex = defaults.audioIndex;
subtitleIndex = defaults.subtitleIndex;
// Keep using the selected bitrate for consistency across all downloads
}
const res = await getStreamUrl({
@@ -216,6 +203,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
mediaSourceId: mediaSource?.Id,
subtitleStreamIndex: subtitleIndex,
deviceProfile: download,
download: true,
// deviceId: mediaSource?.Id,
});
if (!res) {
@@ -230,12 +219,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
if (!url || !source) throw new Error("No url");
if (usingOptimizedServer) {
saveDownloadItemInfoToDiskTmp(item, source, url);
await startBackgroundDownload(url, item, source);
} else {
//await startRemuxing(item, url, source);
}
saveDownloadItemInfoToDiskTmp(item, source, url);
await startBackgroundDownload(url, item, source, maxBitrate);
}
},
[
@@ -249,7 +234,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
maxBitrate,
usingOptimizedServer,
startBackgroundDownload,
//startRemuxing,
],
);

View File

@@ -17,6 +17,7 @@ import { useImageColors } from "@/hooks/useImageColors";
import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider";
import { userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import type {
@@ -34,6 +35,7 @@ import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
export type SelectedOptions = {
@@ -50,6 +52,8 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
const { orientation } = useOrientation();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const [user] = useAtom(userAtom);
useImageColors({ item });
const [loadingLogo, setLoadingLogo] = useState(true);
@@ -97,6 +101,10 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
{!Platform.isTV && (
<DownloadSingleItem item={item} size='large' />
)}
{user?.Policy?.IsAdministrator && (
<PlayInRemoteSessionButton item={item} size='large' />
)}
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
</View>

View File

@@ -7,7 +7,6 @@ import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import ios from "@/utils/profiles/ios";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
@@ -67,11 +66,14 @@ export const PlayButton: React.FC<Props> = ({
const startColor = useSharedValue(colorAtom);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const [settings] = useSettings();
const [settings, updateSettings] = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(
(q: string) => {
if (settings.maxAutoPlayEpisodeCount.value !== -1) {
updateSettings({ autoPlayEpisodeCount: 0 });
}
router.push(`/player/direct-player?${q}`);
},
[router],

View File

@@ -0,0 +1,194 @@
import { useAllSessions, type useSessionsProps } from "@/hooks/useSessions";
import { apiAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import {
type BaseItemDto,
PlayCommand,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { useAtomValue } from "jotai";
import React, { useState } from "react";
import {
FlatList,
Modal,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import { Loader } from "./Loader";
import { RoundButton } from "./RoundButton";
import { Text } from "./common/Text";
interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto;
size?: "default" | "large";
}
export const PlayInRemoteSessionButton: React.FC<Props> = ({
item,
...props
}) => {
const [modalVisible, setModalVisible] = useState(false);
const api = useAtomValue(apiAtom);
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
const handlePlayInSession = async (sessionId: string) => {
if (!api || !item.Id) return;
try {
console.log(`Playing ${item.Name} in session ${sessionId}`);
getSessionApi(api).play({
sessionId,
itemIds: [item.Id],
playCommand: PlayCommand.PlayNow,
});
setModalVisible(false);
} catch (error) {
console.error("Error playing in remote session:", error);
}
};
return (
<View {...props}>
<RoundButton
icon='play-circle-outline'
onPress={() => setModalVisible(true)}
size={props.size}
/>
<Modal
animationType='slide'
transparent={true}
visible={modalVisible}
onRequestClose={() => setModalVisible(false)}
>
<View style={styles.centeredView}>
<View style={styles.modalView}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Select Session</Text>
<TouchableOpacity onPress={() => setModalVisible(false)}>
<Ionicons name='close' size={24} color='white' />
</TouchableOpacity>
</View>
<View style={styles.modalContent}>
{isLoading ? (
<View style={styles.loadingContainer}>
<Loader />
</View>
) : !sessions || sessions.length === 0 ? (
<Text style={styles.noSessionsText}>
No active sessions found
</Text>
) : (
<FlatList
data={sessions}
keyExtractor={(session) => session.Id || "unknown"}
renderItem={({ item: session }) => (
<TouchableOpacity
style={styles.sessionItem}
onPress={() => handlePlayInSession(session.Id || "")}
>
<View style={styles.sessionInfo}>
<Text style={styles.sessionName}>
{session.DeviceName}
</Text>
<Text style={styles.sessionDetails}>
{session.UserName} {session.Client}
</Text>
{session.NowPlayingItem && (
<Text style={styles.nowPlaying} numberOfLines={1}>
Now playing:{" "}
{session.NowPlayingItem.SeriesName
? `${session.NowPlayingItem.SeriesName} :`
: ""}
{session.NowPlayingItem.Name}
</Text>
)}
</View>
<Ionicons name='play-sharp' size={20} color='#888' />
</TouchableOpacity>
)}
contentContainerStyle={styles.listContent}
/>
)}
</View>
</View>
</View>
</Modal>
</View>
);
};
const styles = StyleSheet.create({
centeredView: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.7)",
},
modalView: {
width: "90%",
maxHeight: "80%",
backgroundColor: "#1c1c1c",
borderRadius: 20,
overflow: "hidden",
display: "flex",
flexDirection: "column",
},
modalHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#333",
},
modalContent: {
flex: 1,
},
modalTitle: {
fontSize: 18,
fontWeight: "600",
},
loadingContainer: {
padding: 40,
alignItems: "center",
},
noSessionsText: {
padding: 40,
textAlign: "center",
color: "#888",
},
listContent: {
paddingVertical: 8,
},
sessionItem: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: 12,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: "#333",
},
sessionInfo: {
flex: 1,
},
sessionName: {
fontSize: 16,
fontWeight: "500",
marginBottom: 4,
},
sessionDetails: {
fontSize: 13,
opacity: 0.7,
marginBottom: 2,
},
nowPlaying: {
fontSize: 12,
opacity: 0.5,
fontStyle: "italic",
},
});

View File

@@ -23,9 +23,6 @@ import { Button } from "../Button";
const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
: null;
//const FFmpegKitProvider = !Platform.isTV
// ? require("ffmpeg-kit-react-native")
// : null;
interface Props extends ViewProps {}
@@ -72,23 +69,18 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
mutationFn: async (id: string) => {
if (!process) throw new Error("No active download");
if (settings?.downloadMethod === DownloadMethod.Optimized) {
try {
const tasks = await BackGroundDownloader.checkForExistingDownloads();
for (const task of tasks) {
if (task.id === id) {
task.stop();
}
try {
const tasks = await BackGroundDownloader.checkForExistingDownloads();
for (const task of tasks) {
if (task.id === id) {
task.stop();
}
} finally {
await removeProcess(id);
}
} finally {
await removeProcess(id);
if (settings?.downloadMethod === DownloadMethod.Optimized) {
await queryClient.refetchQueries({ queryKey: ["jobs"] });
}
} else {
//FFmpegKitProvider.FFmpegKit.cancel(Number(id));
setProcesses((prev: any[]) =>
prev.filter((p: { id: string }) => p.id !== id),
);
}
},
onSuccess: () => {

View File

@@ -53,6 +53,7 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
width: "100%",
}}
/>
{<WatchedIndicator item={item} />}
</View>
);
};

View File

@@ -9,7 +9,6 @@ import type { PropsWithChildren } from "react";
import { Text } from "../common/Text";
type SearchItemWrapperProps<T> = {
ids?: string[] | null;
items?: T[];
renderItem: (item: any) => React.ReactNode;
header?: string;
@@ -17,7 +16,6 @@ type SearchItemWrapperProps<T> = {
};
export const SearchItemWrapper = <T,>({
ids,
items,
renderItem,
header,
@@ -26,33 +24,7 @@ export const SearchItemWrapper = <T,>({
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data, isLoading: l1 } = useQuery({
queryKey: ["items", ids],
queryFn: async () => {
if (!user?.Id || !api || !ids || ids.length === 0) {
return [];
}
const itemPromises = ids.map((id) =>
getUserItemData({
api,
userId: user.Id,
itemId: id,
}),
);
const results = await Promise.all(itemPromises);
// Filter out null items
return results.filter(
(item) => item !== null,
) as unknown as BaseItemDto[];
},
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
staleTime: Number.POSITIVE_INFINITY,
});
if (!data && (!items || items.length === 0)) return null;
if (!items || items.length === 0) return null;
return (
<>
@@ -67,7 +39,7 @@ export const SearchItemWrapper = <T,>({
keyExtractor={(_, index) => index.toString()}
estimatedItemSize={250}
/*@ts-ignore */
data={data || items}
data={items}
onEndReachedThreshold={1}
onEndReached={onEndReached}
//@ts-ignore

View File

@@ -36,6 +36,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Platform,
RefreshControl,
ScrollView,
TouchableOpacity,
@@ -87,6 +88,12 @@ export const HomeIndex = () => {
const { downloadedFiles, cleanCacheDirectory } = useDownload();
useEffect(() => {
if (Platform.isTV) {
navigation.setOptions({
headerLeft: () => null,
});
return;
}
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
navigation.setOptions({
headerLeft: () => (
@@ -206,19 +213,43 @@ export const HomeIndex = () => {
queryKey,
queryFn: async () => {
if (!api) return [];
return (
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 20,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes,
parentId,
})
).data || []
);
const response = await getItemsApi(api).getItems({
userId: user?.Id,
limit: 40,
recursive: true,
includeItemTypes,
sortBy: ["DateCreated"],
sortOrder: ["Descending"],
fields: ["PrimaryImageAspectRatio", "Path"],
parentId,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
});
let items = response.data.Items || [];
if (includeItemTypes.includes("Episode")) {
// Removes individual episodes from the list if they are part of a series
// and only keeps the series item
// Note: The 'Latest' API endpoint does not work well with combining batch episode imports
// and will either only show the series or the episodes, not both.
// This is a workaround to filter out the episodes from the list
const seriesIds = new Set(
items.filter((i) => i.Type === "Series").map((i) => i.Id),
);
items = items.filter(
(i) =>
i.Type === "Series" ||
(i.Type === "Episode" && !seriesIds.has(i.SeriesId!)),
);
}
if (items.length > 20) {
items = items.slice(0, 20);
}
return items;
},
type: "ScrollingCollectionList",
}),
@@ -232,7 +263,7 @@ export const HomeIndex = () => {
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
c.CollectionType === "tvshows" ? ["Episode", "Series"] : ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey = [
"home",
@@ -358,10 +389,10 @@ export const HomeIndex = () => {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: section.items?.limit || 25,
limit: section.nextUp?.limit || 25,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: section.items?.enableResumable,
enableRewatching: section.items?.enableRewatching,
enableResumable: section.nextUp?.enableResumable,
enableRewatching: section.nextUp?.enableRewatching,
});
return response.data.Items || [];
}

View File

@@ -10,6 +10,7 @@ import {
} from "@/utils/background-tasks";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import i18n, { TFunction } from "i18next";
import type React from "react";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
@@ -251,7 +252,46 @@ export const OtherSettings: React.FC = () => {
}
/>
</ListItem>
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
<Dropdown
data={AUTOPLAY_EPISODES_COUNT(t)}
keyExtractor={(item) => item.key}
titleExtractor={(item) => item.key}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(settings?.maxAutoPlayEpisodeCount.key)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</TouchableOpacity>
}
label={t("home.settings.other.max_auto_play_episode_count")}
onSelected={(maxAutoPlayEpisodeCount) =>
updateSettings({ maxAutoPlayEpisodeCount })
}
/>
</ListItem>
</ListGroup>
</DisabledSetting>
);
};
const AUTOPLAY_EPISODES_COUNT = (
t: TFunction<"translation", undefined>,
): {
key: string;
value: number;
}[] => [
{ key: t("home.settings.other.disabled"), value: -1 },
{ key: "1", value: 1 },
{ key: "2", value: 2 },
{ key: "3", value: 3 },
{ key: "4", value: 4 },
{ key: "5", value: 5 },
{ key: "6", value: 6 },
{ key: "7", value: 7 },
];

View File

@@ -0,0 +1,49 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useSettings } from "@/utils/atoms/settings";
import { useRouter } from "expo-router";
import { t } from "i18next";
import React from "react";
import { View } from "react-native";
export interface ContinueWatchingOverlayProps {
goToNextItem: (options: {
isAutoPlay: boolean;
resetWatchCount: boolean;
}) => void;
}
const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
goToNextItem,
}) => {
const [settings] = useSettings();
const router = useRouter();
return settings.autoPlayEpisodeCount >=
settings.maxAutoPlayEpisodeCount.value ? (
<View
className={
"absolute top-0 bottom-0 left-0 right-0 flex flex-col px-4 items-center justify-center bg-[#000000B3]"
}
>
<Text className='text-2xl font-bold text-white py-4 '>
Are you still watching ?
</Text>
<Button
onPress={() => {
goToNextItem({ isAutoPlay: false, resetWatchCount: true });
}}
color={"purple"}
className='my-4 w-2/3'
>
{t("player.continue_watching")}
</Button>
<Button onPress={router.back} color={"transparent"} className='w-2/3'>
{t("player.go_back")}
</Button>
</View>
) : null;
};
export default ContinueWatchingOverlay;

View File

@@ -1,12 +1,12 @@
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import type { MpvPlayerViewRef, TrackInfo } from "@/modules/MpvPlayer.types";
import { VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider";
import { VideoPlayer, useSettings } from "@/utils/atoms/settings";
@@ -29,7 +29,7 @@ import { Image } from "expo-image";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useAtom } from "jotai";
import { debounce } from "lodash";
import {
import React, {
type Dispatch,
type FC,
type MutableRefObject,
@@ -66,7 +66,7 @@ import { useControlsTimeout } from "./useControlsTimeout";
interface Props {
item: BaseItemDto;
videoRef: MutableRefObject<VlcPlayerViewRef | MpvPlayerViewRef | null>;
videoRef: MutableRefObject<VlcPlayerViewRef | null>;
isPlaying: boolean;
isSeeking: SharedValue<boolean>;
cacheProgress: SharedValue<number>;
@@ -82,7 +82,7 @@ interface Props {
isVideoLoaded?: boolean;
mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void;
startPictureInPicture?: () => Promise<void>;
startPictureInPicture: () => Promise<void>;
play: (() => Promise<void>) | (() => void);
pause: () => void;
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
@@ -122,7 +122,7 @@ export const Controls: FC<Props> = ({
enableTrickplay = true,
isVlc = false,
}) => {
const [settings] = useSettings();
const [settings, updateSettings] = useSettings();
const router = useRouter();
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
@@ -237,15 +237,76 @@ export const Controls: FC<Props> = ({
goToItemCommon(previousItem);
}, [previousItem, goToItemCommon]);
const goToNextItem = useCallback(() => {
if (!nextItem) return;
goToItemCommon(nextItem);
}, [nextItem, goToItemCommon]);
const goToNextItem = useCallback(
({
isAutoPlay,
resetWatchCount,
}: { isAutoPlay?: boolean; resetWatchCount?: boolean }) => {
if (!nextItem) {
return;
}
if (!isAutoPlay) {
// if we are not autoplaying, we won't update anything, we just go to the next item
goToItemCommon(nextItem);
if (resetWatchCount) {
updateSettings({
autoPlayEpisodeCount: 0,
});
}
return;
}
// Skip autoplay logic if maxAutoPlayEpisodeCount is -1
if (settings.maxAutoPlayEpisodeCount.value === -1) {
goToItemCommon(nextItem);
return;
}
if (
settings.autoPlayEpisodeCount + 1 <
settings.maxAutoPlayEpisodeCount.value
) {
goToItemCommon(nextItem);
}
// Check if the autoPlayEpisodeCount is less than maxAutoPlayEpisodeCount for the autoPlay
if (
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value
) {
// update the autoPlayEpisodeCount in settings
updateSettings({
autoPlayEpisodeCount: settings.autoPlayEpisodeCount + 1,
});
}
},
[nextItem, goToItemCommon],
);
// Add a memoized handler for autoplay next episode
const handleNextEpisodeAutoPlay = useCallback(() => {
goToNextItem({ isAutoPlay: true });
}, [goToNextItem]);
// Add a memoized handler for manual next episode
const handleNextEpisodeManual = useCallback(() => {
goToNextItem({ isAutoPlay: false });
}, [goToNextItem]);
// Add a memoized handler for ContinueWatchingOverlay
const handleContinueWatching = useCallback(
(options: { isAutoPlay?: boolean; resetWatchCount?: boolean }) => {
goToNextItem(options);
},
[goToNextItem],
);
const goToItem = useCallback(
async (itemId: string) => {
const gotoItem = await getItemById(api, itemId);
if (!gotoItem) return;
if (!gotoItem) {
return;
}
goToItemCommon(gotoItem);
},
[goToItemCommon, api],
@@ -301,7 +362,9 @@ export const Controls: FC<Props> = ({
};
const handleSliderStart = useCallback(() => {
if (!showControls) return;
if (!showControls) {
return;
}
setIsSliding(true);
wasPlayingRef.current = isPlaying;
@@ -340,7 +403,9 @@ export const Controls: FC<Props> = ({
);
const handleSkipBackward = useCallback(async () => {
if (!settings?.rewindSkipTime) return;
if (!settings?.rewindSkipTime) {
return;
}
wasPlayingRef.current = isPlaying;
lightHapticFeedback();
try {
@@ -372,7 +437,9 @@ export const Controls: FC<Props> = ({
? curr + secondsToMs(settings.forwardSkipTime)
: ticksToSeconds(curr) + settings.forwardSkipTime;
seek(Math.max(0, newTime));
if (wasPlayingRef.current) play();
if (wasPlayingRef.current) {
play();
}
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
@@ -455,9 +522,9 @@ export const Controls: FC<Props> = ({
const onClose = async () => {
lightHapticFeedback();
// await ScreenOrientation.lockAsync(
// ScreenOrientation.OrientationLock.PORTRAIT_UP,
// );
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
router.back();
};
@@ -547,7 +614,7 @@ export const Controls: FC<Props> = ({
{nextItem && !offline && (
<TouchableOpacity
onPress={goToNextItem}
onPress={() => goToNextItem({ isAutoPlay: false })}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
>
<Ionicons name='play-skip-forward' size={24} color='white' />
@@ -742,17 +809,21 @@ export const Controls: FC<Props> = ({
onPress={skipCredit}
buttonText='Skip Credits'
/>
<NextEpisodeCountDownButton
show={
!nextItem
? false
: isVlc
? remainingTime < 10000
: remainingTime < 10
}
onFinish={goToNextItem}
onPress={goToNextItem}
/>
{(settings.maxAutoPlayEpisodeCount.value === -1 ||
settings.autoPlayEpisodeCount <
settings.maxAutoPlayEpisodeCount.value) && (
<NextEpisodeCountDownButton
show={
!nextItem
? false
: isVlc
? remainingTime < 10000
: remainingTime < 10
}
onFinish={handleNextEpisodeAutoPlay}
onPress={handleNextEpisodeManual}
/>
)}
</View>
</View>
<View
@@ -800,6 +871,9 @@ export const Controls: FC<Props> = ({
</View>
</>
)}
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
<ContinueWatchingOverlay goToNextItem={handleContinueWatching} />
)}
</ControlProvider>
);
};

View File

@@ -79,7 +79,7 @@ const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
>
<Animated.View style={animatedStyle} />
<View className='px-3 py-3'>
<Text className='text-center font-bold'>
<Text numberOfLines={1} className='text-center font-bold'>
{t("player.next_episode")}
</Text>
</View>

View File

@@ -1,4 +1,5 @@
import type { TrackInfo } from "@/modules/VlcPlayer.types";
import { VideoPlayer, useSettings } from "@/utils/atoms/settings";
import { router, useLocalSearchParams } from "expo-router";
import type React from "react";
import {
@@ -47,6 +48,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
}) => {
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
const [settings] = useSettings();
const ControlContext = useControlContext();
const isVideoLoaded = ControlContext?.isVideoLoaded;
@@ -126,15 +128,13 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
if (getSubtitleTracks) {
const subtitleData = await getSubtitleTracks();
console.log("subtitleData", subtitleData);
// Step 1: Move external subs to the end, because VLC puts external subs at the end
const sortedSubs = allSubs.sort(
(a, b) => Number(a.IsExternal) - Number(b.IsExternal),
);
// Step 2: Apply VLC indexing logic
let textSubIndex = 0;
let textSubIndex = settings.defaultPlayer === VideoPlayer.VLC_4 ? 0 : 1;
const processedSubs: Track[] = sortedSubs?.map((sub) => {
// Always increment for non-transcoding subtitles
// Only increment for text-based subtitles when transcoding
@@ -172,7 +172,6 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
});
setSubtitleTracks(subtitles);
}
if (getAudioTracks) {
const audioData = await getAudioTracks();

View File

@@ -1,231 +0,0 @@
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getItemImage } from "@/utils/getItemImage";
import { writeErrorLog, writeInfoLog } from "@/utils/log";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router";
// import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native";
const FFMPEGKitReactNative = !Platform.isTV
? require("ffmpeg-kit-react-native")
: null;
import { useSettings } from "@/utils/atoms/settings";
import useDownloadHelper from "@/utils/download";
import type { JobStatus } from "@/utils/optimize-server";
import type { Api } from "@jellyfin/sdk";
import { useAtomValue } from "jotai";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { toast } from "sonner-native";
import useImageStorage from "./useImageStorage";
type FFmpegSession = typeof FFMPEGKitReactNative.FFmpegSession;
type Statistics = typeof FFMPEGKitReactNative.Statistics;
const FFmpegKit = Platform.isTV
? null
: (FFMPEGKitReactNative.FFmpegKit as typeof FFMPEGKitReactNative.FFmpegKit);
const createFFmpegCommand = (url: string, output: string) => [
"-y", // overwrite output files without asking
"-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
// region ffmpeg protocol commands // https://ffmpeg.org/ffmpeg-protocols.html
"-protocol_whitelist file,http,https,tcp,tls,crypto", // whitelist
"-multiple_requests 1", // http
"-tcp_nodelay 1", // http
// endregion ffmpeg protocol commands
"-fflags +genpts", // format flags
`-i ${url}`, // infile
"-map 0:v -map 0:a", // select all streams for video & audio
"-c copy", // streamcopy, preventing transcoding
"-bufsize 25M", // amount of data processed before calculating current bitrate
"-max_muxing_queue_size 4096", // sets the size of stream buffer in packets for output
output,
];
/**
* Custom hook for remuxing HLS to MP4 using FFmpeg.
*
* @param url - The URL of the HLS stream
* @param item - The BaseItemDto object representing the media item
* @returns An object with remuxing-related functions
*/
export const useRemuxHlsToMp4 = () => {
const api = useAtomValue(apiAtom);
const router = useRouter();
const queryClient = useQueryClient();
const { t } = useTranslation();
const [settings] = useSettings();
const { saveImage } = useImageStorage();
const { saveSeriesPrimaryImage } = useDownloadHelper();
const {
saveDownloadedItemInfo,
setProcesses,
processes,
APP_CACHE_DOWNLOAD_DIRECTORY,
} = useDownload();
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
await saveSeriesPrimaryImage(item);
const itemImage = getItemImage({
item,
api,
variant: "Primary",
quality: 90,
width: 500,
});
await saveImage(item.Id, itemImage?.uri);
};
const completeCallback = useCallback(
async (session: FFmpegSession, item: BaseItemDto) => {
try {
console.log("completeCallback");
const returnCode = await session.getReturnCode();
if (returnCode.isValueSuccess()) {
const stat = await session.getLastReceivedStatistics();
await FileSystem.moveAsync({
from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`,
to: `${FileSystem.documentDirectory}${item.Id}.mp4`,
});
await queryClient.invalidateQueries({
queryKey: ["downloadedItems"],
});
saveDownloadedItemInfo(item, stat.getSize());
toast.success(t("home.downloads.toasts.download_completed"));
}
setProcesses((prev: any[]) => {
return prev.filter(
(process: { itemId: string | undefined }) =>
process.itemId !== item.Id,
);
});
} catch (e) {
console.error(e);
}
console.log("completeCallback ~ end");
},
[processes, setProcesses],
);
const statisticsCallback = useCallback(
(statistics: Statistics, item: BaseItemDto) => {
const videoLength =
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
const totalFrames = videoLength * fps;
const processedFrames = statistics.getVideoFrameNumber();
const speed = statistics.getSpeed();
const percentage =
totalFrames > 0 ? Math.floor((processedFrames / totalFrames) * 100) : 0;
if (!item.Id) throw new Error("Item is undefined");
setProcesses((prev: JobStatus[]) => {
return prev.map((process: JobStatus) => {
if (process.itemId === item.Id) {
return {
...process,
id: statistics.getSessionId().toString(),
progress: percentage,
speed: Math.max(speed, 0),
};
}
return process;
});
});
},
[setProcesses, completeCallback],
);
const startRemuxing = useCallback(
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
const cacheDir = await FileSystem.getInfoAsync(
APP_CACHE_DOWNLOAD_DIRECTORY,
);
if (!cacheDir.exists) {
await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {
intermediates: true,
});
}
const output = `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`;
if (!api) throw new Error("API is not defined");
if (!item.Id) throw new Error("Item must have an Id");
// First lets save any important assets we want to present to the user offline
await onSaveAssets(api, item);
toast.success(
t("home.downloads.toasts.download_started_for", { item: item.Name }),
{
action: {
label: "Go to download",
onClick: () => {
router.push("/downloads");
toast.dismiss();
},
},
},
);
try {
const job: JobStatus = {
id: "",
deviceId: "",
inputUrl: url,
item: item,
itemId: item.Id!,
outputPath: output,
progress: 0,
status: "downloading",
timestamp: new Date(),
};
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
setProcesses((prev: any) => [...prev, job]);
await FFmpegKit.executeAsync(
createFFmpegCommand(url, output).join(" "),
(session: any) => completeCallback(session, item),
undefined,
(s: any) => statisticsCallback(s, item),
);
} catch (e) {
const error = e as Error;
console.error("Failed to remux:", error);
writeErrorLog(
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
Error: ${error.message}, Stack: ${error.stack}`,
);
setProcesses((prev: any[]) => {
return prev.filter(
(process: { itemId: string | undefined }) =>
process.itemId !== item.Id,
);
});
throw error; // Re-throw the error to propagate it to the caller
}
},
[settings, processes, setProcesses, completeCallback, statisticsCallback],
);
const cancelRemuxing = useCallback(() => {
FFmpegKit.cancel();
setProcesses([]);
}, []);
return { startRemuxing, cancelRemuxing };
};

View File

@@ -44,3 +44,27 @@ export const useSessions = ({
return { sessions: data, isLoading };
};
export const useAllSessions = ({
refetchInterval = 5 * 1000,
activeWithinSeconds = 360,
}: useSessionsProps) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data, isLoading } = useQuery({
queryKey: ["allSessions"],
queryFn: async () => {
if (!api || !user || !user.Policy?.IsAdministrator) {
return [];
}
const response = await getSessionApi(api).getSessions({
activeWithinSeconds: activeWithinSeconds,
});
return response.data;
},
refetchInterval: refetchInterval,
});
return { sessions: data, isLoading };
};

View File

@@ -9,6 +9,38 @@ interface UseWebSocketProps {
togglePlay: () => void;
stopPlayback: () => void;
offline: boolean;
nextTrack?: () => void;
previousTrack?: () => void;
rewindPlayback?: () => void;
fastForwardPlayback?: () => void;
seekPlayback?: (positionTicks: number) => void;
volumeUp?: () => void;
volumeDown?: () => void;
toggleMute?: () => void;
toggleOsd?: () => void;
toggleFullscreen?: () => void;
goHome?: () => void;
goToSettings?: () => void;
setAudioStreamIndex?: (index: number) => void;
setSubtitleStreamIndex?: (index: number) => void;
moveUp?: () => void;
moveDown?: () => void;
moveLeft?: () => void;
moveRight?: () => void;
select?: () => void;
pageUp?: () => void;
pageDown?: () => void;
setVolume?: (volume: number) => void;
setRepeatMode?: (mode: string) => void;
setShuffleMode?: (mode: string) => void;
togglePictureInPicture?: () => void;
takeScreenshot?: () => void;
sendString?: (text: string) => void;
sendKey?: (key: string) => void;
playMediaSource?: (itemIds: string[], startPositionTicks?: number) => void;
playTrailers?: (itemId: string) => void;
}
export const useWebSocket = ({
@@ -16,38 +48,270 @@ export const useWebSocket = ({
togglePlay,
stopPlayback,
offline,
nextTrack,
previousTrack,
rewindPlayback,
fastForwardPlayback,
seekPlayback,
volumeUp,
volumeDown,
toggleMute,
toggleOsd,
toggleFullscreen,
goHome,
goToSettings,
setAudioStreamIndex,
setSubtitleStreamIndex,
moveUp,
moveDown,
moveLeft,
moveRight,
select,
pageUp,
pageDown,
setVolume,
setRepeatMode,
setShuffleMode,
togglePictureInPicture,
takeScreenshot,
sendString,
sendKey,
playMediaSource,
playTrailers,
}: UseWebSocketProps) => {
const router = useRouter();
const { ws } = useWebSocketContext();
const { lastMessage } = useWebSocketContext();
const { t } = useTranslation();
const { clearLastMessage } = useWebSocketContext();
useEffect(() => {
if (!ws) return;
if (!lastMessage) return;
if (offline) return;
ws.onmessage = (e) => {
const json = JSON.parse(e.data);
const command = json?.Data?.Command;
const messageType = lastMessage.MessageType;
const command: string | undefined =
lastMessage?.Data?.Command || lastMessage?.Data?.Name;
console.log("[WS] ~ ", json);
const args = lastMessage?.Data?.Arguments as
| Record<string, string>
| undefined; // Arguments are Dictionary<string, string>
if (command === "PlayPause") {
console.log("Command ~ PlayPause");
console.log("[WS] ~ ", lastMessage);
if (command === "PlayPause") {
console.log("Command ~ PlayPause");
togglePlay();
} else if (command === "Stop") {
console.log("Command ~ Stop");
stopPlayback();
router.canGoBack() && router.back();
} else if (command === "Pause") {
console.log("Command ~ Pause");
if (isPlaying) {
togglePlay();
} else if (command === "Stop") {
console.log("Command ~ Stop");
stopPlayback();
router.canGoBack() && router.back();
} else if (json?.Data?.Name === "DisplayMessage") {
console.log("Command ~ DisplayMessage");
const title = json?.Data?.Arguments?.Header;
const body = json?.Data?.Arguments?.Text;
Alert.alert(t("player.message_from_server", { message: title }), body);
}
};
} else if (command === "Unpause") {
console.log("Command ~ Unpause");
if (!isPlaying) {
togglePlay();
}
} else if (command === "NextTrack") {
console.log("Command ~ NextTrack");
nextTrack?.();
} else if (command === "PreviousTrack") {
console.log("Command ~ PreviousTrack");
previousTrack?.();
} else if (command === "Rewind") {
console.log("Command ~ Rewind");
rewindPlayback?.();
} else if (command === "FastForward") {
console.log("Command ~ FastForward");
fastForwardPlayback?.();
} else if (command === "Seek") {
const positionStr = args?.SeekPositionTicks;
console.log("Command ~ Seek", { positionStr });
if (positionStr) {
const position = Number.parseInt(positionStr, 10);
if (!Number.isNaN(position)) {
seekPlayback?.(position);
}
}
} else if (command === "Back") {
console.log("Command ~ Back");
if (router.canGoBack()) {
router.back();
}
} else if (command === "GoHome") {
console.log("Command ~ GoHome");
goHome ? goHome() : router.push("/");
} else if (command === "GoToSettings") {
console.log("Command ~ GoToSettings");
goToSettings ? goToSettings() : router.push("/settings");
} else if (command === "VolumeUp") {
console.log("Command ~ VolumeUp");
volumeUp?.();
} else if (command === "VolumeDown") {
console.log("Command ~ VolumeDown");
volumeDown?.();
} else if (command === "ToggleMute") {
console.log("Command ~ ToggleMute");
return () => {
ws.onmessage = null;
};
}, [ws, stopPlayback, togglePlay, isPlaying, router]);
toggleMute?.();
} else if (command === "ToggleOsd") {
console.log("Command ~ ToggleOsd");
toggleOsd?.();
} else if (command === "ToggleFullscreen") {
console.log("Command ~ ToggleFullscreen");
toggleFullscreen?.();
} else if (command === "SetAudioStreamIndex") {
const indexStr = args?.Index;
console.log("Command ~ SetAudioStreamIndex", { indexStr });
if (indexStr) {
const index = Number.parseInt(indexStr, 10);
if (!Number.isNaN(index)) {
setAudioStreamIndex?.(index);
}
}
} else if (command === "SetSubtitleStreamIndex") {
const indexStr = args?.Index;
console.log("Command ~ SetSubtitleStreamIndex", { indexStr });
if (indexStr) {
const index = Number.parseInt(indexStr, 10);
if (!Number.isNaN(index)) {
setSubtitleStreamIndex?.(index);
}
}
}
// Neue Befehle hier implementieren
else if (command === "MoveUp") {
console.log("Command ~ MoveUp");
moveUp?.();
} else if (command === "MoveDown") {
console.log("Command ~ MoveDown");
moveDown?.();
} else if (command === "MoveLeft") {
console.log("Command ~ MoveLeft");
moveLeft?.();
} else if (command === "MoveRight") {
console.log("Command ~ MoveRight");
moveRight?.();
} else if (command === "Select") {
console.log("Command ~ Select");
select?.();
} else if (command === "PageUp") {
console.log("Command ~ PageUp");
pageUp?.();
} else if (command === "PageDown") {
console.log("Command ~ PageDown");
pageDown?.();
} else if (command === "SetVolume") {
const volumeStr = args?.Volume;
console.log("Command ~ SetVolume", { volumeStr });
if (volumeStr) {
const volumeValue = Number.parseInt(volumeStr, 10);
if (!Number.isNaN(volumeValue)) {
setVolume?.(volumeValue);
}
}
} else if (command === "SetRepeatMode") {
const mode = args?.Mode;
console.log("Command ~ SetRepeatMode", { mode });
if (mode) {
setRepeatMode?.(mode);
}
} else if (command === "SetShuffleMode") {
const mode = args?.Mode;
console.log("Command ~ SetShuffleMode", { mode });
if (mode) {
setShuffleMode?.(mode);
}
} else if (command === "TogglePictureInPicture") {
console.log("Command ~ TogglePictureInPicture");
togglePictureInPicture?.();
} else if (command === "TakeScreenshot") {
console.log("Command ~ TakeScreenshot");
takeScreenshot?.();
} else if (command === "SendString") {
const text = args?.Text;
console.log("Command ~ SendString", { text });
if (text) {
sendString?.(text);
}
} else if (command === "SendKey") {
const key = args?.Key;
console.log("Command ~ SendKey", { key });
if (key) {
sendKey?.(key);
}
} else if (command === "PlayMediaSource") {
const itemIdsStr = args?.ItemIds;
const startPositionTicksStr = args?.StartPositionTicks;
console.log("Command ~ PlayMediaSource", {
itemIdsStr,
startPositionTicksStr,
});
if (itemIdsStr) {
const itemIds = itemIdsStr.split(",");
let startPositionTicks: number | undefined = undefined;
if (startPositionTicksStr) {
const parsedTicks = Number.parseInt(startPositionTicksStr, 10);
if (!Number.isNaN(parsedTicks)) {
startPositionTicks = parsedTicks;
}
}
playMediaSource?.(itemIds, startPositionTicks);
}
} else if (command === "PlayTrailers") {
const itemId = args?.ItemId;
console.log("Command ~ PlayTrailers", { itemId });
if (itemId) {
playTrailers?.(itemId);
}
} else if (command === "DisplayMessage") {
console.log("Command ~ DisplayMessage");
const title = args?.Header;
const body = args?.Text;
Alert.alert(t("player.message_from_server", { message: title }), body);
}
clearLastMessage();
}, [
lastMessage,
offline,
isPlaying,
togglePlay,
stopPlayback,
router,
nextTrack,
previousTrack,
rewindPlayback,
fastForwardPlayback,
seekPlayback,
volumeUp,
volumeDown,
toggleMute,
toggleOsd,
toggleFullscreen,
goHome,
goToSettings,
setAudioStreamIndex,
setSubtitleStreamIndex,
moveUp,
moveDown,
moveLeft,
moveRight,
select,
pageUp,
pageDown,
setVolume,
setRepeatMode,
setShuffleMode,
togglePictureInPicture,
takeScreenshot,
sendString,
sendKey,
playMediaSource,
playTrailers,
t,
clearLastMessage,
]);
};

15
i18n.ts
View File

@@ -4,6 +4,7 @@ import { initReactI18next } from "react-i18next";
import { getLocales } from "expo-localization";
import de from "./translations/de.json";
import en from "./translations/en.json";
import eo from "./translations/eo.json";
import es from "./translations/es.json";
import fr from "./translations/fr.json";
import it from "./translations/it.json";
@@ -11,10 +12,11 @@ import ja from "./translations/ja.json";
import nl from "./translations/nl.json";
import pl from "./translations/pl.json";
import ptBR from "./translations/pt-BR.json";
import sv from "./translations/sv.json";
import ru from "./translations/ru.json";
import sv from "./translations/sv.json";
import tlh from "./translations/tlh.json";
import tr from "./translations/tr.json";
import ua from "./translations/ua.json";
import uk from "./translations/uk.json";
import zhCN from "./translations/zh-CN.json";
import zhTW from "./translations/zh-TW.json";
@@ -22,16 +24,19 @@ export const APP_LANGUAGES = [
{ label: "Deutsch", value: "de" },
{ label: "English", value: "en" },
{ label: "Español", value: "es" },
{ label: "Esperanto", value: "eo" },
{ label: "Français", value: "fr" },
{ label: "Italiano", value: "it" },
{ label: "日本語", value: "ja" },
{ label: "Klingon", value: "tlh" },
{ label: "Türkçe", value: "tr" },
{ label: "Nederlands", value: "nl" },
{ label: "Polski", value: "pl" },
{ label: "Português (Brasil)", value: "pt-BR" },
{ label: "Svenska", value: "sv" },
{ label: "Русский", value: "ru" },
{ label: "Українська", value: "ua" },
{ label: "Українська", value: "uk" },
{ label: "Українська", value: "uk" },
{ label: "简体中文", value: "zh-CN" },
{ label: "繁體中文", value: "zh-TW" },
];
@@ -42,6 +47,7 @@ i18n.use(initReactI18next).init({
de: { translation: de },
en: { translation: en },
es: { translation: es },
eo: { translation: eo },
fr: { translation: fr },
it: { translation: it },
ja: { translation: ja },
@@ -51,7 +57,8 @@ i18n.use(initReactI18next).init({
sv: { translation: sv },
ru: { translation: ru },
tr: { translation: tr },
ua: { translation: ua },
tlh: { translation: tlh },
uk: { translation: uk },
"zh-CN": { translation: zhCN },
"zh-TW": { translation: zhTW },
},

View File

@@ -1,98 +0,0 @@
import { ViewStyle } from "react-native";
export type PlaybackStatePayload = {
nativeEvent: {
target: number;
state: "Opening" | "Buffering" | "Playing" | "Paused" | "Error";
currentTime: number;
duration: number;
isBuffering: boolean;
isPlaying: boolean;
};
};
export type ProgressUpdatePayload = {
nativeEvent: {
currentTime: number;
duration: number;
isPlaying: boolean;
isBuffering: boolean;
};
};
export type VideoLoadStartPayload = {
nativeEvent: {
target: number;
};
};
export type PipStartedPayload = {
nativeEvent: {
pipStarted: boolean;
};
};
export type VideoStateChangePayload = PlaybackStatePayload;
export type VideoProgressPayload = ProgressUpdatePayload;
export type MpvPlayerSource = {
uri: string;
type?: string;
isNetwork?: boolean;
autoplay?: boolean;
externalSubtitles: { name: string; DeliveryUrl: string }[];
initOptions?: any[];
mediaOptions?: { [key: string]: any };
startPosition?: number;
};
export type TrackInfo = {
name: string;
index: number;
language?: string;
};
export type ChapterInfo = {
name: string;
timeOffset: number;
duration: number;
};
export type MpvPlayerViewProps = {
source: MpvPlayerSource;
style?: ViewStyle | ViewStyle[];
progressUpdateInterval?: number;
paused?: boolean;
muted?: boolean;
volume?: number;
videoAspectRatio?: string;
onVideoProgress?: (event: ProgressUpdatePayload) => void;
onVideoStateChange?: (event: PlaybackStatePayload) => void;
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
onVideoLoadEnd?: (event: VideoLoadStartPayload) => void;
onVideoError?: (event: PlaybackStatePayload) => void;
onPipStarted?: (event: PipStartedPayload) => void;
};
export interface MpvPlayerViewRef {
startPictureInPicture: () => Promise<void>;
play: () => Promise<void>;
pause: () => Promise<void>;
stop: () => Promise<void>;
seekTo: (time: number) => Promise<void>;
setAudioTrack: (trackIndex: number) => Promise<void>;
getAudioTracks: () => Promise<TrackInfo[] | null>;
setSubtitleTrack: (trackIndex: number) => Promise<void>;
getSubtitleTracks: () => Promise<TrackInfo[] | null>;
setSubtitleDelay: (delay: number) => Promise<void>;
setAudioDelay: (delay: number) => Promise<void>;
takeSnapshot: (path: string, width: number, height: number) => Promise<void>;
setRate: (rate: number) => Promise<void>;
nextChapter: () => Promise<void>;
previousChapter: () => Promise<void>;
getChapters: () => Promise<ChapterInfo[] | null>;
setVideoCropGeometry: (geometry: string | null) => Promise<void>;
getVideoCropGeometry: () => Promise<string | null>;
setSubtitleURL: (url: string, name: string) => Promise<void>;
}

View File

@@ -1,139 +0,0 @@
import { requireNativeViewManager } from "expo-modules-core";
import * as React from "react";
import { ViewStyle } from "react-native";
import type {
MpvPlayerSource,
MpvPlayerViewProps,
MpvPlayerViewRef,
} from "./MpvPlayer.types";
interface NativeViewRef extends MpvPlayerViewRef {
setNativeProps?: (props: Partial<MpvPlayerViewProps>) => void;
}
const MpvViewManager = requireNativeViewManager("MpvPlayer");
// Create a forwarded ref version of the native view
const NativeView = React.forwardRef<NativeViewRef, MpvPlayerViewProps>(
(props, ref) => {
return <MpvViewManager {...props} ref={ref} />;
},
);
const MpvPlayerView = React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
(props, ref) => {
const nativeRef = React.useRef<NativeViewRef>(null);
React.useImperativeHandle(ref, () => ({
startPictureInPicture: async () => {
await nativeRef.current?.startPictureInPicture();
},
play: async () => {
await nativeRef.current?.play();
},
pause: async () => {
await nativeRef.current?.pause();
},
stop: async () => {
await nativeRef.current?.stop();
},
seekTo: async (time: number) => {
await nativeRef.current?.seekTo(time);
},
setAudioTrack: async (trackIndex: number) => {
await nativeRef.current?.setAudioTrack(trackIndex);
},
getAudioTracks: async () => {
const tracks = await nativeRef.current?.getAudioTracks();
return tracks ?? null;
},
setSubtitleTrack: async (trackIndex: number) => {
await nativeRef.current?.setSubtitleTrack(trackIndex);
},
getSubtitleTracks: async () => {
const tracks = await nativeRef.current?.getSubtitleTracks();
return tracks ?? null;
},
setSubtitleDelay: async (delay: number) => {
await nativeRef.current?.setSubtitleDelay(delay);
},
setAudioDelay: async (delay: number) => {
await nativeRef.current?.setAudioDelay(delay);
},
takeSnapshot: async (path: string, width: number, height: number) => {
await nativeRef.current?.takeSnapshot(path, width, height);
},
setRate: async (rate: number) => {
await nativeRef.current?.setRate(rate);
},
nextChapter: async () => {
await nativeRef.current?.nextChapter();
},
previousChapter: async () => {
await nativeRef.current?.previousChapter();
},
getChapters: async () => {
const chapters = await nativeRef.current?.getChapters();
return chapters ?? null;
},
setVideoCropGeometry: async (geometry: string | null) => {
await nativeRef.current?.setVideoCropGeometry(geometry);
},
getVideoCropGeometry: async () => {
const geometry = await nativeRef.current?.getVideoCropGeometry();
return geometry ?? null;
},
setSubtitleURL: async (url: string, name: string) => {
await nativeRef.current?.setSubtitleURL(url, name);
},
}));
const {
source,
style,
progressUpdateInterval = 500,
paused,
muted,
volume,
videoAspectRatio,
onVideoLoadStart,
onVideoStateChange,
onVideoProgress,
onVideoLoadEnd,
onVideoError,
onPipStarted,
...otherProps
} = props;
const processedSource: MpvPlayerSource =
typeof source === "string"
? ({ uri: source } as unknown as MpvPlayerSource)
: source;
if (processedSource.startPosition !== undefined) {
processedSource.startPosition = Math.floor(processedSource.startPosition);
}
return (
<NativeView
{...otherProps}
ref={nativeRef}
source={processedSource}
style={[{ width: "100%", height: "100%" }, style as ViewStyle]}
progressUpdateInterval={progressUpdateInterval}
paused={paused}
muted={muted}
volume={volume}
videoAspectRatio={videoAspectRatio}
onVideoLoadStart={onVideoLoadStart}
onVideoLoadEnd={onVideoLoadEnd}
onVideoStateChange={onVideoStateChange}
onVideoProgress={onVideoProgress}
onVideoError={onVideoError}
onPipStarted={onPipStarted}
/>
);
},
);
export default MpvPlayerView;

View File

@@ -12,13 +12,6 @@ import {
} from "./VlcPlayer.types";
import VlcPlayerView from "./VlcPlayerView";
import {
MpvPlayerSource,
MpvPlayerViewProps,
MpvPlayerViewRef,
} from "./MpvPlayer.types";
import MpvPlayerView from "./MpvPlayerView";
export {
VlcPlayerView,
VlcPlayerViewProps,
@@ -31,9 +24,4 @@ export {
VlcPlayerSource,
TrackInfo,
ChapterInfo,
// MPV Player exports
MpvPlayerView,
MpvPlayerViewProps,
MpvPlayerViewRef,
MpvPlayerSource,
};

View File

@@ -1,6 +0,0 @@
{
"platforms": ["ios", "tvos"],
"ios": {
"modules": ["MpvPlayerModule"]
}
}

View File

@@ -1,23 +0,0 @@
Pod::Spec.new do |s|
s.name = 'MpvPlayer'
s.version = '0.40.0'
s.summary = 'MPVKit player for iOS/tvOS'
s.description = 'A module that integrates MPVKit for video playback in iOS and tvOS applications'
s.author = ''
s.source = { git: '' }
s.homepage = 'https://github.com/mpvkit/MPVKit'
s.platforms = { :ios => '13.4', :tvos => '13.4' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.dependency 'MPVKit', '~> 0.40.6'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
end

View File

@@ -1,71 +0,0 @@
import ExpoModulesCore
public class MpvPlayerModule: Module {
public func definition() -> ModuleDefinition {
Name("MpvPlayer")
View(MpvPlayerView.self) {
Prop("source") { (view: MpvPlayerView, source: [String: Any]) in
view.setSource(source)
}
Prop("paused") { (view: MpvPlayerView, paused: Bool) in
if paused {
view.pause()
} else {
view.play()
}
}
Events(
"onPlaybackStateChanged",
"onVideoStateChange",
"onVideoLoadStart",
"onVideoLoadEnd",
"onVideoProgress",
"onVideoError",
"onPipStarted"
)
AsyncFunction("startPictureInPicture") { (view: MpvPlayerView) in
view.startPictureInPicture()
}
AsyncFunction("play") { (view: MpvPlayerView) in
view.play()
}
AsyncFunction("pause") { (view: MpvPlayerView) in
view.pause()
}
AsyncFunction("stop") { (view: MpvPlayerView) in
view.stop()
}
AsyncFunction("seekTo") { (view: MpvPlayerView, time: Int32) in
view.seekTo(time)
}
AsyncFunction("setAudioTrack") { (view: MpvPlayerView, trackIndex: Int) in
view.setAudioTrack(trackIndex)
}
AsyncFunction("getAudioTracks") { (view: MpvPlayerView) -> [[String: Any]]? in
return view.getAudioTracks()
}
AsyncFunction("setSubtitleTrack") { (view: MpvPlayerView, trackIndex: Int) in
view.setSubtitleTrack(trackIndex)
}
AsyncFunction("getSubtitleTracks") { (view: MpvPlayerView) -> [[String: Any]]? in
return view.getSubtitleTracks()
}
AsyncFunction("setSubtitleURL") {
(view: MpvPlayerView, url: String, name: String) in
view.setSubtitleURL(url, name: name)
}
}
}
}

View File

@@ -1,892 +0,0 @@
import ExpoModulesCore
import Libmpv
import SwiftUI
import UIKit
// MARK: - Metal Layer
class MetalLayer: CAMetalLayer {
// Workaround for MoltenVK issue that sets drawableSize to 1x1
override var drawableSize: CGSize {
get { return super.drawableSize }
set {
if Int(newValue.width) > 1 && Int(newValue.height) > 1 {
super.drawableSize = newValue
}
}
}
// Handle extended dynamic range content on iOS 16+
@available(iOS 16.0, *)
override var wantsExtendedDynamicRangeContent: Bool {
get { return super.wantsExtendedDynamicRangeContent }
set {
if Thread.isMainThread {
super.wantsExtendedDynamicRangeContent = newValue
} else {
DispatchQueue.main.sync {
super.wantsExtendedDynamicRangeContent = newValue
}
}
}
}
// Helper to set HDR content safely
func setHDRContent(_ enabled: Bool) {
if #available(iOS 16.0, *) {
if Thread.isMainThread {
self.wantsExtendedDynamicRangeContent = enabled
} else {
DispatchQueue.main.sync {
self.wantsExtendedDynamicRangeContent = enabled
}
}
}
}
}
// MARK: - MPV Properties
enum MpvProperty {
static let timePosition = "time-pos"
static let duration = "duration"
static let pause = "pause"
static let pausedForCache = "paused-for-cache"
static let videoParamsSigPeak = "video-params/sig-peak"
}
// MARK: - Protocol
protocol MpvPlayerDelegate: AnyObject {
func propertyChanged(mpv: OpaquePointer, propertyName: String, value: Any?)
}
// MARK: - MPV Player View
class MpvPlayerView: ExpoView {
// MARK: - Properties
private var playerController: MpvMetalViewController?
private var source: [String: Any]?
private var externalSubtitles: [[String: String]]?
// MARK: - Event Emitters
@objc var onVideoStateChange: RCTDirectEventBlock?
@objc var onVideoLoadStart: RCTDirectEventBlock?
@objc var onVideoLoadEnd: RCTDirectEventBlock?
@objc var onVideoProgress: RCTDirectEventBlock?
@objc var onVideoError: RCTDirectEventBlock?
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
@objc var onPipStarted: RCTDirectEventBlock?
// MARK: - Initialization
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
setupView()
}
// MARK: - Setup
private func setupView() {
backgroundColor = .black
print("Setting up direct MPV view")
// Create player controller
let controller = MpvMetalViewController()
// Configure player delegate
controller.delegate = self
playerController = controller
// Add the controller's view to our view hierarchy
controller.view.translatesAutoresizingMaskIntoConstraints = false
controller.view.backgroundColor = .clear
addSubview(controller.view)
NSLayoutConstraint.activate([
controller.view.leadingAnchor.constraint(equalTo: leadingAnchor),
controller.view.trailingAnchor.constraint(equalTo: trailingAnchor),
controller.view.topAnchor.constraint(equalTo: topAnchor),
controller.view.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
// MARK: - Public Methods
func setSource(_ source: [String: Any]) {
self.source = source
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.onVideoLoadStart?(["target": self.reactTag as Any])
// Store external subtitle data
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
if let uri = source["uri"] as? String, let url = URL(string: uri) {
print("Loading file: \(url.absoluteString)")
self.playerController?.playUrl = url
// Set start position if available
if let startPosition = source["startPosition"] as? Double {
self.playerController?.setStartPosition(startPosition)
}
self.playerController?.loadFile(url)
// Set video to fill the screen
self.setVideoScalingMode("cover")
// Add external subtitles after the video is loaded
self.setInitialExternalSubtitles()
self.onVideoLoadEnd?(["target": self.reactTag as Any])
} else {
self.onVideoError?(["error": "Invalid or empty URI"])
}
}
}
func startPictureInPicture() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.onPipStarted?(["pipStarted": false, "target": self.reactTag as Any])
}
}
func play() {
playerController?.play()
}
func pause() {
playerController?.pause()
}
func stop() {
playerController?.command("stop", args: [])
}
func seekTo(_ time: Int32) {
let seconds = Double(time) / 1000.0
print("Seeking to absolute position: \(seconds) seconds")
playerController?.command("seek", args: ["\(seconds)", "absolute"])
}
func setAudioTrack(_ trackIndex: Int) {
playerController?.command("set", args: ["aid", "\(trackIndex)"])
}
func getAudioTracks() -> [[String: Any]] {
guard let playerController = playerController else {
return []
}
// Get track list as a node
guard let trackListStr = playerController.getNode("track-list") else {
return []
}
// Parse the JSON string into an array
guard let data = trackListStr.data(using: .utf8),
let trackList = try? JSONSerialization.jsonObject(with: data) as? [Any]
else {
return []
}
// Filter to audio tracks only
var audioTracks: [[String: Any]] = []
for case let track as [String: Any] in trackList {
if let type = track["type"] as? String, type == "audio" {
let id = track["id"] as? Int ?? 0
let title = track["title"] as? String ?? "Audio \(id)"
let lang = track["lang"] as? String ?? "unknown"
let selected = track["selected"] as? Bool ?? false
audioTracks.append([
"id": id,
"title": title,
"language": lang,
"selected": selected,
])
}
}
return audioTracks
}
func setSubtitleTrack(_ trackIndex: Int) {
playerController?.command("set", args: ["sid", "\(trackIndex)"])
}
func getSubtitleTracks() -> [[String: Any]] {
guard let playerController = playerController else {
return []
}
// Get track list as a node
guard let trackListStr = playerController.getNode("track-list") else {
return []
}
// Parse the JSON string into an array
guard let data = trackListStr.data(using: .utf8),
let trackList = try? JSONSerialization.jsonObject(with: data) as? [Any]
else {
return []
}
// Filter to subtitle tracks only
var subtitleTracks: [[String: Any]] = []
for case let track as [String: Any] in trackList {
if let type = track["type"] as? String, type == "sub" {
let id = track["id"] as? Int ?? 0
let title = track["title"] as? String ?? "Subtitle \(id)"
let lang = track["lang"] as? String ?? "unknown"
let selected = track["selected"] as? Bool ?? false
subtitleTracks.append([
"id": id,
"title": title,
"language": lang,
"selected": selected,
])
}
}
return subtitleTracks
}
func setSubtitleURL(_ subtitleURL: String, name: String) {
guard let url = URL(string: subtitleURL) else { return }
print("Adding subtitle: \(name) from \(subtitleURL)")
// Add the subtitle file
playerController?.command("sub-add", args: [url.absoluteString])
}
@objc
func setVideoScalingMode(_ mode: String) {
// Mode can be: "contain" (letterbox), "cover" (crop/fill), or "stretch"
guard let playerController = playerController else { return }
switch mode.lowercased() {
case "cover", "fill", "crop":
// Fill the screen, cropping if necessary
playerController.command("set", args: ["panscan", "1.0"])
playerController.command("set", args: ["video-unscaled", "no"])
playerController.command("set", args: ["video-aspect-override", "no"])
// Center the crop
playerController.command("set", args: ["video-align-x", "0.5"])
playerController.command("set", args: ["video-align-y", "0.5"])
case "stretch":
// Stretch to fill without maintaining aspect ratio
playerController.command("set", args: ["panscan", "0.0"])
playerController.command("set", args: ["video-unscaled", "no"])
playerController.command("set", args: ["video-aspect-override", "-1"])
// No need for alignment as it stretches to fill entire area
case "contain", "letterbox", "fit":
// Keep aspect ratio, fit within screen (letterbox)
playerController.command("set", args: ["panscan", "0.0"])
playerController.command("set", args: ["video-unscaled", "no"])
playerController.command("set", args: ["video-aspect-override", "no"])
// Set alignment to center
playerController.command("set", args: ["video-align-x", "0.5"])
playerController.command("set", args: ["video-align-y", "0.5"])
default:
break
}
}
private func setInitialExternalSubtitles() {
if let externalSubtitles = self.externalSubtitles {
for subtitle in externalSubtitles {
if let subtitleName = subtitle["name"],
let subtitleURL = subtitle["DeliveryUrl"]
{
print("Adding external subtitle: \(subtitleName) from \(subtitleURL)")
setSubtitleURL(subtitleURL, name: subtitleName)
}
}
}
}
// MARK: - Private Methods
private func isPaused() -> Bool {
return playerController?.getFlag(MpvProperty.pause) ?? true
}
private func isBuffering() -> Bool {
return playerController?.getFlag(MpvProperty.pausedForCache) ?? false
}
private func getCurrentTime() -> Double {
return playerController?.getDouble(MpvProperty.timePosition) ?? 0
}
private func getVideoDuration() -> Double {
return playerController?.getDouble(MpvProperty.duration) ?? 0
}
// MARK: - Cleanup
override func removeFromSuperview() {
cleanup()
super.removeFromSuperview()
}
private func cleanup() {
// Check if we already cleaned up
print("Cleaning up player")
guard playerController != nil else { return }
// First stop playback
stop()
// Break reference cycles
playerController?.delegate = nil
// Remove from view hierarchy
playerController?.view.removeFromSuperview()
// Release references
playerController = nil
}
deinit {
cleanup()
}
// Check if player needs reset when the view appears
override func didMoveToWindow() {
super.didMoveToWindow()
// If we're returning to the window and player is missing, reset
if window != nil && playerController == nil {
setupView()
// Reload previous source if available
if let source = source {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.setSource(source)
}
}
}
}
}
// MARK: - MPV Player Delegate
extension MpvPlayerView: MpvPlayerDelegate {
// Move the static properties to class level
private static var lastTimePositionUpdate = Date(timeIntervalSince1970: 0)
func propertyChanged(mpv: OpaquePointer, propertyName: String, value: Any?) {
// Add throttling for frequently updated properties
switch propertyName {
case MpvProperty.timePosition:
// Throttle timePosition updates to once per second
let now = Date()
if now.timeIntervalSince(MpvPlayerView.lastTimePositionUpdate) < 1.0 {
return
}
MpvPlayerView.lastTimePositionUpdate = now
if let position = value as? Double {
let timeMs = position * 1000
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
print("IsPlaying: \(!self.isPaused())")
self.onVideoProgress?([
"currentTime": timeMs,
"duration": self.getVideoDuration() * 1000,
"isPlaying": !self.isPaused(),
"isBuffering": self.isBuffering(),
"target": self.reactTag as Any,
])
}
}
case MpvProperty.pausedForCache:
// We want to respond immediately to buffering state changes
let isBuffering = value as? Bool ?? false
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.onVideoStateChange?([
"isBuffering": isBuffering, "target": self.reactTag as Any,
"isPlaying": !self.isPaused(),
"state": self.isPaused() ? "Paused" : "Playing",
])
}
case MpvProperty.pause:
// We want to respond immediately to play/pause state changes
if let isPaused = value as? Bool {
let state = isPaused ? "Paused" : "Playing"
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
print("onPlaybackStateChanged: \(state)")
self.onPlaybackStateChanged?([
"state": state,
"isPlaying": !isPaused,
"isBuffering": self.isBuffering(),
"currentTime": self.getCurrentTime() * 1000,
"duration": self.getVideoDuration() * 1000,
"target": self.reactTag as Any,
])
}
}
default:
break
}
}
}
// MARK: - Player Controller
final class MpvMetalViewController: UIViewController {
// MARK: - Properties
var metalLayer = MetalLayer()
var mpv: OpaquePointer?
weak var delegate: MpvPlayerDelegate?
let mpvQueue = DispatchQueue(label: "mpv.queue", qos: .userInitiated)
private var isBeingDeallocated = false
// Use a static dictionary to store controller references instead of WeakContainer
private static var controllers = [UInt: MpvMetalViewController]()
private var controllerId: UInt = 0
var playUrl: URL?
var hdrAvailable: Bool {
if #available(iOS 16.0, *) {
let maxEDRRange = view.window?.screen.potentialEDRHeadroom ?? 1.0
let sigPeak = getDouble(MpvProperty.videoParamsSigPeak)
return maxEDRRange > 1.0 && sigPeak > 1.0
} else {
return false
}
}
var hdrEnabled = false {
didSet {
guard let mpv = mpv else { return }
if hdrEnabled {
mpv_set_option_string(mpv, "target-colorspace-hint", "yes")
metalLayer.setHDRContent(true)
} else {
mpv_set_option_string(mpv, "target-colorspace-hint", "no")
metalLayer.setHDRContent(false)
}
}
}
// Add a new property to track shutdown state
private var isShuttingDown = false
private let syncQueue = DispatchQueue(label: "com.mpv.sync", qos: .userInitiated)
private var startPosition: Double?
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupMetalLayer()
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.setupMPV()
if let url = self?.playUrl {
self?.loadFile(url)
}
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
metalLayer.frame = view.bounds
}
deinit {
// Flag that we're being deinitialized
isBeingDeallocated = true
// Clean up on main thread to avoid threading issues
if Thread.isMainThread {
safeCleanup()
} else {
DispatchQueue.main.sync {
self.safeCleanup()
}
}
}
private func safeCleanup() {
// Remove from controllers dictionary first
if controllerId != 0 {
MpvMetalViewController.controllers.removeValue(forKey: controllerId)
}
// Remove the wakeup callback
if let mpv = self.mpv {
mpv_set_wakeup_callback(mpv, nil, nil)
}
// Terminate and destroy MPV instance
if let mpv = self.mpv {
// Unobserve all properties
mpv_unobserve_property(mpv, 0)
// Store locally to avoid accessing after freeing
let mpvToDestroy = mpv
self.mpv = nil
// Terminate and destroy
mpv_terminate_destroy(mpvToDestroy)
}
}
// MARK: - Setup
private func setupMetalLayer() {
metalLayer.frame = view.bounds
metalLayer.contentsScale = UIScreen.main.nativeScale
metalLayer.framebufferOnly = true
metalLayer.backgroundColor = UIColor.black.cgColor
view.layer.addSublayer(metalLayer)
}
private func setupMPV() {
guard let mpvHandle = mpv_create() else {
print("Failed to create MPV instance")
return
}
mpv = mpvHandle
// Configure mpv options
#if DEBUG
// mpv_request_log_messages(mpvHandle, "debug")
#else
mpv_request_log_messages(mpvHandle, "no")
#endif
// Force a proper window setup to prevent black screens
mpv_set_option_string(mpvHandle, "force-window", "yes")
mpv_set_option_string(mpvHandle, "reset-on-next-file", "all")
// Set rendering options
var layerPtr = Unmanaged.passUnretained(metalLayer).toOpaque()
mpv_set_option(mpvHandle, "wid", MPV_FORMAT_INT64, &layerPtr)
mpv_set_option_string(mpvHandle, "vo", "gpu-next")
mpv_set_option_string(mpvHandle, "gpu-api", "metal")
mpv_set_option_string(mpvHandle, "gpu-context", "auto")
mpv_set_option_string(mpvHandle, "hwdec", "videotoolbox")
// Set subtitle options
mpv_set_option_string(mpvHandle, "subs-match-os-language", "yes")
mpv_set_option_string(mpvHandle, "subs-fallback", "yes")
mpv_set_option_string(mpvHandle, "sub-auto", "no")
// Disable subtitle selection at start
mpv_set_option_string(mpvHandle, "sid", "no")
// Set starting point if available
if let startPos = startPosition {
let startPosString = String(format: "%.1f", startPos)
print("Setting initial start position to \(startPosString)")
mpv_set_option_string(mpvHandle, "start", startPosString)
}
// Set video options
mpv_set_option_string(mpvHandle, "video-rotate", "no")
mpv_set_option_string(mpvHandle, "ytdl", "no")
// Initialize mpv
let status = mpv_initialize(mpvHandle)
if status < 0 {
print("Failed to initialize MPV: \(String(cString: mpv_error_string(status)))")
mpv_terminate_destroy(mpvHandle)
mpv = nil
return
}
// Observe properties
observeProperty(mpvHandle, MpvProperty.videoParamsSigPeak, MPV_FORMAT_DOUBLE)
observeProperty(mpvHandle, MpvProperty.pausedForCache, MPV_FORMAT_FLAG)
observeProperty(mpvHandle, MpvProperty.timePosition, MPV_FORMAT_DOUBLE)
observeProperty(mpvHandle, MpvProperty.duration, MPV_FORMAT_DOUBLE)
observeProperty(mpvHandle, MpvProperty.pause, MPV_FORMAT_FLAG)
// Store controller in static dictionary and set its unique ID
controllerId = UInt(bitPattern: ObjectIdentifier(self))
MpvMetalViewController.controllers[controllerId] = self
// Set wakeup callback using the static method
mpv_set_wakeup_callback(
mpvHandle, MpvMetalViewController.mpvWakeupCallback,
UnsafeMutableRawPointer(bitPattern: controllerId))
print("MPV initialized")
}
// Static callback function - no WeakContainer needed
private static let mpvWakeupCallback: (@convention(c) (UnsafeMutableRawPointer?) -> Void) = {
(ctx) in
guard let ctx = ctx else { return }
// Get the controllerId from the context pointer
let controllerId = UInt(bitPattern: ctx)
// Dispatch to main queue to handle UI updates safely
DispatchQueue.main.async {
// Get the controller safely from the dictionary
if let controller = MpvMetalViewController.controllers[controllerId] {
// Only process events if not being deallocated
if !controller.isBeingDeallocated {
controller.processEvents()
}
}
}
}
// Helper method for safer property observation
private func observeProperty(_ handle: OpaquePointer, _ name: String, _ format: mpv_format) {
let status = mpv_observe_property(handle, 0, name, format)
if status < 0 {
print(
"Failed to observe property \(name): \(String(cString: mpv_error_string(status)))")
}
}
// MARK: - MPV Methods
func loadFile(_ url: URL) {
guard let mpv = mpv else { return }
print("Loading file: \(url.absoluteString)")
// Use string array extension for safer command execution
command("loadfile", args: [url.absoluteString, "replace"])
}
func play() {
setFlag(MpvProperty.pause, false)
}
func pause() {
print("Pausing")
setFlag(MpvProperty.pause, true)
}
func getDouble(_ name: String) -> Double {
guard let mpv = mpv else { return 0.0 }
var data = 0.0
let status = mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &data)
if status < 0 {
print(
"Failed to get double property \(name): \(String(cString: mpv_error_string(status)))"
)
}
return data
}
func getNode(_ name: String) -> String? {
guard let mpv = mpv else { return nil }
guard let cString = mpv_get_property_string(mpv, name) else { return nil }
// Use defer to ensure memory is freed even if an exception occurs
defer {
mpv_free(UnsafeMutableRawPointer(mutating: cString))
}
return String(cString: cString)
}
func getString(_ name: String) -> String? {
guard let mpv = mpv else { return nil }
guard let cString = mpv_get_property_string(mpv, name) else { return nil }
// Use defer to ensure memory is freed even if an exception occurs
defer {
mpv_free(UnsafeMutableRawPointer(mutating: cString))
}
return String(cString: cString)
}
func getFlag(_ name: String) -> Bool {
guard let mpv = mpv else { return false }
var data: Int32 = 0
let status = mpv_get_property(mpv, name, MPV_FORMAT_FLAG, &data)
if status < 0 {
print(
"Failed to get flag property \(name): \(String(cString: mpv_error_string(status)))")
}
return data > 0
}
func setFlag(_ name: String, _ value: Bool) {
guard let mpv = mpv else { return }
var data: Int32 = value ? 1 : 0
print("Setting flag \(name) to \(value)")
let status = mpv_set_property(mpv, name, MPV_FORMAT_FLAG, &data)
if status < 0 {
print(
"Failed to set flag property \(name): \(String(cString: mpv_error_string(status)))")
}
}
func command(
_ command: String,
args: [String] = [],
checkErrors: Bool = true,
completion: ((Int32) -> Void)? = nil
) {
guard let mpv = mpv else {
completion?(-1)
return
}
// Approach 1: Create array of C strings directly from Swift strings
let allArgs = [command] + args
// Allocate array of C string pointers of the correct type
let cArray = UnsafeMutablePointer<UnsafePointer<CChar>?>.allocate(
capacity: allArgs.count + 1)
// Convert Swift strings to C strings and store in the array
for i in 0..<allArgs.count {
cArray[i] = (allArgs[i] as NSString).utf8String
}
// Set final element to nil
cArray[allArgs.count] = nil
// Execute the command
let status = mpv_command(mpv, cArray)
// Clean up
cArray.deallocate()
if checkErrors && status < 0 {
print("MPV command error: \(String(cString: mpv_error_string(status)))")
}
completion?(status)
}
// MARK: - Event Processing
private func processEvents() {
// Exit if we're being deallocated
if isBeingDeallocated {
return
}
guard let mpv = mpv else { return }
// Process a limited number of events to avoid infinite loops
let maxEvents = 10
var eventCount = 0
while !isBeingDeallocated && eventCount < maxEvents {
guard let event = mpv_wait_event(mpv, 0) else { break }
if event.pointee.event_id == MPV_EVENT_NONE { break }
handleEvent(event)
eventCount += 1
}
}
private func handleEvent(_ event: UnsafePointer<mpv_event>) {
// Exit early if we're being deallocated
if isBeingDeallocated {
return
}
guard let mpv = mpv else { return }
switch event.pointee.event_id {
case MPV_EVENT_PROPERTY_CHANGE:
guard let propertyData = event.pointee.data else { break }
// Safely create a typed pointer to the property data
let propertyPtr = propertyData.bindMemory(
to: mpv_event_property.self, capacity: 1)
// Safely get the property name
guard let namePtr = propertyPtr.pointee.name else { break }
let propertyName = String(cString: namePtr)
var value: Any?
// Handle different property types safely
switch propertyName {
case MpvProperty.pausedForCache, MpvProperty.pause:
if propertyPtr.pointee.format == MPV_FORMAT_FLAG,
let data = propertyPtr.pointee.data
{
// Cast to Int32 which is MPV's flag format
let flagPtr = data.bindMemory(to: Int32.self, capacity: 1)
value = flagPtr.pointee != 0
}
case MpvProperty.timePosition, MpvProperty.duration:
if propertyPtr.pointee.format == MPV_FORMAT_DOUBLE,
let data = propertyPtr.pointee.data
{
// Cast to Double which is MPV's double format
let doublePtr = data.bindMemory(to: Double.self, capacity: 1)
value = doublePtr.pointee
}
default:
break
}
// Notify delegate on main thread
if let value = value {
DispatchQueue.main.async { [weak self] in
guard let self = self, !self.isBeingDeallocated else { return }
self.delegate?.propertyChanged(
mpv: mpv, propertyName: propertyName, value: value)
}
}
case MPV_EVENT_SHUTDOWN:
print("MPV shutdown event received")
isBeingDeallocated = true
case MPV_EVENT_LOG_MESSAGE:
return
default:
if let eventName = mpv_event_name(event.pointee.event_id) {
print("MPV event: \(String(cString: eventName))")
}
}
}
// MARK: - Public Methods
func setStartPosition(_ position: Double) {
startPosition = position
// If MPV is already initialized, we need to update the option
if let mpv = mpv {
let positionString = String(format: "%.1f", position)
print("Setting start position to \(positionString)")
mpv_set_option_string(mpv, "start", positionString)
}
}
}

View File

@@ -1,831 +0,0 @@
import ExpoModulesCore
import Foundation
import GLKit
import Libmpv
import SwiftUI
import UIKit
// MARK: - MPV Properties
enum MpvProperty {
static let timePosition = "time-pos"
static let duration = "duration"
static let pause = "pause"
static let pausedForCache = "paused-for-cache"
static let videoParamsSigPeak = "video-params/sig-peak"
}
// MARK: - Protocol
protocol MpvPlayerDelegate: AnyObject {
func propertyChanged(mpv: OpaquePointer, propertyName: String, value: Any?)
}
// MARK: - MPV Player View
class MpvPlayerView: ExpoView {
// MARK: - Properties
private var playerController: MpvGLViewController?
private var source: [String: Any]?
private var externalSubtitles: [[String: String]]?
// MARK: - Event Emitters
@objc var onVideoStateChange: RCTDirectEventBlock?
@objc var onVideoLoadStart: RCTDirectEventBlock?
@objc var onVideoLoadEnd: RCTDirectEventBlock?
@objc var onVideoProgress: RCTDirectEventBlock?
@objc var onVideoError: RCTDirectEventBlock?
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
@objc var onPipStarted: RCTDirectEventBlock?
// MARK: - Initialization
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
setupView()
}
// MARK: - Setup
private func setupView() {
backgroundColor = .black
print("Setting up MPV GL view")
// Create player controller - IMPORTANT: Use init(nibName:bundle:) to ensure proper GLKView setup
let controller = MpvGLViewController(nibName: nil, bundle: nil)
// Force view loading immediately
_ = controller.view
// Configure player delegate
controller.mpvDelegate = self
playerController = controller
// Make sure controller view is properly set up as GLKView
controller.view.backgroundColor = .black
// Set explicit frame to ensure it's visible
controller.view.frame = bounds
controller.view.translatesAutoresizingMaskIntoConstraints = false
controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
// Add to hierarchy
addSubview(controller.view)
// Use constraints to ensure proper sizing
NSLayoutConstraint.activate([
controller.view.leadingAnchor.constraint(equalTo: leadingAnchor),
controller.view.trailingAnchor.constraint(equalTo: trailingAnchor),
controller.view.topAnchor.constraint(equalTo: topAnchor),
controller.view.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
// Override layoutSubviews to make sure the player view is properly sized
override func layoutSubviews() {
super.layoutSubviews()
playerController?.view.frame = bounds
}
// MARK: - Public Methods
func setSource(_ source: [String: Any]) {
self.source = source
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.onVideoLoadStart?(["target": self.reactTag as Any])
// Store external subtitle data
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
if let uri = source["uri"] as? String, let url = URL(string: uri) {
print("Loading file: \(url.absoluteString)")
self.playerController?.playUrl = url
// Set start position if available
if let startPosition = source["startPosition"] as? Double {
self.playerController?.startPosition = startPosition
}
self.playerController?.loadFile(url)
// Set video to fill the screen
self.setVideoScalingMode("cover")
// Add external subtitles after the video is loaded
self.setInitialExternalSubtitles()
self.onVideoLoadEnd?(["target": self.reactTag as Any])
} else {
self.onVideoError?(["error": "Invalid or empty URI"])
}
}
}
func startPictureInPicture() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.onPipStarted?(["pipStarted": false, "target": self.reactTag as Any])
}
}
func play() {
playerController?.play()
}
func pause() {
playerController?.pause()
}
func stop() {
playerController?.command("stop", args: [])
}
func seekTo(_ time: Int32) {
let seconds = Double(time) / 1000.0
print("Seeking to absolute position: \(seconds) seconds")
playerController?.command("seek", args: ["\(seconds)", "absolute"])
}
func setAudioTrack(_ trackIndex: Int) {
playerController?.command("set", args: ["aid", "\(trackIndex)"])
}
func getAudioTracks() -> [[String: Any]] {
guard let playerController = playerController else {
return []
}
// Get track list as a node
guard let trackListStr = playerController.getNode("track-list") else {
return []
}
// Parse the JSON string into an array
guard let data = trackListStr.data(using: .utf8),
let trackList = try? JSONSerialization.jsonObject(with: data) as? [Any]
else {
return []
}
// Filter to audio tracks only
var audioTracks: [[String: Any]] = []
for case let track as [String: Any] in trackList {
if let type = track["type"] as? String, type == "audio" {
let id = track["id"] as? Int ?? 0
let title = track["title"] as? String ?? "Audio \(id)"
let lang = track["lang"] as? String ?? "unknown"
let selected = track["selected"] as? Bool ?? false
audioTracks.append([
"id": id,
"title": title,
"language": lang,
"selected": selected,
])
}
}
return audioTracks
}
func setSubtitleTrack(_ trackIndex: Int) {
playerController?.command("set", args: ["sid", "\(trackIndex)"])
}
func getSubtitleTracks() -> [[String: Any]] {
guard let playerController = playerController else {
return []
}
// Get track list as a node
guard let trackListStr = playerController.getNode("track-list") else {
return []
}
// Parse the JSON string into an array
guard let data = trackListStr.data(using: .utf8),
let trackList = try? JSONSerialization.jsonObject(with: data) as? [Any]
else {
return []
}
// Filter to subtitle tracks only
var subtitleTracks: [[String: Any]] = []
for case let track as [String: Any] in trackList {
if let type = track["type"] as? String, type == "sub" {
let id = track["id"] as? Int ?? 0
let title = track["title"] as? String ?? "Subtitle \(id)"
let lang = track["lang"] as? String ?? "unknown"
let selected = track["selected"] as? Bool ?? false
subtitleTracks.append([
"id": id,
"title": title,
"language": lang,
"selected": selected,
])
}
}
return subtitleTracks
}
func setSubtitleURL(_ subtitleURL: String, name: String) {
guard let url = URL(string: subtitleURL) else { return }
print("Adding subtitle: \(name) from \(subtitleURL)")
// Add the subtitle file
playerController?.command("sub-add", args: [url.absoluteString])
}
@objc
func setVideoScalingMode(_ mode: String) {
// Mode can be: "contain" (letterbox), "cover" (crop/fill), or "stretch"
guard let playerController = playerController else { return }
switch mode.lowercased() {
case "cover", "fill", "crop":
// Fill the screen, cropping if necessary
playerController.command("set", args: ["panscan", "1.0"])
playerController.command("set", args: ["video-unscaled", "no"])
playerController.command("set", args: ["video-aspect-override", "no"])
// Center the crop
playerController.command("set", args: ["video-align-x", "0.5"])
playerController.command("set", args: ["video-align-y", "0.5"])
case "stretch":
// Stretch to fill without maintaining aspect ratio
playerController.command("set", args: ["panscan", "0.0"])
playerController.command("set", args: ["video-unscaled", "no"])
playerController.command("set", args: ["video-aspect-override", "-1"])
// No need for alignment as it stretches to fill entire area
case "contain", "letterbox", "fit":
// Keep aspect ratio, fit within screen (letterbox)
playerController.command("set", args: ["panscan", "0.0"])
playerController.command("set", args: ["video-unscaled", "no"])
playerController.command("set", args: ["video-aspect-override", "no"])
// Set alignment to center
playerController.command("set", args: ["video-align-x", "0.5"])
playerController.command("set", args: ["video-align-y", "0.5"])
default:
break
}
}
private func setInitialExternalSubtitles() {
if let externalSubtitles = self.externalSubtitles {
for subtitle in externalSubtitles {
if let subtitleName = subtitle["name"],
let subtitleURL = subtitle["DeliveryUrl"]
{
print("Adding external subtitle: \(subtitleName) from \(subtitleURL)")
setSubtitleURL(subtitleURL, name: subtitleName)
}
}
}
}
// MARK: - Private Methods
private func isPaused() -> Bool {
return playerController?.getFlag(MpvProperty.pause) ?? true
}
private func isBuffering() -> Bool {
return playerController?.getFlag(MpvProperty.pausedForCache) ?? false
}
private func getCurrentTime() -> Double {
return playerController?.getDouble(MpvProperty.timePosition) ?? 0
}
private func getVideoDuration() -> Double {
return playerController?.getDouble(MpvProperty.duration) ?? 0
}
// MARK: - Cleanup
override func removeFromSuperview() {
cleanup()
super.removeFromSuperview()
}
private func cleanup() {
// Check if we already cleaned up
print("Cleaning up player")
guard playerController != nil else { return }
// First stop playback
stop()
// Break reference cycles
playerController?.mpvDelegate = nil
// Remove from view hierarchy
playerController?.view.removeFromSuperview()
// Release references
playerController = nil
}
deinit {
cleanup()
}
// Check if player needs reset when the view appears
override func didMoveToWindow() {
super.didMoveToWindow()
// If we're returning to the window and player is missing, reset
if window != nil && playerController == nil {
setupView()
// Reload previous source if available
if let source = source {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.setSource(source)
}
}
}
}
}
// MARK: - MPV Player Delegate
extension MpvPlayerView: MpvPlayerDelegate {
// Move the static properties to class level
private static var lastTimePositionUpdate = Date(timeIntervalSince1970: 0)
func propertyChanged(mpv: OpaquePointer, propertyName: String, value: Any?) {
// Add throttling for frequently updated properties
switch propertyName {
case MpvProperty.timePosition:
// Throttle timePosition updates to once per second
let now = Date()
if now.timeIntervalSince(MpvPlayerView.lastTimePositionUpdate) < 1.0 {
return
}
MpvPlayerView.lastTimePositionUpdate = now
if let position = value as? Double {
let timeMs = position * 1000
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
print("IsPlaying: \(!self.isPaused())")
self.onVideoProgress?([
"currentTime": timeMs,
"duration": self.getVideoDuration() * 1000,
"isPlaying": !self.isPaused(),
"isBuffering": self.isBuffering(),
"target": self.reactTag as Any,
])
}
}
case MpvProperty.pausedForCache:
// We want to respond immediately to buffering state changes
let isBuffering = value as? Bool ?? false
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.onVideoStateChange?([
"isBuffering": isBuffering, "target": self.reactTag as Any,
"isPlaying": !self.isPaused(),
"state": self.isPaused() ? "Paused" : "Playing",
])
}
case MpvProperty.pause:
// We want to respond immediately to play/pause state changes
if let isPaused = value as? Bool {
let state = isPaused ? "Paused" : "Playing"
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
print("onPlaybackStateChanged: \(state)")
self.onPlaybackStateChanged?([
"state": state,
"isPlaying": !isPaused,
"isBuffering": self.isBuffering(),
"currentTime": self.getCurrentTime() * 1000,
"duration": self.getVideoDuration() * 1000,
"target": self.reactTag as Any,
])
}
}
default:
break
}
}
}
// MARK: - Player Controller
final class MpvGLViewController: GLKViewController {
// MARK: - Properties
var mpv: OpaquePointer!
var mpvGL: OpaquePointer!
weak var mpvDelegate: MpvPlayerDelegate?
var queue: DispatchQueue = DispatchQueue(label: "mpv", qos: .userInteractive)
private var defaultFBO: GLint = -1
var playUrl: URL?
var startPosition: Double?
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupContext()
setupMpv()
if let url = playUrl {
self.loadFile(url)
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
print("GLKViewController viewWillAppear")
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
print("GLKViewController viewDidAppear")
}
deinit {
// Clean up on deallocation
if mpvGL != nil {
mpv_render_context_free(mpvGL)
mpvGL = nil
}
if mpv != nil {
mpv_terminate_destroy(mpv)
mpv = nil
}
}
// MARK: - Setup
func setupContext() {
print("Setting up OpenGL ES context")
let context = EAGLContext(api: .openGLES3)!
if context == nil {
print("ERROR: Failed to create OpenGL ES context")
return
}
let isSuccess = EAGLContext.setCurrent(context)
if !isSuccess {
print("ERROR: Failed to set current GL context")
return
}
// Set the context on our GLKView
let glkView = self.view as! GLKView
glkView.context = context
print("Successfully set up OpenGL ES context")
}
func setupMpv() {
print("Setting up MPV")
mpv = mpv_create()
if mpv == nil {
print("ERROR: failed creating mpv context\n")
exit(1)
}
// https://mpv.io/manual/stable/#options
#if DEBUG
checkError(mpv_request_log_messages(mpv, "debug"))
#else
checkError(mpv_request_log_messages(mpv, "no"))
#endif
#if os(macOS)
checkError(mpv_set_option_string(mpv, "input-media-keys", "yes"))
#endif
// Set options
checkError(mpv_set_option_string(mpv, "subs-match-os-language", "yes"))
checkError(mpv_set_option_string(mpv, "subs-fallback", "yes"))
checkError(mpv_set_option_string(mpv, "hwdec", "auto-copy"))
checkError(mpv_set_option_string(mpv, "vo", "libmpv"))
checkError(mpv_set_option_string(mpv, "profile", "gpu-hq"))
checkError(mpv_set_option_string(mpv, "gpu-api", "opengl"))
// Add in setupMpv before initialization
checkError(mpv_set_option_string(mpv, "opengl-es", "yes"))
checkError(mpv_set_option_string(mpv, "opengl-version", "3"))
// Initialize MPV
checkError(mpv_initialize(mpv))
// Set starting point if available
if let startPos = startPosition {
let startPosString = String(format: "%.1f", startPos)
print("Setting initial start position to \(startPosString)")
checkError(mpv_set_option_string(mpv, "start", startPosString))
}
// Set up rendering
print("Setting up MPV GL rendering context")
let api = UnsafeMutableRawPointer(
mutating: (MPV_RENDER_API_TYPE_OPENGL as NSString).utf8String)
var initParams = mpv_opengl_init_params(
get_proc_address: {
(ctx, name) in
return MpvGLViewController.getProcAddress(ctx, name)
},
get_proc_address_ctx: nil
)
withUnsafeMutablePointer(to: &initParams) { initParams in
var params = [
mpv_render_param(type: MPV_RENDER_PARAM_API_TYPE, data: api),
mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, data: initParams),
mpv_render_param(),
]
if mpv_render_context_create(&mpvGL, mpv, &params) < 0 {
puts("ERROR: failed to initialize mpv GL context")
exit(1)
}
print("Successfully created MPV GL render context")
mpv_render_context_set_update_callback(
mpvGL,
mpvGLUpdate,
UnsafeMutableRawPointer(Unmanaged.passUnretained(self.view).toOpaque())
)
}
// Observe properties
mpv_observe_property(mpv, 0, MpvProperty.videoParamsSigPeak, MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, 0, MpvProperty.pausedForCache, MPV_FORMAT_FLAG)
mpv_observe_property(mpv, 0, MpvProperty.timePosition, MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, 0, MpvProperty.duration, MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, 0, MpvProperty.pause, MPV_FORMAT_FLAG)
// Set wakeup callback
mpv_set_wakeup_callback(
self.mpv,
{ (ctx) in
let client = unsafeBitCast(ctx, to: MpvGLViewController.self)
client.readEvents()
}, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()))
print("MPV setup complete")
// Configure GLKView properly for better performance
let glkView = self.view as! GLKView
glkView.enableSetNeedsDisplay = false // Allow continuous rendering
glkView.drawableMultisample = .multisample4X // Might help or hurt - test both
glkView.drawableColorFormat = .RGBA8888
// Set higher preferred frame rate
self.preferredFramesPerSecond = 60 // Or even higher on newer devices
}
// MARK: - MPV Methods
func loadFile(_ url: URL) {
print("Loading file: \(url.absoluteString)")
var args = [url.absoluteString]
args.append("replace")
print("MPV Command: loadfile with args \(args)")
command("loadfile", args: args.map { $0 as String? })
// Set video settings for visibility
command("set", args: ["video-unscaled", "no"])
command("set", args: ["panscan", "1.0"]) // Ensure video fills screen
}
func togglePause() {
getFlag(MpvProperty.pause) ? play() : pause()
}
func play() {
setFlag(MpvProperty.pause, false)
}
func pause() {
setFlag(MpvProperty.pause, true)
}
func getDouble(_ name: String) -> Double {
var data = 0.0
mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &data)
return data
}
func getNode(_ name: String) -> String? {
guard let cString = mpv_get_property_string(mpv, name) else { return nil }
defer {
mpv_free(UnsafeMutableRawPointer(mutating: cString))
}
return String(cString: cString)
}
func getFlag(_ name: String) -> Bool {
var data = Int64()
mpv_get_property(mpv, name, MPV_FORMAT_FLAG, &data)
return data > 0
}
func setFlag(_ name: String, _ flag: Bool) {
guard mpv != nil else { return }
var data: Int = flag ? 1 : 0
mpv_set_property(mpv, name, MPV_FORMAT_FLAG, &data)
}
func command(
_ command: String,
args: [String?] = [],
checkForErrors: Bool = true,
returnValueCallback: ((Int32) -> Void)? = nil
) {
guard mpv != nil else {
return
}
var cargs = makeCArgs(command, args).map { $0.flatMap { UnsafePointer<CChar>(strdup($0)) } }
defer {
for ptr in cargs where ptr != nil {
free(UnsafeMutablePointer(mutating: ptr!))
}
}
let returnValue = mpv_command(mpv, &cargs)
if checkForErrors {
checkError(returnValue)
}
if let cb = returnValueCallback {
cb(returnValue)
}
}
private func makeCArgs(_ command: String, _ args: [String?]) -> [String?] {
if !args.isEmpty, args.last == nil {
fatalError("Command do not need a nil suffix")
}
var strArgs = args
strArgs.insert(command, at: 0)
strArgs.append(nil)
return strArgs
}
// MARK: - Event Processing
func readEvents() {
queue.async { [self] in
while self.mpv != nil {
let event = mpv_wait_event(self.mpv, 0)
if event!.pointee.event_id == MPV_EVENT_NONE {
break
}
switch event!.pointee.event_id {
case MPV_EVENT_PROPERTY_CHANGE:
let dataOpaquePtr = OpaquePointer(event!.pointee.data)
if let property = UnsafePointer<mpv_event_property>(dataOpaquePtr)?.pointee {
let propertyName = String(cString: property.name)
// Handle different property types
var value: Any?
switch propertyName {
case MpvProperty.pausedForCache, MpvProperty.pause:
if property.format == MPV_FORMAT_FLAG,
let data = property.data
{
let boolValue =
UnsafePointer<Bool>(OpaquePointer(data))?.pointee ?? false
value = boolValue
}
case MpvProperty.timePosition, MpvProperty.duration:
if property.format == MPV_FORMAT_DOUBLE,
let data = property.data
{
let doubleValue =
UnsafePointer<Double>(OpaquePointer(data))?.pointee ?? 0.0
value = doubleValue
}
default:
break
}
// Notify delegate if we have a value
if let value = value {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.mpvDelegate?.propertyChanged(
mpv: self.mpv, propertyName: propertyName, value: value)
}
}
}
case MPV_EVENT_SHUTDOWN:
mpv_render_context_free(mpvGL)
mpv_terminate_destroy(mpv)
mpv = nil
print("event: shutdown\n")
break
case MPV_EVENT_LOG_MESSAGE:
let msg = UnsafeMutablePointer<mpv_event_log_message>(
OpaquePointer(event!.pointee.data))
print(
"[\(String(cString: (msg!.pointee.prefix)!))] \(String(cString: (msg!.pointee.level)!)): \(String(cString: (msg!.pointee.text)!))",
terminator: "")
default:
let eventName = mpv_event_name(event!.pointee.event_id)
print("event: \(String(cString: (eventName)!))")
}
}
}
}
private func checkError(_ status: CInt) {
if status < 0 {
print("MPV API error: \(String(cString: mpv_error_string(status)))\n")
}
}
private var machine: String {
var systeminfo = utsname()
uname(&systeminfo)
return withUnsafeBytes(of: &systeminfo.machine) { bufPtr -> String in
let data = Data(bufPtr)
if let lastIndex = data.lastIndex(where: { $0 != 0 }) {
return String(data: data[0...lastIndex], encoding: .isoLatin1)!
} else {
return String(data: data, encoding: .isoLatin1)!
}
}
}
// MARK: - GL Rendering
override func glkView(_ view: GLKView, drawIn rect: CGRect) {
guard let mpvGL else {
return
}
// fill black background
glClearColor(0, 0, 0, 0)
glClear(UInt32(GL_COLOR_BUFFER_BIT))
glGetIntegerv(UInt32(GL_FRAMEBUFFER_BINDING), &defaultFBO)
var dims: [GLint] = [0, 0, 0, 0]
glGetIntegerv(GLenum(GL_VIEWPORT), &dims)
var data = mpv_opengl_fbo(
fbo: Int32(defaultFBO),
w: Int32(dims[2]),
h: Int32(dims[3]),
internal_format: 0
)
var flip: CInt = 1
withUnsafeMutablePointer(to: &flip) { flip in
withUnsafeMutablePointer(to: &data) { data in
var params = [
mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_FBO, data: data),
mpv_render_param(type: MPV_RENDER_PARAM_FLIP_Y, data: flip),
mpv_render_param(),
]
mpv_render_context_render(mpvGL, &params)
}
}
}
private static func getProcAddress(_: UnsafeMutableRawPointer?, _ name: UnsafePointer<Int8>?)
-> UnsafeMutableRawPointer?
{
let symbolName = CFStringCreateWithCString(
kCFAllocatorDefault, name, CFStringBuiltInEncodings.ASCII.rawValue)
let identifier = CFBundleGetBundleWithIdentifier("com.apple.opengles" as CFString)
return CFBundleGetFunctionPointerForName(identifier, symbolName)
}
}
private func mpvGLUpdate(_ ctx: UnsafeMutableRawPointer?) {
let glView = unsafeBitCast(ctx, to: GLKView.self)
DispatchQueue.main.async {
glView.display()
}
}

View File

@@ -1,5 +0,0 @@
import { requireNativeModule } from "expo-modules-core";
// It loads the native module object from the JSI or falls back to
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
export default requireNativeModule("MpvPlayer");

View File

@@ -1,10 +1,10 @@
import ExpoModulesCore
#if os(tvOS)
import TVVLCKit
import TVVLCKit
#else
import MobileVLCKit
import MobileVLCKit
#endif
import UIKit
class VlcPlayer3View: ExpoView {
private var mediaPlayer: VLCMediaPlayer?
@@ -16,7 +16,7 @@ class VlcPlayer3View: ExpoView {
private var lastReportedIsPlaying: Bool?
private var customSubtitles: [(internalName: String, originalName: String)] = []
private var startPosition: Int32 = 0
private var isMediaReady: Bool = false
private var externalSubtitles: [[String: String]]?
private var externalTrack: [String: String]?
private var progressTimer: DispatchSourceTimer?
private var isStopping: Bool = false // Define isStopping here
@@ -61,7 +61,7 @@ class VlcPlayer3View: ExpoView {
}
// MARK: - Public Methods
func startPictureInPicture() { }
func startPictureInPicture() {}
@objc func play() {
self.mediaPlayer?.play()
@@ -109,6 +109,7 @@ class VlcPlayer3View: ExpoView {
self.externalTrack = source["externalTrack"] as? [String: String]
var initOptions = source["initOptions"] as? [Any] ?? []
self.startPosition = source["startPosition"] as? Int32 ?? 0
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
initOptions.append("--start-time=\(self.startPosition)")
guard let uri = source["uri"] as? String, !uri.isEmpty else {
@@ -143,8 +144,8 @@ class VlcPlayer3View: ExpoView {
media.addOptions(mediaOptions)
self.mediaPlayer?.media = media
self.setInitialExternalSubtitles()
self.hasSource = true
if autoplay {
print("Playing...")
self.play()
@@ -182,9 +183,9 @@ class VlcPlayer3View: ExpoView {
return
}
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true)
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: false)
if let result = result {
let internalName = "Track \(self.customSubtitles.count + 1)"
let internalName = "Track \(self.customSubtitles.count)"
print("Subtitle added with result: \(result) \(internalName)")
self.customSubtitles.append((internalName: internalName, originalName: name))
} else {
@@ -192,6 +193,19 @@ class VlcPlayer3View: ExpoView {
}
}
private func setInitialExternalSubtitles() {
if let externalSubtitles = self.externalSubtitles {
for subtitle in externalSubtitles {
if let subtitleName = subtitle["name"],
let subtitleURL = subtitle["DeliveryUrl"]
{
print("Setting external subtitle: \(subtitleName) \(subtitleURL)")
self.setSubtitleURL(subtitleURL, name: subtitleName)
}
}
}
}
@objc func getSubtitleTracks() -> [[String: Any]]? {
guard let mediaPlayer = self.mediaPlayer else {
return nil
@@ -276,16 +290,6 @@ class VlcPlayer3View: ExpoView {
print("Debug: Current time: \(currentTimeMs)")
if currentTimeMs >= 0 && currentTimeMs < durationMs {
if player.isPlaying && !self.isMediaReady {
self.isMediaReady = true
// Set external track subtitle when starting.
if let externalTrack = self.externalTrack {
if let name = externalTrack["name"], !name.isEmpty {
let deliveryUrl = externalTrack["DeliveryUrl"] ?? ""
self.setSubtitleURL(deliveryUrl, name: name)
}
}
}
self.onVideoProgress?([
"currentTime": currentTimeMs,
"duration": durationMs,

View File

@@ -12,7 +12,6 @@ Pod::Spec.new do |s|
s.dependency 'ExpoModulesCore'
s.ios.dependency 'VLCKit', s.version
s.tvos.dependency 'VLCKit', s.version
s.dependency 'Alamofire', '~> 5.10'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {

View File

@@ -6,21 +6,21 @@
"submodule-reload": "git submodule update --init --remote --recursive",
"clean": "echo y | expo prebuild --clean",
"start": "bun run submodule-reload && expo start",
"ios": "EXPO_TV=0 expo run:ios",
"ios:tv": "EXPO_TV=1 expo run:ios",
"android": "EXPO_TV=0 expo run:android",
"android:tv": "EXPO_TV=1 expo run:android",
"prebuild": "EXPO_TV=0 bun run clean",
"prebuild:tv": "EXPO_TV=1 bun run clean",
"ios": "cross-env EXPO_TV=0 expo run:ios",
"ios:tv": "cross-env EXPO_TV=1 expo run:ios",
"android": "cross-env EXPO_TV=0 expo run:android",
"android:tv": "cross-env EXPO_TV=1 expo run:android",
"prebuild": "cross-env EXPO_TV=0 bun run clean",
"prebuild:tv": "cross-env EXPO_TV=1 bun run clean",
"build:android:local": "cd android && cross-env NODE_ENV=production ./gradlew assembleRelease",
"prepare": "husky",
"check": "biome check .",
"lint": "biome check --write --unsafe"
},
"dependencies": {
"@bottom-tabs/react-navigation": "0.8.6",
"@config-plugins/ffmpeg-kit-react-native": "^9.0.0",
"@expo/config-plugins": "~9.0.15",
"@expo/react-native-action-sheet": "^4.1.0",
"@expo/react-native-action-sheet": "^4.1.1",
"@expo/vector-icons": "^14.0.4",
"@futurejj/react-native-visibility-sensor": "^1.3.10",
"@gorhom/bottom-sheet": "^5.1.0",
@@ -31,7 +31,7 @@
"@react-navigation/bottom-tabs": "^7.2.0",
"@react-navigation/material-top-tabs": "^7.1.0",
"@react-navigation/native": "^7.0.14",
"@shopify/flash-list": "1.7.3",
"@shopify/flash-list": "1.8.2",
"@tanstack/react-query": "^5.66.0",
"add": "^2.0.6",
"axios": "^1.7.9",
@@ -57,7 +57,7 @@
"expo-router": "~4.0.17",
"expo-screen-orientation": "~8.0.4",
"expo-sensors": "~14.0.2",
"expo-sharing": "~13.1.0",
"expo-sharing": "~13.0.1",
"expo-splash-screen": "~0.29.22",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.8",
@@ -71,7 +71,7 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"react-i18next": "^15.4.0",
"react-native": "npm:react-native-tvos@~0.77.0-0",
"react-native": "npm:react-native-tvos@~0.77.2-0",
"react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "0.8.6",
"react-native-circular-progress": "^1.4.1",
@@ -121,6 +121,7 @@
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "^19.0.0",
"@types/uuid": "^10.0.0",
"cross-env": "^7.0.3",
"husky": "^9.1.7",
"lint-staged": "^15.5.0",
"postinstall-postinstall": "^2.1.0",
@@ -135,6 +136,6 @@
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": ["biome check --write --unsafe"],
"*.{json,md}": ["biome format --write"]
"*.{json}": ["biome format --write"]
}
}

View File

@@ -1,5 +1,6 @@
import { useHaptic } from "@/hooks/useHaptic";
import useImageStorage from "@/hooks/useImageStorage";
import { useInterval } from "@/hooks/useInterval";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device";
import useDownloadHelper from "@/utils/download";
@@ -18,6 +19,7 @@ import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import BackGroundDownloader from "@kesha-antonov/react-native-background-downloader";
import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
@@ -38,6 +40,7 @@ import {
import { useTranslation } from "react-i18next";
import { AppState, type AppStateStatus, Platform } from "react-native";
import { toast } from "sonner-native";
import { Bitrate } from "../components/BitrateSelector";
import { apiAtom } from "./JellyfinProvider";
export type DownloadedItem = {
@@ -66,7 +69,7 @@ function useDownloadProvider() {
const { saveSeriesPrimaryImage } = useDownloadHelper();
const { saveImage } = useImageStorage();
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
let [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
const successHapticFeedback = useHaptic("success");
@@ -74,6 +77,17 @@ function useDownloadProvider() {
return api?.accessToken;
}, [api]);
const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === DownloadMethod.Optimized,
[settings],
);
const getDownloadUrl = (process: JobStatus) => {
return usingOptimizedServer
? `${settings.optimizedVersionsServerUrl}download/${process.id}`
: process.inputUrl;
};
const { data: downloadedFiles, refetch } = useQuery({
queryKey: ["downloadedItems"],
queryFn: getAllDownloadedItems,
@@ -164,6 +178,64 @@ function useDownloadProvider() {
enabled: settings?.downloadMethod === DownloadMethod.Optimized,
});
/// Cant use the background downloader callback. As its not triggered if size is unknown.
const updateProgress = async () => {
if (settings?.downloadMethod === DownloadMethod.Optimized) {
return;
}
// const response = await getSessionApi(api).getSessions({
// activeWithinSeconds: 300,
// });
const tasks = await BackGroundDownloader.checkForExistingDownloads();
// check if processes are missing
const missingProcesses = tasks
.filter((t) => !processes.some((p) => p.id === t.id))
.map((t) => {
return t.metadata;
});
processes = [...processes, ...missingProcesses];
const updatedProcesses = processes.map((p) => {
// const result = response.data.find((s) => s.Id == p.sessionId);
// if (result) {
// return {
// ...p,
// progress: result.TranscodingInfo?.CompletionPercentage,
// };
// }
// fallback. Doesn't really work for transcodes as they may be a lot smaller.
// We make an wild guess by comparing bitrates
const task = tasks.find((s) => s.id === p.id);
if (task) {
let progress = p.progress;
let size = p.mediaSource.Size;
const maxBitrate = p.maxBitrate.value;
if (maxBitrate && maxBitrate < p.mediaSource.Bitrate) {
size = (size / p.mediaSource.Bitrate) * maxBitrate;
}
progress = (100 / size) * task.bytesDownloaded;
if (progress >= 100) {
progress = 99;
}
return {
...p,
progress,
};
}
return p;
});
setProcesses(updatedProcesses);
};
useInterval(updateProgress, 2000);
useEffect(() => {
const checkIfShouldStartDownload = async () => {
if (processes.length === 0) return;
@@ -176,18 +248,25 @@ function useDownloadProvider() {
const removeProcess = useCallback(
async (id: string) => {
const deviceId = await getOrSetDeviceId();
if (!deviceId || !authHeader || !settings?.optimizedVersionsServerUrl)
return;
if (!deviceId || !authHeader) return;
try {
await cancelJobById({
authHeader,
id,
url: settings?.optimizedVersionsServerUrl,
});
} catch (error) {
console.error(error);
if (usingOptimizedServer) {
try {
await cancelJobById({
authHeader,
id,
url: settings?.optimizedVersionsServerUrl,
});
} catch (error) {
console.error(error);
}
}
setProcesses((prev: any[]) => {
return prev.filter(
(process: { itemId: string | undefined }) => process.id !== id,
);
});
},
[settings?.optimizedVersionsServerUrl, authHeader],
);
@@ -238,8 +317,9 @@ function useDownloadProvider() {
BackGroundDownloader?.download({
id: process.id,
url: `${settings?.optimizedVersionsServerUrl}download/${process.id}`,
url: getDownloadUrl(process),
destination: `${baseDirectory}/${process.item.Id}.mp4`,
metadata: process,
})
.begin(() => {
setProcesses((prev) =>
@@ -256,6 +336,9 @@ function useDownloadProvider() {
);
})
.progress((data) => {
if (!usingOptimizedServer) {
return;
}
const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
setProcesses((prev) =>
prev.map((p) =>
@@ -328,7 +411,12 @@ function useDownloadProvider() {
);
const startBackgroundDownload = useCallback(
async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => {
async (
url: string,
item: BaseItemDto,
mediaSource: MediaSourceInfo,
maxBitrate?: Bitrate,
) => {
if (!api || !item.Id || !authHeader)
throw new Error("startBackgroundDownload ~ Missing required params");
@@ -345,26 +433,42 @@ function useDownloadProvider() {
width: 500,
});
await saveImage(item.Id, itemImage?.uri);
const response = await axios.post(
`${settings?.optimizedVersionsServerUrl}optimize-version`,
{
url,
fileExtension,
deviceId,
itemId: item.Id,
item,
},
{
headers: {
"Content-Type": "application/json",
Authorization: authHeader,
if (usingOptimizedServer) {
const response = await axios.post(
`${settings?.optimizedVersionsServerUrl}optimize-version`,
{
url,
fileExtension,
deviceId,
itemId: item.Id,
item,
},
},
);
{
headers: {
"Content-Type": "application/json",
Authorization: authHeader,
},
},
);
if (response.status !== 201) {
throw new Error("Failed to start optimization job");
if (response.status !== 201) {
throw new Error("Failed to start optimization job");
}
} else {
const job: JobStatus = {
id: item.Id!,
deviceId: deviceId,
inputUrl: url,
item: item,
itemId: item.Id!,
mediaSource,
progress: 0,
maxBitrate,
status: "downloading",
timestamp: new Date(),
};
setProcesses([...processes, job]);
startDownload(job);
}
toast.success(
@@ -738,12 +842,39 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
}
export function useDownload() {
if (Platform.isTV) {
// Since tv doesn't do downloads, just return no-op functions for everything
return {
processes: [],
startBackgroundDownload: useCallback(
async (
_url: string,
_item: BaseItemDto,
_mediaSource: MediaSourceInfo,
_maxBitrate?: Bitrate,
) => {},
[],
),
downloadedFiles: [],
deleteAllFiles: async (): Promise<void> => {},
deleteFile: async (id: string): Promise<void> => {},
deleteItems: async (items: BaseItemDto[]) => {},
saveDownloadedItemInfo: (item: BaseItemDto, size?: number) => {},
removeProcess: (id: string) => {},
setProcesses: () => {},
startDownload: async (_process: JobStatus): Promise<void> => {},
getDownloadedItem: (itemId: string) => {},
deleteFileByType: async (_type: BaseItemDto["Type"]) => {},
appSizeUsage: async () => 0,
getDownloadedItemSize: (itemId: string) => {},
APP_CACHE_DOWNLOAD_DIRECTORY: "",
cleanCacheDirectory: async (): Promise<void> => {},
};
}
const context = useContext(DownloadContext);
if (context === null) {
throw new Error("useDownload must be used within a DownloadProvider");
}
if (Platform.isTV) {
throw new Error("useDownload is not supported on TVOS");
}
return context;
}

View File

@@ -1,108 +0,0 @@
import { storage } from "@/utils/mmkv";
import type { JobStatus } from "@/utils/optimize-server";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import * as Application from "expo-application";
import * as FileSystem from "expo-file-system";
import { atom, useAtom } from "jotai";
import type React from "react";
import { createContext, useCallback, useContext, useMemo } from "react";
export type DownloadedItem = {
item: Partial<BaseItemDto>;
mediaSource: MediaSourceInfo;
};
export const processesAtom = atom<JobStatus[]>([]);
const DownloadContext = createContext<ReturnType<
typeof useDownloadProvider
> | null>(null);
/**
* Dummy download provider for tvOS
*/
function useDownloadProvider() {
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
const downloadedFiles: DownloadedItem[] = [];
const removeProcess = useCallback(async (id: string) => {}, []);
const startDownload = useCallback(async (process: JobStatus) => {
return null;
}, []);
const startBackgroundDownload = useCallback(
async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => {
return null;
},
[],
);
const deleteAllFiles = async (): Promise<void> => {};
const deleteFile = async (id: string): Promise<void> => {};
const deleteItems = async (items: BaseItemDto[]) => {};
const cleanCacheDirectory = async () => {};
const deleteFileByType = async (type: BaseItemDto["Type"]) => {};
const appSizeUsage = useMemo(async () => {
return 0;
}, []);
function getDownloadedItem(itemId: string): DownloadedItem | null {
return null;
}
function saveDownloadedItemInfo(item: BaseItemDto, size = 0) {}
function getDownloadedItemSize(itemId: string): number {
const size = storage.getString(`downloadedItemSize-${itemId}`);
return size ? Number.parseInt(size) : 0;
}
const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`;
return {
processes,
startBackgroundDownload,
downloadedFiles,
deleteAllFiles,
deleteFile,
deleteItems,
saveDownloadedItemInfo,
removeProcess,
setProcesses,
startDownload,
getDownloadedItem,
deleteFileByType,
appSizeUsage,
getDownloadedItemSize,
APP_CACHE_DOWNLOAD_DIRECTORY,
cleanCacheDirectory,
};
}
export function DownloadProvider({ children }: { children: React.ReactNode }) {
const downloadProviderValue = useDownloadProvider();
return (
<DownloadContext.Provider value={downloadProviderValue}>
{children}
</DownloadContext.Provider>
);
}
export function useDownload() {
const context = useContext(DownloadContext);
if (context === null) {
throw new Error("useDownload must be used within a DownloadProvider");
}
return context;
}

View File

@@ -1,7 +1,7 @@
import type { Bitrate } from "@/components/BitrateSelector";
import { settingsAtom } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import native from "@/utils/profiles/native";
import generateDeviceProfile from "@/utils/profiles/native";
import type {
BaseItemDto,
MediaSourceInfo,
@@ -84,6 +84,7 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
}
try {
const native = await generateDeviceProfile();
const data = await getStreamUrl({
api,
deviceProfile: native,

View File

@@ -1,5 +1,6 @@
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
import { useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
createContext,
@@ -12,6 +13,12 @@ import React, {
} from "react";
import { AppState, type AppStateStatus } from "react-native";
interface WebSocketMessage {
MessageType: string;
Data: any;
// Add other fields as needed
}
interface WebSocketProviderProps {
children: ReactNode;
}
@@ -19,6 +26,9 @@ interface WebSocketProviderProps {
interface WebSocketContextType {
ws: WebSocket | null;
isConnected: boolean;
lastMessage: WebSocketMessage | null;
sendMessage: (message: any) => void;
clearLastMessage: () => void;
}
const WebSocketContext = createContext<WebSocketContextType | null>(null);
@@ -27,7 +37,8 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const api = useAtomValue(apiAtom);
const [ws, setWs] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
const router = useRouter();
const deviceId = useMemo(() => {
return getOrSetDeviceId();
}, []);
@@ -48,6 +59,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
let keepAliveInterval: number | null = null;
newWebSocket.onopen = () => {
console.log("WebSocket connection opened");
setIsConnected(true);
keepAliveInterval = setInterval(() => {
if (newWebSocket.readyState === WebSocket.OPEN) {
@@ -56,9 +68,23 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
}, 30000);
};
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
const reconnectDelay = 10000;
newWebSocket.onerror = (e) => {
console.error("WebSocket error:", e);
setIsConnected(false);
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
setTimeout(() => {
console.log(`WebSocket reconnect attempt ${reconnectAttempts}`);
connectWebSocket();
}, reconnectDelay);
} else {
console.warn("Max WebSocket reconnect attempts reached.");
}
};
newWebSocket.onclose = () => {
@@ -67,7 +93,15 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
}
setIsConnected(false);
};
newWebSocket.onmessage = (e) => {
try {
const message = JSON.parse(e.data);
console.log("[WS] Received message:", message);
setLastMessage(message); // Store the last message in context
} catch (error) {
console.error("Error parsing WebSocket message:", error);
}
};
setWs(newWebSocket);
return () => {
@@ -78,6 +112,41 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
};
}, [api, deviceId]);
useEffect(() => {
if (!lastMessage) {
return;
}
if (lastMessage.MessageType === "Play") {
handlePlayCommand(lastMessage.Data);
}
}, [lastMessage, router]);
const handlePlayCommand = useCallback(
(data: any) => {
if (!data || !data.ItemIds || !data.ItemIds.length) {
console.warn("[WS] Received Play command with no items");
return;
}
const itemId = data.ItemIds[0];
console.log(`[WS] Handling Play command for item: ${itemId}`);
router.push({
pathname: "/(auth)/player/direct-player",
params: {
itemId: itemId,
playCommand: data.PlayCommand || "PlayNow",
audioIndex: data.AudioStreamIndex?.toString(),
subtitleIndex: data.SubtitleStreamIndex?.toString(),
mediaSourceId: data.MediaSourceId || "",
bitrateValue: "",
offline: "false",
},
});
},
[router],
);
useEffect(() => {
const cleanup = connectWebSocket();
return cleanup;
@@ -126,9 +195,23 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
ws?.close();
};
}, [ws, connectWebSocket]);
const sendMessage = useCallback(
(message: any) => {
if (ws && isConnected) {
ws.send(JSON.stringify(message));
} else {
console.warn("Cannot send message: WebSocket is not connected");
}
},
[ws, isConnected],
);
const clearLastMessage = useCallback(() => {
setLastMessage(null);
}, []);
return (
<WebSocketContext.Provider value={{ ws, isConnected }}>
<WebSocketContext.Provider
value={{ ws, isConnected, lastMessage, sendMessage, clearLastMessage }}
>
{children}
</WebSocketContext.Provider>
);

48
renovate.json Normal file
View File

@@ -0,0 +1,48 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"description": "Default Renovate preset for Streamyfin repositories",
"extends": [
"config:base",
":disableDependencyDashboard",
":enableVulnerabilityAlertsWithLabel(security)",
":semanticCommits",
":timezone(Etc/UTC)",
"docker:enableMajor",
"group:testNonMajor",
"group:monorepos",
"helpers:pinGitHubActionDigests"
],
"addLabels": ["dependencies"],
"rebaseWhen": "conflicted",
"ignorePaths": ["**/node_modules/**", "**/bower_components/**"],
"lockFileMaintenance": {
"enabled": true,
"groupName": "lockfiles",
"schedule": ["every month"]
},
"packageRules": [
{
"description": "Add 'ci' and 'github-actions' labels to GitHub Action update PRs",
"matchManagers": ["github-actions"],
"addLabels": ["ci", "github-actions"]
},
{
"description": "Group minor and patch GitHub Action updates into a single PR",
"matchManagers": ["github-actions"],
"groupName": "CI dependencies",
"groupSlug": "ci-deps",
"matchUpdateTypes": ["minor", "patch"]
},
{
"description": "Group lock file maintenance updates",
"matchUpdateTypes": ["lockFileMaintenance"],
"groupName": "lockfiles",
"dependencyDashboardApproval": true
},
{
"description": "Add specific labels for Expo and React Native dependencies",
"matchPackagePatterns": ["expo", "react-native"],
"addLabels": ["expo", "react-native"]
}
]
}

View File

@@ -138,7 +138,8 @@
"hide_libraries": "Bibliotheken ausblenden",
"select_liraries_you_want_to_hide": "Wähl die Bibliotheken aus, die du im Bibliothekstab und auf der Startseite ausblenden möchtest.",
"disable_haptic_feedback": "Haptisches Feedback deaktivieren",
"default_quality": "Standardqualität"
"default_quality": "Standardqualität",
"disabled": "Deaktiviert"
},
"downloads": {
"downloads_title": "Downloads",
@@ -370,7 +371,9 @@
"audio_tracks": "Audiospuren:",
"playback_state": "Wiedergabestatus:",
"no_data_available": "Keine Daten verfügbar",
"index": "Index:"
"index": "Index:",
"continue_watching": "Weiterschauen",
"go_back": "Zurück"
},
"item_card": {
"next_up": "Als Nächstes",

View File

@@ -138,7 +138,9 @@
"hide_libraries": "Hide Libraries",
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"disable_haptic_feedback": "Disable Haptic Feedback",
"default_quality": "Default quality"
"default_quality": "Default quality",
"max_auto_play_episode_count": "Max auto play episode count",
"disabled": "Disabled"
},
"downloads": {
"downloads_title": "Downloads",
@@ -374,7 +376,9 @@
"audio_tracks": "Audio Tracks:",
"playback_state": "Playback State:",
"no_data_available": "No data available",
"index": "Index:"
"index": "Index:",
"continue_watching": "Continue Watching",
"go_back": "Go back"
},
"item_card": {
"next_up": "Next up",

480
translations/eo.json Normal file
View File

@@ -0,0 +1,480 @@
{
"login": {
"username_required": "Uzantnomo estas deviga",
"error_title": "Eraro",
"login_title": "Ensaluti",
"login_to_title": "Ensaluti al",
"username_placeholder": "Uzantnomo",
"password_placeholder": "Pasvorto",
"login_button": "Ensaluti",
"quick_connect": "Rapida Konekto",
"enter_code_to_login": "Enigu kodon {{code}} por ensaluti",
"failed_to_initiate_quick_connect": "Malsukcesis iniciati Rapidan Konekton",
"got_it": "Komprenita",
"connection_failed": "Konekto malsukcesis",
"could_not_connect_to_server": "Ne povis konekti al la servilo. Bonvolu kontroli la URL-on kaj vian retan konekton.",
"an_unexpected_error_occured": "Neatendita eraro okazis",
"change_server": "Ŝanĝi servilon",
"invalid_username_or_password": "Nevalida uzantnomo aŭ pasvorto",
"user_does_not_have_permission_to_log_in": "Uzanto ne havas permeson ensaluti",
"server_is_taking_too_long_to_respond_try_again_later": "Servilo respondas tro malrapide, provu denove poste",
"server_received_too_many_requests_try_again_later": "Servilo ricevis tro multajn petojn, provu denove poste.",
"there_is_a_server_error": "Estas servila eraro",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Neatendita eraro okazis. Ĉu vi enigis la ĝustan servilan URL-on?"
},
"server": {
"enter_url_to_jellyfin_server": "Enigu la URL-on al via Jellyfin-servilo",
"server_url_placeholder": "http(s)://via-servilo.com",
"connect_button": "Konekti",
"previous_servers": "antaŭaj serviloj",
"clear_button": "Forviŝi",
"search_for_local_servers": "Serĉi lokajn servilojn",
"searching": "Serĉante...",
"servers": "Serviloj"
},
"home": {
"no_internet": "Neniu Interreto",
"no_items": "Neniuj eroj",
"no_internet_message": "Ne zorgu, vi ankoraŭ povas spekti\nelsŝutitan enhavon.",
"go_to_downloads": "Iri al elŝutoj",
"oops": "Ho ve!",
"error_message": "Io misfunkciis.\nBonvolu elsaluti kaj reensaluti.",
"continue_watching": "Daŭrigi Spektadon",
"next_up": "Sekva",
"recently_added_in": "Ĵus Aldonita en {{libraryName}}",
"suggested_movies": "Sugestitaj Filmoj",
"suggested_episodes": "Sugestitaj Epizodoj",
"intro": {
"welcome_to_streamyfin": "Bonvenon al Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "Senpaga kaj malfermfonta kliento por Jellyfin.",
"features_title": "Trajtoj",
"features_description": "Streamyfin havas multajn trajtojn kaj integriĝas kun vasta gamo de programaroj, kiujn vi povas trovi en la agorda menuo, tiuj inkluzivas:",
"jellyseerr_feature_description": "Konekti al via Jellyseerr-instanco kaj peti filmojn rekte en la aplikaĵo.",
"downloads_feature_title": "Elŝutoj",
"downloads_feature_description": "Elŝutu filmojn kaj televidajn seriojn por vidi senkonekte. Uzu aŭ la defaŭltan metodon aŭ instalu la optimumigan servilon por elŝuti dosierojn en la fono.",
"chromecast_feature_description": "Ĵetu filmojn kaj televidajn seriojn al viaj Chromecast-aparatoj.",
"centralised_settings_plugin_title": "Centralizita Agorda Kromprogramo",
"centralised_settings_plugin_description": "Agordu agordojn de centralizita loko sur via Jellyfin-servilo. Ĉiuj klientaj agordoj por ĉiuj uzantoj estos sinkronigitaj aŭtomate.",
"done_button": "Farite",
"go_to_settings_button": "Iri al agordoj",
"read_more": "Legu pli"
},
"settings": {
"settings_title": "Agordoj",
"log_out_button": "Elsaluti",
"user_info": {
"user_info_title": "Uzantaj Informoj",
"user": "Uzanto",
"server": "Servilo",
"token": "Ĵetono",
"app_version": "Aplikaĵa Versio"
},
"quick_connect": {
"quick_connect_title": "Rapida Konekto",
"authorize_button": "Aŭtorizi Rapidan Konekton",
"enter_the_quick_connect_code": "Enigu la rapidan konektan kodon...",
"success": "Sukceso",
"quick_connect_autorized": "Rapida Konekto aŭtorizita",
"error": "Eraro",
"invalid_code": "Nevalida kodo",
"authorize": "Aŭtorizi"
},
"media_controls": {
"media_controls_title": "Mediaj Kontroloj",
"forward_skip_length": "Antaŭensalta longeco",
"rewind_length": "Rebobena longeco",
"seconds_unit": "s"
},
"audio": {
"audio_title": "Audio",
"set_audio_track": "Agordi Aŭdian Trakon De Antaŭa Ero",
"audio_language": "Aŭdia lingvo",
"audio_hint": "Elektu defaŭltan aŭdian lingvon.",
"none": "Neniu",
"language": "Lingvo"
},
"subtitles": {
"subtitle_title": "Subtekstoj",
"subtitle_language": "Subteksta lingvo",
"subtitle_mode": "Subteksta Reĝimo",
"set_subtitle_track": "Agordi Subtekstan Trakon De Antaŭa Ero",
"subtitle_size": "Subteksta Grandeco",
"subtitle_hint": "Agordu subtekstan preferon.",
"none": "Neniu",
"language": "Lingvo",
"loading": "Ŝarĝante",
"modes": {
"Default": "Defaŭlta",
"Smart": "Inteligenta",
"Always": "Ĉiam",
"None": "Neniu",
"OnlyForced": "NurDevigita"
}
},
"other": {
"other_title": "Alia",
"follow_device_orientation": "Aŭtomata rotacio",
"video_orientation": "Video-orientiĝo",
"orientation": "Orientiĝo",
"orientations": {
"DEFAULT": "Defaŭlta",
"ALL": "Ĉiuj",
"PORTRAIT": "Portreta",
"PORTRAIT_UP": "Portreta Supren",
"PORTRAIT_DOWN": "Portreta Malsupren",
"LANDSCAPE": "Pejzaĝa",
"LANDSCAPE_LEFT": "Pejzaĝa Maldekstren",
"LANDSCAPE_RIGHT": "Pejzaĝa Dekstren",
"OTHER": "Alia",
"UNKNOWN": "Nekonata"
},
"safe_area_in_controls": "Sekura areo en kontroloj",
"video_player": "Video-ludilo",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Eksperimenta + PiP)"
},
"show_custom_menu_links": "Montri Proprajn Menuajn Ligilojn",
"hide_libraries": "Kaŝi Bibliotekojn",
"select_liraries_you_want_to_hide": "Elektu la bibliotekojn, kiujn vi volas kaŝi de la Biblioteka langeto kaj hejmpaĝaj sekcioj.",
"disable_haptic_feedback": "Malŝalti Haptan Rimarkon",
"default_quality": "Defaŭlta kvalito"
},
"downloads": {
"downloads_title": "Elŝutoj",
"download_method": "Elŝuta metodo",
"remux_max_download": "Remux maksimuma elŝuto",
"auto_download": "Aŭtomata elŝuto",
"optimized_versions_server": "Optimumigitaj versioj servilo",
"save_button": "Konservi",
"optimized_server": "Optimumigita Servilo",
"optimized": "Optimumigita",
"default": "Defaŭlta",
"optimized_version_hint": "Enigu la URL-on por la optimumiga servilo. La URL devus inkluzivi http aŭ https kaj laŭvole la pordon.",
"read_more_about_optimized_server": "Legu pli pri la optimumiga servilo.",
"url": "URL",
"server_url_placeholder": "http(s)://domajno.org:pordo"
},
"plugins": {
"plugins_title": "Kromprogramoj",
"jellyseerr": {
"jellyseerr_warning": "Ĉi tiu integriĝo estas en siaj fruaj stadioj. Atendu ŝanĝojn.",
"server_url": "Servila URL",
"server_url_hint": "Ekzemplo: http(s)://via-gastiganto.url\n(aldonu pordon se necese)",
"server_url_placeholder": "Jellyseerr URL...",
"password": "Pasvorto",
"password_placeholder": "Enigu pasvorton por Jellyfin-uzanto {{username}}",
"save_button": "Konservi",
"clear_button": "Forviŝi",
"login_button": "Ensaluti",
"total_media_requests": "Totalaj mediaj petoj",
"movie_quota_limit": "Filma kvota limo",
"movie_quota_days": "Filmaj kvotaj tagoj",
"tv_quota_limit": "Televida kvota limo",
"tv_quota_days": "Televidaj kvotaj tagoj",
"reset_jellyseerr_config_button": "Restarigi Jellyseerr-agordon",
"unlimited": "Senlima",
"plus_n_more": "+{{n}} pli",
"order_by": {
"DEFAULT": "Defaŭlta",
"VOTE_COUNT_AND_AVERAGE": "Voĉdonkalkulo kaj mezumo",
"POPULARITY": "Populareco"
}
},
"marlin_search": {
"enable_marlin_search": "Ebligi Marlin Serĉon ",
"url": "URL",
"server_url_placeholder": "http(s)://domajno.org:pordo",
"marlin_search_hint": "Enigu la URL-on por la Marlin-servilo. La URL devus inkluzivi http aŭ https kaj laŭvole la pordon.",
"read_more_about_marlin": "Legu pli pri Marlin.",
"save_button": "Konservi",
"toasts": {
"saved": "Konservita"
}
}
},
"storage": {
"storage_title": "Stokado",
"app_usage": "Aplikaĵo {{usedSpace}}%",
"device_usage": "Aparato {{availableSpace}}%",
"size_used": "{{used}} el {{total}} uzata",
"delete_all_downloaded_files": "Forigi Ĉiujn Elŝutitajn Dosierojn"
},
"intro": {
"show_intro": "Montri enkondukon",
"reset_intro": "Restarigi enkondukon"
},
"logs": {
"logs_title": "Protokoloj",
"export_logs": "Eksporti protokolojn",
"click_for_more_info": "Klaku por pli da informoj",
"level": "Nivelo",
"no_logs_available": "Neniuj protokoloj disponeblaj",
"delete_all_logs": "Forigi ĉiujn protokolojn"
},
"languages": {
"title": "Lingvoj",
"app_language": "Aplikaĵa lingvo",
"app_language_description": "Elektu la lingvon por la aplikaĵo.",
"system": "Sistemo"
},
"toasts": {
"error_deleting_files": "Eraro forigante dosierojn",
"background_downloads_enabled": "Fonaj elŝutoj ebligitaj",
"background_downloads_disabled": "Fonaj elŝutoj malŝaltitaj",
"connected": "Konektita",
"could_not_connect": "Ne povis konekti",
"invalid_url": "Nevalida URL"
}
},
"sessions": {
"title": "Sesioj",
"no_active_sessions": "Neniuj aktivaj sesioj"
},
"downloads": {
"downloads_title": "Elŝutoj",
"tvseries": "Televidaj serioj",
"movies": "Filmoj",
"queue": "Vico",
"queue_hint": "Vico kaj elŝutoj perdiĝos ĉe aplikaĵa rekomenco",
"no_items_in_queue": "Neniuj eroj en vico",
"no_downloaded_items": "Neniuj elŝutitaj eroj",
"delete_all_movies_button": "Forigi ĉiujn Filmojn",
"delete_all_tvseries_button": "Forigi ĉiujn Televidajn Seriojn",
"delete_all_button": "Forigi ĉion",
"active_download": "Aktiva elŝuto",
"no_active_downloads": "Neniuj aktivaj elŝutoj",
"active_downloads": "Aktivaj elŝutoj",
"new_app_version_requires_re_download": "Nova aplikaĵa versio postulas re-elŝuton",
"new_app_version_requires_re_download_description": "La nova ĝisdatigo postulas, ke enhavo estu elŝutita denove. Bonvolu forigi ĉian elŝutitan enhavon kaj provi denove.",
"back": "Reen",
"delete": "Forigi",
"something_went_wrong": "Io misfunkciis",
"could_not_get_stream_url_from_jellyfin": "Ne povis akiri la fluan URL-on de Jellyfin",
"eta": "ETA {{eta}}",
"methods": "Metodoj",
"toasts": {
"you_are_not_allowed_to_download_files": "Vi ne rajtas elŝuti dosierojn.",
"deleted_all_movies_successfully": "Sukcese forigis ĉiujn filmojn!",
"failed_to_delete_all_movies": "Malsukcesis forigi ĉiujn filmojn",
"deleted_all_tvseries_successfully": "Sukcese forigis ĉiujn Televidajn Seriojn!",
"failed_to_delete_all_tvseries": "Malsukcesis forigi ĉiujn Televidajn Seriojn",
"download_cancelled": "Elŝuto nuligita",
"could_not_cancel_download": "Ne povis nuligi elŝuton",
"download_completed": "Elŝuto finita",
"download_started_for": "Elŝuto komenciĝis por {{item}}",
"item_is_ready_to_be_downloaded": "{{item}} estas preta por esti elŝutita",
"download_stated_for_item": "Elŝuto komenciĝis por {{item}}",
"download_failed_for_item": "Elŝuto malsukcesis por {{item}} - {{error}}",
"download_completed_for_item": "Elŝuto finita por {{item}}",
"queued_item_for_optimization": "Envicigis {{item}} por optimumigo",
"failed_to_start_download_for_item": "Malsukcesis komenci elŝutadon por {{item}}: {{message}}",
"server_responded_with_status_code": "Servilo respondis kun statuskodo {{statusCode}}",
"no_response_received_from_server": "Neniu respondo ricevita de la servilo",
"error_setting_up_the_request": "Eraro starigante la peton",
"failed_to_start_download_for_item_unexpected_error": "Malsukcesis komenci elŝutadon por {{item}}: Neatendita eraro",
"all_files_folders_and_jobs_deleted_successfully": "Ĉiuj dosieroj, dosierujoj kaj taskoj sukcese forigitaj",
"an_error_occured_while_deleting_files_and_jobs": "Eraro okazis dum forigo de dosieroj kaj taskoj",
"go_to_downloads": "Iri al elŝutoj"
}
}
},
"search": {
"search_here": "Serĉu ĉi tie...",
"search": "Serĉi...",
"x_items": "{{count}} eroj",
"library": "Biblioteko",
"discover": "Malkovri",
"no_results": "Neniuj rezultoj",
"no_results_found_for": "Neniuj rezultoj trovitaj por",
"movies": "Filmoj",
"series": "Serioj",
"episodes": "Epizodoj",
"collections": "Kolektoj",
"actors": "Aktoroj",
"request_movies": "Peti Filmojn",
"request_series": "Peti Seriojn",
"recently_added": "Ĵus Aldonita",
"recent_requests": "Lastatempaj Petoj",
"plex_watchlist": "Plex Spektolisto",
"trending": "Tendencaj",
"popular_movies": "Popularaj Filmoj",
"movie_genres": "Filmaj Ĝenroj",
"upcoming_movies": "Venontaj Filmoj",
"studios": "Studioj",
"popular_tv": "Populara Televido",
"tv_genres": "Televidaj Ĝenroj",
"upcoming_tv": "Venonta Televido",
"networks": "Retoj",
"tmdb_movie_keyword": "TMDB Filma Ŝlosilvorto",
"tmdb_movie_genre": "TMDB Filma Ĝenro",
"tmdb_tv_keyword": "TMDB Televida Ŝlosilvorto",
"tmdb_tv_genre": "TMDB Televida Ĝenro",
"tmdb_search": "TMDB Serĉo",
"tmdb_studio": "TMDB Studio",
"tmdb_network": "TMDB Reto",
"tmdb_movie_streaming_services": "TMDB Filmaj Fluservoj",
"tmdb_tv_streaming_services": "TMDB Televidaj Fluservoj"
},
"library": {
"no_items_found": "Neniuj eroj trovitaj",
"no_results": "Neniuj rezultoj",
"no_libraries_found": "Neniuj bibliotekoj trovitaj",
"item_types": {
"movies": "filmoj",
"series": "serioj",
"boxsets": "skatolaj aroj",
"items": "eroj"
},
"options": {
"display": "Vidigi",
"row": "Vico",
"list": "Listo",
"image_style": "Bildostilo",
"poster": "Afiŝo",
"cover": "Kovrilo",
"show_titles": "Montri titolojn",
"show_stats": "Montri statistikojn"
},
"filters": {
"genres": "Ĝenroj",
"years": "Jaroj",
"sort_by": "Ordigi laŭ",
"sort_order": "Orda ordo",
"asc": "Supreniranta",
"desc": "Malsupreniranta",
"tags": "Etikedoj"
}
},
"favorites": {
"series": "Serioj",
"movies": "Filmoj",
"episodes": "Epizodoj",
"videos": "Videoj",
"boxsets": "Skatolaj aroj",
"playlists": "Ludlistoj",
"noDataTitle": "Ankoraŭ neniuj favoratoj",
"noData": "Marku erojn kiel favoratojn por vidi ilin aperi ĉi tie por rapida aliro."
},
"custom_links": {
"no_links": "Neniuj ligiloj"
},
"player": {
"error": "Eraro",
"failed_to_get_stream_url": "Malsukcesis akiri la fluan URL-on",
"an_error_occured_while_playing_the_video": "Eraro okazis dum ludado de la video. Kontrolu protokolojn en agordoj.",
"client_error": "Klienta eraro",
"could_not_create_stream_for_chromecast": "Ne povis krei fluon por Chromecast",
"message_from_server": "Mesaĝo de servilo: {{message}}",
"video_has_finished_playing": "Video finis ludi!",
"no_video_source": "Neniu video-fonto...",
"next_episode": "Sekva Epizodo",
"refresh_tracks": "Refreŝigi Trakojn",
"subtitle_tracks": "Subtekstaj Trakoj:",
"audio_tracks": "Aŭdiaj Trakoj:",
"playback_state": "Ludada Stato:",
"no_data_available": "Neniuj datumoj disponeblaj",
"index": "Indekso:"
},
"item_card": {
"next_up": "Sekva",
"no_items_to_display": "Neniuj eroj por montri",
"cast_and_crew": "Rolantaro & Skiparo",
"series": "Serioj",
"seasons": "Sezonoj",
"season": "Sezono",
"no_episodes_for_this_season": "Neniuj epizodoj por ĉi tiu sezono",
"overview": "Superrigardo",
"more_with": "Pli kun {{name}}",
"similar_items": "Similaj eroj",
"no_similar_items_found": "Neniuj similaj eroj trovitaj",
"video": "Video",
"more_details": "Pli da detaloj",
"quality": "Kvalito",
"audio": "Audio",
"subtitles": "Subteksto",
"show_more": "Montri pli",
"show_less": "Montri malpli",
"appeared_in": "Aperis en",
"could_not_load_item": "Ne povis ŝarĝi eron",
"none": "Neniu",
"download": {
"download_season": "Elŝuti Sezonon",
"download_series": "Elŝuti Serion",
"download_episode": "Elŝuti Epizodon",
"download_movie": "Elŝuti Filmon",
"download_x_item": "Elŝuti {{item_count}} erojn",
"download_button": "Elŝuti",
"using_optimized_server": "Uzante optimumigitan servilon",
"using_default_method": "Uzante defaŭltan metodon"
}
},
"live_tv": {
"next": "Sekva",
"previous": "Antaŭa",
"live_tv": "Viva Televido",
"coming_soon": "Baldaŭ",
"on_now": "Nun",
"shows": "Spektakloj",
"movies": "Filmoj",
"sports": "Sportoj",
"for_kids": "Por Infanoj",
"news": "Novaĵoj"
},
"jellyseerr": {
"confirm": "Konfirmi",
"cancel": "Nuligi",
"yes": "Jes",
"whats_wrong": "Kio estas malĝusta?",
"issue_type": "Problema tipo",
"select_an_issue": "Elektu problemon",
"types": "Tipoj",
"describe_the_issue": "(laŭvola) Priskribu la problemon...",
"submit_button": "Sendi",
"report_issue_button": "Raporti problemon",
"request_button": "Peti",
"are_you_sure_you_want_to_request_all_seasons": "Ĉu vi certas, ke vi volas peti ĉiujn sezonojn?",
"failed_to_login": "Malsukcesis ensaluti",
"cast": "Rolantaro",
"details": "Detaloj",
"status": "Stato",
"original_title": "Originala Titolo",
"series_type": "Seria Tipo",
"release_dates": "Eldondatoj",
"first_air_date": "Unua Elsendo-dato",
"next_air_date": "Sekva Elsendo-dato",
"revenue": "Enspezo",
"budget": "Buĝeto",
"original_language": "Originala Lingvo",
"production_country": "Produktada Lando",
"studios": "Studioj",
"network": "Reto",
"currently_streaming_on": "Nuntempe Flusanta ĉe",
"advanced": "Altnivela",
"request_as": "Peti Kiel",
"tags": "Etikedoj",
"quality_profile": "Kvalita Profilo",
"root_folder": "Radika Dosierujo",
"season_all": "Sezono (ĉiuj)",
"season_number": "Sezono {{season_number}}",
"number_episodes": "{{episode_number}} Epizodoj",
"born": "Naskiĝis",
"appearances": "Aperoj",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr-servilo ne plenumas minimumajn versiajn postulojn! Bonvolu ĝisdatigi al almenaŭ 2.0.0",
"jellyseerr_test_failed": "Jellyseerr-testo malsukcesis. Bonvolu provi denove.",
"failed_to_test_jellyseerr_server_url": "Malsukcesis testi jellyseerr-servilan url-on",
"issue_submitted": "Problemo sendita!",
"requested_item": "Petis {{item}}!",
"you_dont_have_permission_to_request": "Vi ne havas permeson peti!",
"something_went_wrong_requesting_media": "Io misfunkciis petante medion!"
}
},
"tabs": {
"home": "Hejmo",
"search": "Serĉi",
"library": "Biblioteko",
"custom_links": "Propraj Ligiloj",
"favorites": "Favoratoj"
}
}

View File

@@ -138,7 +138,8 @@
"hide_libraries": "Ocultar bibliotecas",
"select_liraries_you_want_to_hide": "Selecciona las bibliotecas que quieres ocultar de la pestaña Bibliotecas y de Inicio.",
"disable_haptic_feedback": "Desactivar feedback háptico",
"default_quality": "Calidad por defecto"
"default_quality": "Calidad por defecto",
"disabled": "Deshabilitado"
},
"downloads": {
"downloads_title": "Descargas",
@@ -370,7 +371,9 @@
"audio_tracks": "Pistas de audio:",
"playback_state": "Estado de la reproducción:",
"no_data_available": "No hay datos disponibles",
"index": "Índice:"
"index": "Índice:",
"continue_watching": "Continuar viendo",
"go_back": "Volver"
},
"item_card": {
"next_up": "A continuación",

View File

@@ -138,7 +138,8 @@
"hide_libraries": "Cacher des bibliothèques",
"select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans l'onglet Bibliothèque et les sections de la page d'accueil.",
"disable_haptic_feedback": "Désactiver le retour haptique",
"default_quality": "Qualité par défaut"
"default_quality": "Qualité par défaut",
"disabled": "Désactivé"
},
"downloads": {
"downloads_title": "Téléchargements",
@@ -370,7 +371,9 @@
"audio_tracks": "Pistes audio:",
"playback_state": "État de lecture:",
"no_data_available": "Aucune donnée disponible",
"index": "Index:"
"index": "Index :",
"continue_watching": "Continuer à regarder",
"go_back": "Retour"
},
"item_card": {
"next_up": "À suivre",

View File

@@ -138,7 +138,8 @@
"hide_libraries": "Nascondi Librerie",
"select_liraries_you_want_to_hide": "Selezionate le librerie che volete nascondere dalla scheda Libreria e dalle sezioni della pagina iniziale.",
"disable_haptic_feedback": "Disabilita il feedback aptico",
"default_quality": "Qualità predefinita"
"default_quality": "Qualità predefinita",
"disabled": "Disabilitato"
},
"downloads": {
"downloads_title": "Scaricamento",
@@ -370,7 +371,9 @@
"audio_tracks": "Tracce audio:",
"playback_state": "Stato della riproduzione:",
"no_data_available": "Nessun dato disponibile",
"index": "Indice:"
"index": "Indice:",
"continue_watching": "Continua a guardare",
"go_back": "Indietro"
},
"item_card": {
"next_up": "Il prossimo",

View File

@@ -152,7 +152,9 @@
"optimized_version_hint": "OptimizeサーバーのURLを入力します。URLにはhttpまたはhttpsを含め、オプションでポートを指定します。",
"read_more_about_optimized_server": "Optimizeサーバーの詳細をご覧ください。",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:ポート"
"server_url_placeholder": "http(s)://domain.org:ポート",
"default_quality": "デフォルトの品質",
"disabled": "無効"
},
"plugins": {
"plugins_title": "プラグイン",
@@ -369,7 +371,9 @@
"audio_tracks": "音声トラック:",
"playback_state": "再生状態:",
"no_data_available": "データなし",
"index": "インデックス:"
"index": "インデックス:",
"continue_watching": "視聴を続ける",
"go_back": "戻る"
},
"item_card": {
"next_up": "次",

View File

@@ -138,7 +138,8 @@
"hide_libraries": "Verberg Bibliotheken",
"select_liraries_you_want_to_hide": "Selecteer de bibliotheken die je wil verbergen van de Bibliotheektab en hoofdpagina onderdelen.",
"disable_haptic_feedback": "Haptische feedback uitschakelen",
"default_quality": "Standaard kwaliteit"
"default_quality": "Standaard kwaliteit",
"disabled": "Uitgeschakeld"
},
"downloads": {
"downloads_title": "Downloads",
@@ -370,7 +371,9 @@
"audio_tracks": "Audio Tracks:",
"playback_state": "Afspeelstatus:",
"no_data_available": "Geen data beschikbaar",
"index": "Index:"
"index": "Index:",
"continue_watching": "Verder kijken",
"go_back": "Terug"
},
"item_card": {
"next_up": "Volgende",

View File

@@ -138,7 +138,8 @@
"hide_libraries": "Ukryj biblioteki",
"select_liraries_you_want_to_hide": "Wybierz biblioteki, które chcesz ukryć na karcie Biblioteka i w sekcjach strony głównej.",
"disable_haptic_feedback": "Wyłącz wibracje",
"default_quality": "Domyślna jakość"
"default_quality": "Domyślna jakość",
"disabled": "Wyłączone"
},
"downloads": {
"downloads_title": "Pobieranie",
@@ -374,7 +375,9 @@
"audio_tracks": "Ścieżki audio:",
"playback_state": "Stan odtwarzania:",
"no_data_available": "Brak dostępnych danych",
"index": "Indeks:"
"index": "Indeks:",
"continue_watching": "Kontynuuj oglądanie",
"go_back": "Wstecz"
},
"item_card": {
"next_up": "Następne",

View File

@@ -138,7 +138,8 @@
"hide_libraries": "Ocultar bibliotecas",
"select_liraries_you_want_to_hide": "Selecione as bibliotecas que você deseja ocultar das abas Biblioteca e Início.",
"disable_haptic_feedback": "Desativar o feedback háptico",
"default_quality": "Qualidade padrão"
"default_quality": "Qualidade padrão",
"disabled": "Desativado"
},
"downloads": {
"downloads_title": "Downloads",
@@ -371,7 +372,9 @@
"audio_tracks": "Faixas do áudio:",
"playback_state": "Playback State:",
"no_data_available": "Nenhum dado disponível",
"index": "Índice:"
"index": "Índice:",
"continue_watching": "Continuar assistindo",
"go_back": "Voltar"
},
"item_card": {
"next_up": "Próximo em",

View File

@@ -1,478 +1,480 @@
{
"login": {
"username_required": "Имя пользователя обязательно",
"error_title": "Ошибка",
"login_title": "Вход",
"login_to_title": "Вход в",
"username_placeholder": "Имя пользователя",
"password_placeholder": "Пароль",
"login_button": "Войти",
"quick_connect": "Быстрое подключение",
"enter_code_to_login": "Введите код {{code}} чтобы войти",
"failed_to_initiate_quick_connect": "Не удалось инициировать быстрое подключение",
"got_it": "Принято",
"connection_failed": "Соединение не удалось",
"could_not_connect_to_server": "Не удалось подключиться к серверу. Пожалуйста проверьте URL и ваше интернет соединение.",
"an_unexpected_error_occured": "Возникла непредвиденная ошибка",
"change_server": "Поменять сервер",
"invalid_username_or_password": "Неправильное имя пользователя или пароль",
"user_does_not_have_permission_to_log_in": "Пользователь не имеет прав на вход",
"server_is_taking_too_long_to_respond_try_again_later": "Сервер долго не отвечает, попробуйте позже.",
"server_received_too_many_requests_try_again_later": "Сервер получил слишком много запросов, попробуйте позже.",
"there_is_a_server_error": "Возникла ошибка сервера",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Возникла непредвиденная ошибка. Вы правильно ввели URL?"
"login": {
"username_required": "Имя пользователя обязательно",
"error_title": "Ошибка",
"login_title": "Вход",
"login_to_title": "Вход в",
"username_placeholder": "Имя пользователя",
"password_placeholder": "Пароль",
"login_button": "Войти",
"quick_connect": "Быстрое подключение",
"enter_code_to_login": "Введите код {{code}} чтобы войти",
"failed_to_initiate_quick_connect": "Не удалось инициировать быстрое подключение",
"got_it": "Принято",
"connection_failed": "Соединение не удалось",
"could_not_connect_to_server": "Не удалось подключиться к серверу. Пожалуйста проверьте URL и ваше интернет соединение.",
"an_unexpected_error_occured": "Возникла непредвиденная ошибка",
"change_server": "Поменять сервер",
"invalid_username_or_password": "Неправильное имя пользователя или пароль",
"user_does_not_have_permission_to_log_in": "Пользователь не имеет прав на вход",
"server_is_taking_too_long_to_respond_try_again_later": "Сервер долго не отвечает, попробуйте позже.",
"server_received_too_many_requests_try_again_later": "Сервер получил слишком много запросов, попробуйте позже.",
"there_is_a_server_error": "Возникла ошибка сервера",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Возникла непредвиденная ошибка. Вы правильно ввели URL?"
},
"server": {
"enter_url_to_jellyfin_server": "Укажите URL на ваш Jellyfin сервер",
"server_url_placeholder": "http(s)://your-server.com",
"connect_button": "Подключиться",
"previous_servers": "предыдущие серверы",
"clear_button": "Очистить",
"search_for_local_servers": "Поиск локальных серверов",
"searching": "Поиск...",
"servers": "Сервера"
},
"home": {
"no_internet": "Нет интернета",
"no_items": "Нет элементов",
"no_internet_message": "Не переживайте, Вы всё ещё можете смотреть\nскачанный контент.",
"go_to_downloads": "В загрузки",
"oops": "Упс!",
"error_message": "Что-то пошло не так.\nПожалуйста выйдите и зайдите снова.",
"continue_watching": "Продолжить просмотр",
"next_up": "Следующее",
"recently_added_in": "Недавно добавлено в {{libraryName}}",
"suggested_movies": "Предложенные фильмы",
"suggested_episodes": "Предложенные серии",
"intro": {
"welcome_to_streamyfin": "Добро пожаловать в Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "Бесплатный клиент для Jellyfin с открытым кодом",
"features_title": "Функции",
"features_description": "Streamyfin имеет множество функций и интегрируется с широким спектром программ, которое вы можете найти в меню настроек:",
"jellyseerr_feature_description": "Подключитесь к Jellyseerr и запрашивайте фильмы прямо в приложении.",
"downloads_feature_title": "Загрузки",
"downloads_feature_description": "Скачивайте фильмы и сериалы для просмотра без интернета. Используйте стандартный способ или установите сервер оптимизации для загрузки файлов в фоновом режиме.",
"chromecast_feature_description": "Транслируйте фильмы и сериалы на ваши устройста с поддержкой Chromecast.",
"centralised_settings_plugin_title": "Плагин для централизованной настройки",
"centralised_settings_plugin_description": "Настраивайте параметры из централизованного места на сервере Jellyfin. Все настройки клиента для всех пользователей будут синхронизированы автоматически.",
"done_button": "Готово",
"go_to_settings_button": "Перейти в настройки",
"read_more": "Узнать больше"
},
"server": {
"enter_url_to_jellyfin_server": "Укажите URL на ваш Jellyfin сервер",
"server_url_placeholder": "http(s)://your-server.com",
"connect_button": "Подключиться",
"previous_servers": "предыдущие серверы",
"clear_button": "Очистить",
"search_for_local_servers": "Поиск локальных серверов",
"searching": "Поиск...",
"servers": "Сервера"
},
"home": {
"no_internet": "Нет интернета",
"no_items": "Нет элементов",
"no_internet_message": "Не переживайте, Вы всё ещё можете смотреть\nскачанный контент.",
"go_to_downloads": "В загрузки",
"oops": "Упс!",
"error_message": "Что-то пошло не так.\nПожалуйста выйдите и зайдите снова.",
"continue_watching": "Продолжить просмотр",
"next_up": "Следующее",
"recently_added_in": "Недавно добавлено в {{libraryName}}",
"suggested_movies": "Предложенные фильмы",
"suggested_episodes": "Предложенные серии",
"intro": {
"welcome_to_streamyfin": "Добро пожаловать в Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "Бесплатный клиент для Jellyfin с открытым кодом",
"features_title": "Функции",
"features_description": "Streamyfin имеет множество функций и интегрируется с широким спектром программ, которое вы можете найти в меню настроек:",
"jellyseerr_feature_description": "Подключитесь к Jellyseerr и запрашивайте фильмы прямо в приложении.",
"downloads_feature_title": "Загрузки",
"downloads_feature_description": "Скачивайте фильмы и сериалы для просмотра без интернета. Используйте стандартный способ или установите сервер оптимизации для загрузки файлов в фоновом режиме.",
"chromecast_feature_description": "Транслируйте фильмы и сериалы на ваши устройста с поддержкой Chromecast.",
"centralised_settings_plugin_title": "Плагин для централизованной настройки",
"centralised_settings_plugin_description": "Настраивайте параметры из централизованного места на сервере Jellyfin. Все настройки клиента для всех пользователей будут синхронизированы автоматически.",
"done_button": "Готово",
"go_to_settings_button": "Перейти в настройки",
"read_more": "Узнать больше"
"settings": {
"settings_title": "Настройки",
"log_out_button": "Выйти",
"user_info": {
"user_info_title": "Информация о пользователе",
"user": "Пользователь",
"server": "Сервер",
"token": "Токен",
"app_version": "Версия приложения"
},
"settings": {
"settings_title": "Настройки",
"log_out_button": "Выйти",
"user_info": {
"user_info_title": "Информация о пользователе",
"user": "Пользователь",
"server": "Сервер",
"token": "Токен",
"app_version": "Версия приложения"
},
"quick_connect": {
"quick_connect_title": "Быстрое подключение",
"authorize_button": "Авторизировать через быстрое подключение",
"enter_the_quick_connect_code": "Введите код для быстрого подключения...",
"success": "Успех",
"quick_connect_autorized": "Быстрое подключение авторизовано",
"error": "Ошибка",
"invalid_code": "Неверный код",
"authorize": "Авторизировать"
},
"media_controls": {
"media_controls_title": "Медиа-контроль",
"forward_skip_length": "Длина пропуска вперед",
"rewind_length": "Длина перемотки",
"seconds_unit": "c"
},
"audio": {
"audio_title": "Аудио",
"set_audio_track": "Устанавливать аудио дорожку из предыдущего элемента",
"audio_language": "Язык аудио",
"audio_hint": "Выберите стандартный язык аудио.",
"none": "Отсутствует",
"language": "Язык"
},
"subtitles": {
"subtitle_title": "Субтитры",
"subtitle_language": "Язык субтитров",
"subtitle_mode": "Режим субтитров",
"set_subtitle_track": "Устанавливать субтитры из предыдущего элемента",
"subtitle_size": "Размер субтитров",
"subtitle_hint": "Настроить субтитры.",
"none": "Отсутствует",
"language": "Язык",
"loading": "Загрузка",
"modes": {
"Default": "Стандартный",
"Smart": "Умный",
"Always": "Всегда",
"None": "Отсутствует",
"OnlyForced": "Только принудительные"
}
},
"other": {
"other_title": "Другое",
"follow_device_orientation": "Авто-поворот",
"video_orientation": "Ориентация видео",
"orientation": "Ориентация",
"orientations": {
"DEFAULT": "Стандартный",
"ALL": "Все",
"PORTRAIT": "Портретный",
"PORTRAIT_UP": "Портрет вверх",
"PORTRAIT_DOWN": "Портрет вниз",
"LANDSCAPE": "Ландшафтный",
"LANDSCAPE_LEFT": "Ландшафтный слева",
"LANDSCAPE_RIGHT": "Ландшафтный справа",
"OTHER": "Другое",
"UNKNOWN": "Неизвестное"
},
"safe_area_in_controls": "Безопасная зона в элементах управления",
"video_player": "Видео прейер",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Экспериментальный + PiP)"
},
"show_custom_menu_links": "Показать ссылки кастомного меню",
"hide_libraries": "Скрыть библиотеки",
"select_liraries_you_want_to_hide": "Выберите Библиотеки, которое хотите спрятать из вкладки Библиотеки и домашней страницы.",
"disable_haptic_feedback": "Отключить тактильную обратную связь",
"default_quality": "Качество по умолчанию"
},
"downloads": {
"downloads_title": "Загрузки",
"download_method": "способ загрузки",
"remux_max_download": "Remux max скачать",
"auto_download": "Авто-загрузка",
"optimized_versions_server": "Оптимизированные версии сервера",
"save_button": "Сохранить",
"optimized_server": "Оптимизированный сервер",
"optimized": "Оптимизированный",
"default": "По умолчанию",
"optimized_version_hint": "Укажите URL на оптимизированный сервер. URL должен включать http or https и опционально порт.",
"read_more_about_optimized_server": "Узнать больше про оптимизацию сервера.",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port"
},
"plugins": {
"plugins_title": "Плагины",
"jellyseerr": {
"jellyseerr_warning": "Эта интеграция находится на ранней стадии. Ожидайте изменений.",
"server_url": "URL сервера",
"server_url_hint": "Пример: http(s)://your-host.url\n(Добавьте порт если необходимо)",
"server_url_placeholder": "Jellyseerr URL...",
"password": "Пароль",
"password_placeholder": "Введите пароль для пользователя Jellyfin {{username}}",
"save_button": "Сохранить",
"clear_button": "Очистить",
"login_button": "Войти",
"total_media_requests": "Всего запросов на медиа",
"movie_quota_limit": "Ограничение квоты на фильмы",
"movie_quota_days": "Дни квоты на фильмы",
"tv_quota_limit": "Ограничение квоты на сериалы",
"tv_quota_days": "Дни квоты на сериалы",
"reset_jellyseerr_config_button": "Сбросить конфигурацию Jellyseerr",
"unlimited": "Неограниченно",
"plus_n_more": "+{{n}} больше",
"order_by": {
"DEFAULT": "По умолчанию",
"VOTE_COUNT_AND_AVERAGE": "Количеству голосов и среднему",
"POPULARITY": "Популярности"
}
},
"marlin_search": {
"enable_marlin_search": "Включить Marlin Search ",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port",
"marlin_search_hint": "Введите URL для Marlin сервера. URL должен включать http or https и опционально порт.",
"read_more_about_marlin": "Узнать больше о Marlin.",
"save_button": "Сохранить",
"toasts": {
"saved": "Сохранено"
}
}
},
"storage": {
"storage_title": "Хранилище",
"app_usage": "Приложение {{usedSpace}}%",
"device_usage": "Устройство {{availableSpace}}%",
"size_used": "{{used}} из {{total}} использовано",
"delete_all_downloaded_files": "Удалить все загруженные файлы",
},
"intro": {
"show_intro": "Показать вступление",
"reset_intro": "Сбросить вступление"
},
"logs": {
"logs_title": "Логи",
"no_logs_available": "Логи не доступны",
"delete_all_logs": "Удалить все логи",
},
"languages": {
"title": "Языки",
"app_language": "Язык приложения",
"app_language_description": "Выберите язык для приложения.",
"system": "Системный"
},
"toasts": {
"error_deleting_files": "Ошибка при удалении файлов",
"background_downloads_enabled": "Фоновая загрузка включена",
"background_downloads_disabled": "Фоновая загрузка отключена",
"connected": "Подключено",
"could_not_connect": "Не удалось подключиться",
"invalid_url": "Неверный URL"
"quick_connect": {
"quick_connect_title": "Быстрое подключение",
"authorize_button": "Авторизировать через быстрое подключение",
"enter_the_quick_connect_code": "Введите код для быстрого подключения...",
"success": "Успех",
"quick_connect_autorized": "Быстрое подключение авторизовано",
"error": "Ошибка",
"invalid_code": "Неверный код",
"authorize": "Авторизировать"
},
"media_controls": {
"media_controls_title": "Медиа-контроль",
"forward_skip_length": "Длина пропуска вперед",
"rewind_length": "Длина перемотки",
"seconds_unit": "c"
},
"audio": {
"audio_title": "Аудио",
"set_audio_track": "Устанавливать аудио дорожку из предыдущего элемента",
"audio_language": "Язык аудио",
"audio_hint": "Выберите стандартный язык аудио.",
"none": "Отсутствует",
"language": "Язык"
},
"subtitles": {
"subtitle_title": "Субтитры",
"subtitle_language": "Язык субтитров",
"subtitle_mode": "Режим субтитров",
"set_subtitle_track": "Устанавливать субтитры из предыдущего элемента",
"subtitle_size": "Размер субтитров",
"subtitle_hint": "Настроить субтитры.",
"none": "Отсутствует",
"language": "Язык",
"loading": "Загрузка",
"modes": {
"Default": "Стандартный",
"Smart": "Умный",
"Always": "Всегда",
"None": "Отсутствует",
"OnlyForced": "Только принудительные"
}
},
"sessions": {
"title": "Сессии",
"no_active_sessions": "Нет активных сессий",
"other": {
"other_title": "Другое",
"follow_device_orientation": "Авто-поворот",
"video_orientation": "Ориентация видео",
"orientation": "Ориентация",
"orientations": {
"DEFAULT": "Стандартный",
"ALL": "Все",
"PORTRAIT": "Портретный",
"PORTRAIT_UP": "Портрет вверх",
"PORTRAIT_DOWN": "Портрет вниз",
"LANDSCAPE": "Ландшафтный",
"LANDSCAPE_LEFT": "Ландшафтный слева",
"LANDSCAPE_RIGHT": "Ландшафтный справа",
"OTHER": "Другое",
"UNKNOWN": "Неизвестное"
},
"safe_area_in_controls": "Безопасная зона в элементах управления",
"video_player": "Видео прейер",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Экспериментальный + PiP)"
},
"show_custom_menu_links": "Показать ссылки кастомного меню",
"hide_libraries": "Скрыть библиотеки",
"select_liraries_you_want_to_hide": "Выберите Библиотеки, которое хотите спрятать из вкладки Библиотеки и домашней страницы.",
"disable_haptic_feedback": "Отключить тактильную обратную связь",
"default_quality": "Качество по умолчанию",
"disabled": "Отключено"
},
"downloads": {
"downloads_title": "Загрузки",
"tvseries": "Сериалы",
"movies": "Фильмы",
"queue": "Очередь",
"queue_hint": "Очередь и загрузки будут удалены при перезагрузке приложения",
"no_items_in_queue": "Нет элементов в очереди",
"no_downloaded_items": "Нет загруженых предметов",
"delete_all_movies_button": "Удалить все фильмы",
"delete_all_tvseries_button": "Удалить все сериалы",
"delete_all_button": "Удалить все",
"active_download": "Активно загружается",
"no_active_downloads": "Нет активных загрузок",
"active_downloads": "Активные загрузки",
"new_app_version_requires_re_download": "Новая версия приложения требует повторной загрузки",
"new_app_version_requires_re_download_description": "Новая версия приложения требует повторной загрузки. Пожалуйста удалите всё и попробуйте заново.",
"back": "Назад",
"delete": "Удалить",
"something_went_wrong": "Что-то пошло не так",
"could_not_get_stream_url_from_jellyfin": "Не удалось получить ссылку трансляции из Jellyfin",
"eta": "ETA {{eta}}",
"methods": "Методы",
"toasts": {
"you_are_not_allowed_to_download_files": "Нет разрешения на скачивание файлов.",
"deleted_all_movies_successfully": "Все фильмы были успешно удалены!",
"failed_to_delete_all_movies": "Возникла ошибка при удалении всех фильмов",
"deleted_all_tvseries_successfully": "Все сериалы были успешно удалены!",
"failed_to_delete_all_tvseries": "Возникла ошибка при удалении всех сериалов",
"download_cancelled": "Загрузка отменена",
"could_not_cancel_download": "Не удалось отменить загрузку",
"download_completed": "Загрузка завершена",
"download_started_for": "Загрузка {{item}} началась",
"item_is_ready_to_be_downloaded": "{{item}} готов к загрузке",
"download_stated_for_item": "Загрузка {{item} началась",
"download_failed_for_item": "Загрузка {{item}} провалилась с ошибкой: {{error}}",
"download_completed_for_item": "{{item}} успешно загружен",
"queued_item_for_optimization": "{{item}} поставлен в очередь для оптимизации",
"failed_to_start_download_for_item": "Не удалось начать загрузку {{item}}: {{message}}",
"server_responded_with_status_code": "Сервер ответил со статусом {{statusCode}}",
"no_response_received_from_server": "Нет ответа от сервера",
"error_setting_up_the_request": "Ошибка при создании запроса",
"failed_to_start_download_for_item_unexpected_error": "Не удалось начать загрузку {{item}}: Неожиданная ошибка",
"all_files_folders_and_jobs_deleted_successfully": "Все файлы, папки, и задачи были успешно удалены",
"an_error_occured_while_deleting_files_and_jobs": "Возникла ошибка при удалении файлов и работ",
"go_to_downloads": "В загрузки"
"download_method": "способ загрузки",
"remux_max_download": "Remux max скачать",
"auto_download": "Авто-загрузка",
"optimized_versions_server": "Оптимизированные версии сервера",
"save_button": "Сохранить",
"optimized_server": "Оптимизированный сервер",
"optimized": "Оптимизированный",
"default": "По умолчанию",
"optimized_version_hint": "Укажите URL на оптимизированный сервер. URL должен включать http or https и опционально порт.",
"read_more_about_optimized_server": "Узнать больше про оптимизацию сервера.",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port"
},
"plugins": {
"plugins_title": "Плагины",
"jellyseerr": {
"jellyseerr_warning": "Эта интеграция находится на ранней стадии. Ожидайте изменений.",
"server_url": "URL сервера",
"server_url_hint": "Пример: http(s)://your-host.url\n(Добавьте порт если необходимо)",
"server_url_placeholder": "Jellyseerr URL...",
"password": "Пароль",
"password_placeholder": "Введите пароль для пользователя Jellyfin {{username}}",
"save_button": "Сохранить",
"clear_button": "Очистить",
"login_button": "Войти",
"total_media_requests": "Всего запросов на медиа",
"movie_quota_limit": "Ограничение квоты на фильмы",
"movie_quota_days": "Дни квоты на фильмы",
"tv_quota_limit": "Ограничение квоты на сериалы",
"tv_quota_days": "Дни квоты на сериалы",
"reset_jellyseerr_config_button": "Сбросить конфигурацию Jellyseerr",
"unlimited": "Неограниченно",
"plus_n_more": "+{{n}} больше",
"order_by": {
"DEFAULT": "По умолчанию",
"VOTE_COUNT_AND_AVERAGE": "Количеству голосов и среднему",
"POPULARITY": "Популярности"
}
},
"marlin_search": {
"enable_marlin_search": "Включить Marlin Search ",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port",
"marlin_search_hint": "Введите URL для Marlin сервера. URL должен включать http or https и опционально порт.",
"read_more_about_marlin": "Узнать больше о Marlin.",
"save_button": "Сохранить",
"toasts": {
"saved": "Сохранено"
}
}
}
},
"search": {
"search_here": "Искать здесь...",
"search": "Поиск...",
"x_items": "{{count}} предметов",
"library": "Библиотека",
"discover": "Найти новое",
"no_results": "Нет результатов",
"no_results_found_for": "Не было результатов при поиске",
"movies": "Фильмы",
"series": "Сериалы",
"episodes": "Серии",
"collections": "Коллекции",
"actors": "Актеры",
"request_movies": "Запросить фильмы",
"request_series": "Запросить сериалы",
"recently_added": "Недавно добавлено",
"recent_requests": "Недавно запрошено",
"plex_watchlist": "Список просмотра с Plex",
"trending": "В тренде",
"popular_movies": "Популярные фильмы",
"movie_genres": "Популярные жанры",
"upcoming_movies": "Предстоящие фильмы",
"studios": "Студии",
"popular_tv": "Популярные сериалы",
"tv_genres": "жанры сериалов",
"upcoming_tv": "Предстоящие сериалы",
"networks": "Сети",
"tmdb_movie_keyword": "TMDB Ключевые слова фильмов",
"tmdb_movie_genre": "TMDB Жанры фильмов",
"tmdb_tv_keyword": "TMDB Ключевые слова сериалов",
"tmdb_tv_genre": "TMDB Жанры сериалов",
"tmdb_search": "TMDB Поиск",
"tmdb_studio": "TMDB Студии",
"tmdb_network": "TMDB Сеть",
"tmdb_movie_streaming_services": "TMDB Потоковые сервисы фильмов",
"tmdb_tv_streaming_services": "TMDB Потоковые сервисы сериалов",
},
"library": {
"no_items_found": "элементы не найдены",
"no_results": "Нет результатов",
"no_libraries_found": "Библиотеки не найдены",
"item_types": {
"movies": "фильмы",
"series": "Сериалы",
"boxsets": "Коллекции",
"items": "элементы"
},
"options": {
"display": "Отображать",
"row": "Ряд",
"list": "Список",
"image_style": "Стиль изображения",
"poster": "Постер",
"cover": "Обложка",
"show_titles": "Показывать загаловки",
"show_stats": "Показывать статистику",
"storage": {
"storage_title": "Хранилище",
"app_usage": "Приложение {{usedSpace}}%",
"device_usage": "Устройство {{availableSpace}}%",
"size_used": "{{used}} из {{total}} использовано",
"delete_all_downloaded_files": "Удалить все загруженные файлы"
},
"intro": {
"show_intro": "Показать вступление",
"reset_intro": "Сбросить вступление"
},
"logs": {
"logs_title": "Логи",
"no_logs_available": "Логи не доступны",
"delete_all_logs": "Удалить все логи"
},
"languages": {
"title": "Языки",
"app_language": "Язык приложения",
"app_language_description": "Выберите язык для приложения.",
"system": "Системный"
},
"filters": {
"genres": "Жанры",
"years": "Года",
"sort_by": "Сортировать по",
"sort_order": "Порядок сортировки",
"asc": "По Возрастанию",
"desc": "По убыванию",
"tags": "Тэги"
}
},
"favorites": {
"series": "Сериалы",
"movies": "Фильмы",
"episodes": "Серии",
"videos": "Видео",
"boxsets": "Коллекции",
"playlists": "Плейлисты",
"noDataTitle": "Пока нет избранных",
"noData": "Отметьте элементы как избранные, чтобы они отображались здесь для быстрого доступа."
},
"custom_links": {
"no_links": "Нет ссылок"
},
"player": {
"error": "Ошибка",
"failed_to_get_stream_url": "Не удалось получить URL потока",
"an_error_occured_while_playing_the_video": "Возникла Неожиданная ошибка во время воспроизведения. Проверьте логи в настройках.",
"client_error": "Ошибка клиента",
"could_not_create_stream_for_chromecast": "Не удалось создать поток для Chromecast",
"message_from_server": "Сообщение от сервера: {{message}}",
"video_has_finished_playing": "Видео закончило воспроизводиться!",
"no_video_source": "Нет источника видео...",
"next_episode": "Следующая серия",
"refresh_tracks": "Обновить дорожки",
"subtitle_tracks": "Субтитры:",
"audio_tracks": "Аудио дорожки:",
"playback_state": "Состояние воспроизведения:",
"no_data_available": "Данные не доступны",
"index": "Индекс:"
},
"item_card": {
"next_up": "Следующее",
"no_items_to_display": "Нет элементов для отображения",
"cast_and_crew": "Актеры и съемочная группа",
"series": "Серии",
"seasons": "Сезоны",
"season": "Сезон",
"no_episodes_for_this_season": "В этом сезоне нет серий",
"overview": "Обзор",
"more_with": "Больше с {{name}}",
"similar_items": "Похожие элементы",
"no_similar_items_found": "Похожие элементы не найдены",
"video": "Видео",
"more_details": "Больше деталей",
"quality": "Качество",
"audio": "Звук",
"subtitles": "Субтитры",
"show_more": "Показать больше",
"show_less": "Показать меньше",
"appeared_in": "Появлялся в",
"could_not_load_item": "Не удалось загрузить элемент",
"none": "Отсутствует",
"download": {
"download_season": "Загрузить сезон",
"download_series": "Загрузить сериал",
"download_episode": "Загрузить серию",
"download_movie": "Скачать фильм",
"download_x_item": "Загрузить {{item_count}} элементов",
"download_button": "Загрузить",
"using_optimized_server": "Использовать оптимизированный сервер",
"using_default_method": "Использовать стандартный метод",
}
},
"live_tv": {
"next": "Следующая",
"previous": "Предыдущая",
"live_tv": "Прямой эфир ТВ",
"coming_soon": "Скоро",
"on_now": "Сейчас в эфире",
"shows": "Сериалы",
"movies": "Фильмы",
"sports": "Спорт",
"for_kids": "Для детей",
"news": "Новости"
},
"jellyseerr": {
"confirm": "Подтвердить",
"cancel": "Отменить",
"yes": "Да",
"whats_wrong": "В чем дело?",
"issue_type": "Вид проблемы",
"select_an_issue": "Выберите проблему",
"types": "Типы",
"describe_the_issue": "(опционально) Опишите проблему...",
"submit_button": "Подать",
"report_issue_button": "Сообщить о проблеме",
"request_button": "Запросить",
"are_you_sure_you_want_to_request_all_seasons": "Вы уверены, что хотите запросить все сезоны?",
"failed_to_login": "Не удалось войти",
"cast": "Транслировать",
"details": "Детали",
"status": "Статус",
"original_title": "Оригинальное название",
"series_type": "Тип сериала",
"release_dates": "Дата релиза",
"first_air_date": "Первая дата выхода в эфир",
"next_air_date": "Следующая дата выхода в эфир",
"revenue": "Прибыль",
"budget": "Бюджет",
"original_language": "Оригинальный язык",
"production_country": "Страна производства",
"studios": "Студия",
"network": "Сеть",
"currently_streaming_on": "Сейчас доступно на",
"advanced": "Продвинутое",
"request_as": "Запросить как",
"tags": "Тэги",
"quality_profile": "Профиль качества",
"root_folder": "Корневая папка",
"season_all": "Сезон (все)",
"season_number": "Сезон {{season_number}}",
"number_episodes": "{{episode_number}} серий",
"born": "Рожден",
"appearances": "Появления",
"toasts": {
"jellyseer_does_not_meet_requirements": "Сервер Jellyseerr не соответствует минимальным требованиям версии! Пожалуйста, обновите до версии не ниже 2.0.0",
"jellyseerr_test_failed": "Тест Jellyseerr не пройден. Попробуйте еще раз.",
"failed_to_test_jellyseerr_server_url": "Не удалось проверить URL-адрес сервера jellyseerr",
"issue_submitted": "Проблема отправлена!",
"requested_item": "Запрошено {{item}}!",
"you_dont_have_permission_to_request": "У вас нет разрешения на запрос!",
"something_went_wrong_requesting_media": "Что-то пошло не так при запросе медиафайлов!"
"error_deleting_files": "Ошибка при удалении файлов",
"background_downloads_enabled": "Фоновая загрузка включена",
"background_downloads_disabled": "Фоновая загрузка отключена",
"connected": "Подключено",
"could_not_connect": "Не удалось подключиться",
"invalid_url": "Неверный URL"
}
},
"tabs": {
"home": "Дом",
"search": "Поиск",
"library": "Библиотека",
"custom_links": "Кастомные ссылки",
"favorites": "Избранное"
"sessions": {
"title": "Сессии",
"no_active_sessions": "Нет активных сессий"
},
"downloads": {
"downloads_title": "Загрузки",
"tvseries": "Сериалы",
"movies": "Фильмы",
"queue": "Очередь",
"queue_hint": "Очередь и загрузки будут удалены при перезагрузке приложения",
"no_items_in_queue": "Нет элементов в очереди",
"no_downloaded_items": "Нет загруженых предметов",
"delete_all_movies_button": "Удалить все фильмы",
"delete_all_tvseries_button": "Удалить все сериалы",
"delete_all_button": "Удалить все",
"active_download": "Активно загружается",
"no_active_downloads": "Нет активных загрузок",
"active_downloads": "Активные загрузки",
"new_app_version_requires_re_download": "Новая версия приложения требует повторной загрузки",
"new_app_version_requires_re_download_description": "Новая версия приложения требует повторной загрузки. Пожалуйста удалите всё и попробуйте заново.",
"back": "Назад",
"delete": "Удалить",
"something_went_wrong": "Что-то пошло не так",
"could_not_get_stream_url_from_jellyfin": "Не удалось получить ссылку трансляции из Jellyfin",
"eta": "ETA {{eta}}",
"methods": "Методы",
"toasts": {
"you_are_not_allowed_to_download_files": "Нет разрешения на скачивание файлов.",
"deleted_all_movies_successfully": "Все фильмы были успешно удалены!",
"failed_to_delete_all_movies": "Возникла ошибка при удалении всех фильмов",
"deleted_all_tvseries_successfully": "Все сериалы были успешно удалены!",
"failed_to_delete_all_tvseries": "Возникла ошибка при удалении всех сериалов",
"download_cancelled": "Загрузка отменена",
"could_not_cancel_download": "Не удалось отменить загрузку",
"download_completed": "Загрузка завершена",
"download_started_for": "Загрузка {{item}} началась",
"item_is_ready_to_be_downloaded": "{{item}} готов к загрузке",
"download_stated_for_item": "Загрузка {{item} началась",
"download_failed_for_item": "Загрузка {{item}} провалилась с ошибкой: {{error}}",
"download_completed_for_item": "{{item}} успешно загружен",
"queued_item_for_optimization": "{{item}} поставлен в очередь для оптимизации",
"failed_to_start_download_for_item": "Не удалось начать загрузку {{item}}: {{message}}",
"server_responded_with_status_code": "Сервер ответил со статусом {{statusCode}}",
"no_response_received_from_server": "Нет ответа от сервера",
"error_setting_up_the_request": "Ошибка при создании запроса",
"failed_to_start_download_for_item_unexpected_error": "Не удалось начать загрузку {{item}}: Неожиданная ошибка",
"all_files_folders_and_jobs_deleted_successfully": "Все файлы, папки, и задачи были успешно удалены",
"an_error_occured_while_deleting_files_and_jobs": "Возникла ошибка при удалении файлов и работ",
"go_to_downloads": "В загрузки"
}
}
},
"search": {
"search_here": "Искать здесь...",
"search": "Поиск...",
"x_items": "{{count}} предметов",
"library": "Библиотека",
"discover": "Найти новое",
"no_results": "Нет результатов",
"no_results_found_for": "Не было результатов при поиске",
"movies": "Фильмы",
"series": "Сериалы",
"episodes": "Серии",
"collections": "Коллекции",
"actors": "Актеры",
"request_movies": "Запросить фильмы",
"request_series": "Запросить сериалы",
"recently_added": "Недавно добавлено",
"recent_requests": "Недавно запрошено",
"plex_watchlist": "Список просмотра с Plex",
"trending": "В тренде",
"popular_movies": "Популярные фильмы",
"movie_genres": "Популярные жанры",
"upcoming_movies": "Предстоящие фильмы",
"studios": "Студии",
"popular_tv": "Популярные сериалы",
"tv_genres": "жанры сериалов",
"upcoming_tv": "Предстоящие сериалы",
"networks": "Сети",
"tmdb_movie_keyword": "TMDB Ключевые слова фильмов",
"tmdb_movie_genre": "TMDB Жанры фильмов",
"tmdb_tv_keyword": "TMDB Ключевые слова сериалов",
"tmdb_tv_genre": "TMDB Жанры сериалов",
"tmdb_search": "TMDB Поиск",
"tmdb_studio": "TMDB Студии",
"tmdb_network": "TMDB Сеть",
"tmdb_movie_streaming_services": "TMDB Потоковые сервисы фильмов",
"tmdb_tv_streaming_services": "TMDB Потоковые сервисы сериалов"
},
"library": {
"no_items_found": "элементы не найдены",
"no_results": "Нет результатов",
"no_libraries_found": "Библиотеки не найдены",
"item_types": {
"movies": "фильмы",
"series": "Сериалы",
"boxsets": "Коллекции",
"items": "элементы"
},
"options": {
"display": "Отображать",
"row": "Ряд",
"list": "Список",
"image_style": "Стиль изображения",
"poster": "Постер",
"cover": "Обложка",
"show_titles": "Показывать загаловки",
"show_stats": "Показывать статистику"
},
"filters": {
"genres": "Жанры",
"years": "Года",
"sort_by": "Сортировать по",
"sort_order": "Порядок сортировки",
"asc": "По Возрастанию",
"desc": "По убыванию",
"tags": "Тэги"
}
},
"favorites": {
"series": "Сериалы",
"movies": "Фильмы",
"episodes": "Серии",
"videos": "Видео",
"boxsets": "Коллекции",
"playlists": "Плейлисты",
"noDataTitle": "Пока нет избранных",
"noData": "Отметьте элементы как избранные, чтобы они отображались здесь для быстрого доступа."
},
"custom_links": {
"no_links": "Нет ссылок"
},
"player": {
"error": "Ошибка",
"failed_to_get_stream_url": "Не удалось получить URL потока",
"an_error_occured_while_playing_the_video": "Возникла Неожиданная ошибка во время воспроизведения. Проверьте логи в настройках.",
"client_error": "Ошибка клиента",
"could_not_create_stream_for_chromecast": "Не удалось создать поток для Chromecast",
"message_from_server": "Сообщение от сервера: {{message}}",
"video_has_finished_playing": "Видео закончило воспроизводиться!",
"no_video_source": "Нет источника видео...",
"next_episode": "Следующая серия",
"refresh_tracks": "Обновить дорожки",
"subtitle_tracks": "Субтитры:",
"audio_tracks": "Аудио дорожки:",
"playback_state": "Состояние воспроизведения:",
"no_data_available": "Данные не доступны",
"index": "Индекс:",
"continue_watching": "Продолжить просмотр",
"go_back": "Назад"
},
"item_card": {
"next_up": "Следующее",
"no_items_to_display": "Нет элементов для отображения",
"cast_and_crew": "Актеры и съемочная группа",
"series": "Серии",
"seasons": "Сезоны",
"season": "Сезон",
"no_episodes_for_this_season": "В этом сезоне нет серий",
"overview": "Обзор",
"more_with": "Больше с {{name}}",
"similar_items": "Похожие элементы",
"no_similar_items_found": "Похожие элементы не найдены",
"video": "Видео",
"more_details": "Больше деталей",
"quality": "Качество",
"audio": "Звук",
"subtitles": "Субтитры",
"show_more": "Показать больше",
"show_less": "Показать меньше",
"appeared_in": "Появлялся в",
"could_not_load_item": "Не удалось загрузить элемент",
"none": "Отсутствует",
"download": {
"download_season": "Загрузить сезон",
"download_series": "Загрузить сериал",
"download_episode": "Загрузить серию",
"download_movie": "Скачать фильм",
"download_x_item": "Загрузить {{item_count}} элементов",
"download_button": "Загрузить",
"using_optimized_server": "Использовать оптимизированный сервер",
"using_default_method": "Использовать стандартный метод"
}
},
"live_tv": {
"next": "Следующая",
"previous": "Предыдущая",
"live_tv": "Прямой эфир ТВ",
"coming_soon": "Скоро",
"on_now": "Сейчас в эфире",
"shows": "Сериалы",
"movies": "Фильмы",
"sports": "Спорт",
"for_kids": "Для детей",
"news": "Новости"
},
"jellyseerr": {
"confirm": "Подтвердить",
"cancel": "Отменить",
"yes": "Да",
"whats_wrong": "В чем дело?",
"issue_type": "Вид проблемы",
"select_an_issue": "Выберите проблему",
"types": "Типы",
"describe_the_issue": "(опционально) Опишите проблему...",
"submit_button": "Подать",
"report_issue_button": "Сообщить о проблеме",
"request_button": "Запросить",
"are_you_sure_you_want_to_request_all_seasons": "Вы уверены, что хотите запросить все сезоны?",
"failed_to_login": "Не удалось войти",
"cast": "Транслировать",
"details": "Детали",
"status": "Статус",
"original_title": "Оригинальное название",
"series_type": "Тип сериала",
"release_dates": "Дата релиза",
"first_air_date": "Первая дата выхода в эфир",
"next_air_date": "Следующая дата выхода в эфир",
"revenue": "Прибыль",
"budget": "Бюджет",
"original_language": "Оригинальный язык",
"production_country": "Страна производства",
"studios": "Студия",
"network": "Сеть",
"currently_streaming_on": "Сейчас доступно на",
"advanced": "Продвинутое",
"request_as": "Запросить как",
"tags": "Тэги",
"quality_profile": "Профиль качества",
"root_folder": "Корневая папка",
"season_all": "Сезон (все)",
"season_number": "Сезон {{season_number}}",
"number_episodes": "{{episode_number}} серий",
"born": "Рожден",
"appearances": "Появления",
"toasts": {
"jellyseer_does_not_meet_requirements": "Сервер Jellyseerr не соответствует минимальным требованиям версии! Пожалуйста, обновите до версии не ниже 2.0.0",
"jellyseerr_test_failed": "Тест Jellyseerr не пройден. Попробуйте еще раз.",
"failed_to_test_jellyseerr_server_url": "Не удалось проверить URL-адрес сервера jellyseerr",
"issue_submitted": "Проблема отправлена!",
"requested_item": "Запрошено {{item}}!",
"you_dont_have_permission_to_request": "У вас нет разрешения на запрос!",
"something_went_wrong_requesting_media": "Что-то пошло не так при запросе медиафайлов!"
}
},
"tabs": {
"home": "Дом",
"search": "Поиск",
"library": "Библиотека",
"custom_links": "Кастомные ссылки",
"favorites": "Избранное"
}
}

View File

@@ -30,5 +30,24 @@
"home": "Hem",
"search": "Sök",
"library": "Bibliotek"
},
"player": {
"error": "Fel",
"failed_to_get_stream_url": "Kunde inte hämta stream-URL",
"an_error_occured_while_playing_the_video": "Ett fel uppstod vid uppspelning av videon. Kontrollera loggarna i inställningarna.",
"client_error": "Klientfel",
"could_not_create_stream_for_chromecast": "Kunde inte skapa stream för Chromecast",
"message_from_server": "Meddelande från servern: {{message}}",
"video_has_finished_playing": "Videon har spelat klart!",
"no_video_source": "Ingen videokälla...",
"next_episode": "Nästa avsnitt",
"refresh_tracks": "Uppdatera spår",
"subtitle_tracks": "Textspår:",
"audio_tracks": "Ljudspår:",
"playback_state": "Uppspelningsstatus:",
"no_data_available": "Inga data tillgängliga",
"index": "Index:",
"continue_watching": "Fortsätt titta",
"go_back": "Tillbaka"
}
}

480
translations/tlh.json Normal file
View File

@@ -0,0 +1,480 @@
{
"login": {
"username_required": "tlhIngan DaneH",
"error_title": "ghIq",
"login_title": "lut 'el",
"login_to_title": "lut 'el",
"username_placeholder": "tlhIngan",
"password_placeholder": "ngoq De'",
"login_button": "yI'el!",
"quick_connect": "parmaq ngoQ",
"enter_code_to_login": "yI'elDI' De' {{code}} yIlaD",
"failed_to_initiate_quick_connect": "parmaq ngoQ yIchu'laHbe'",
"got_it": "jIyaj",
"connection_failed": "ngoQlaHbe'",
"could_not_connect_to_server": "SeHlaw veS Ho'Do'laHbe'. URL 'ej ret ghun mej.",
"an_unexpected_error_occured": "num ghIq Doch",
"change_server": "Ho'Do' veS yIghoS",
"invalid_username_or_password": "tlhIngan pagh ngoq De' law'be'",
"user_does_not_have_permission_to_log_in": "tlhIngan lut 'el je'laHbe'",
"server_is_taking_too_long_to_respond_try_again_later": "Ho'Do' veS jachrup. pItlh yIHaD.",
"server_received_too_many_requests_try_again_later": "Ho'Do' veS lutlh ngeb petlh law'. pItlh yIHaD.",
"there_is_a_server_error": "Ho'Do' veS ghIq maS",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "num ghIq Doch. URL mej Danej'a'?"
},
"server": {
"enter_url_to_jellyfin_server": "Jellyfin Ho'Do' veS URL yI'el",
"server_url_placeholder": "http(s)://HoDo-veS.com",
"connect_button": "yIngoq!",
"previous_servers": "namen Ho'Do' veS",
"clear_button": "yIQaw'",
"search_for_local_servers": "val Ho'Do' veS yISam",
"searching": "Sam...",
"servers": "Ho'Do' veS"
},
"home": {
"no_internet": "ret pagh",
"no_items": "Doch pagh",
"no_internet_message": "QublaHbe'.\nDoch Qaw'laHnIS SoH.",
"go_to_downloads": "Qaw' Doch yIghoS",
"oops": "QI'ya!",
"error_message": "Doch rurbe'.\nyIQo' 'ej yI'elqa'.",
"continue_watching": "tlhol yIHaDqa'",
"next_up": "wej",
"recently_added_in": "num tu'lu' {{libraryName}}",
"suggested_movies": "rutlh DIS",
"suggested_episodes": "rutlh Hem",
"intro": {
"welcome_to_streamyfin": "Streamyfin yI'el!",
"a_free_and_open_source_client_for_jellyfin": "Jellyfin lut 'el je'be' 'ej wang.",
"features_title": "mIw",
"features_description": "Streamyfin mIw law' tu'. men menuDaq yISam:",
"jellyseerr_feature_description": "Jellyseerr yIngoq 'ej DIS pe'vIl yISov.",
"downloads_feature_title": "Qaw' Doch",
"downloads_feature_description": "DIS 'ej Hem Qaw'laH. Qaw' mIw tu'lu'.",
"chromecast_feature_description": "DIS 'ej Hem Chromecast vI' ghoS.",
"centralised_settings_plugin_title": "wa'DIch men mIw",
"centralised_settings_plugin_description": "Jellyfin Ho'Do' veSDaq men yISeH. tlhIngan chIch.",
"done_button": "Qapla'",
"go_to_settings_button": "men yIghoS",
"read_more": "yIlaDqa'"
},
"settings": {
"settings_title": "men",
"log_out_button": "yIQo'",
"user_info": {
"user_info_title": "tlhIngan De'",
"user": "tlhIngan",
"server": "Ho'Do' veS",
"token": "per De'",
"app_version": "ghun wej"
},
"quick_connect": {
"quick_connect_title": "parmaq ngoQ",
"authorize_button": "parmaq ngoQ yIje'",
"enter_the_quick_connect_code": "parmaq ngoQ De' yI'el...",
"success": "Qapla'",
"quick_connect_autorized": "parmaq ngoQ je'laH",
"error": "ghIq",
"invalid_code": "De' law'be'",
"authorize": "yIje'"
},
"media_controls": {
"media_controls_title": "tlhol SeHlaw",
"forward_skip_length": "Du'Hom vum",
"rewind_length": "bavHom vum",
"seconds_unit": "tera' rep"
},
"audio": {
"audio_title": "QoQ",
"set_audio_track": "namen Doch QoQ ret yISeH",
"audio_language": "QoQ Hol",
"audio_hint": "QoQ Hol wa' yIwIv.",
"none": "pagh",
"language": "Hol"
},
"subtitles": {
"subtitle_title": "De' chu'",
"subtitle_language": "De' chu' Hol",
"subtitle_mode": "De' chu' mIw",
"set_subtitle_track": "namen Doch De' chu' ret yISeH",
"subtitle_size": "De' chu' qIt",
"subtitle_hint": "De' chu' wIvlaw' yISeH.",
"none": "pagh",
"language": "Hol",
"loading": "tlha'... ",
"modes": {
"Default": "wa'",
"Smart": "SonchIy",
"Always": "reH",
"None": "pagh",
"OnlyForced": "Dun je'"
}
},
"other": {
"other_title": "patlh",
"follow_device_orientation": "naDevvo' pegh",
"video_orientation": "mu'tlhegh pegh",
"orientation": "pegh",
"orientations": {
"DEFAULT": "wa'",
"ALL": "Hoch",
"PORTRAIT": "leng ret",
"PORTRAIT_UP": "leng ret Dung",
"PORTRAIT_DOWN": "leng ret nuq",
"LANDSCAPE": "leng yot",
"LANDSCAPE_LEFT": "leng yot poS",
"LANDSCAPE_RIGHT": "leng yot nIH",
"OTHER": "patlh",
"UNKNOWN": "Sovbe'"
},
"safe_area_in_controls": "SeHlawDaq yot QIH",
"video_player": "mu'tlhegh tlholwI'",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (PiP mIwHa')"
},
"show_custom_menu_links": "menuDaq ret teqlu' yInej",
"hide_libraries": "De'wI' bom yIQIj",
"select_liraries_you_want_to_hide": "De'wI' bom Danej QIj yIwIv.",
"disable_haptic_feedback": "Qub quvHa' yIQIj",
"default_quality": "wa' luj"
},
"downloads": {
"downloads_title": "Qaw' Doch",
"download_method": "Qaw' mIw",
"remux_max_download": "Remux Qaw' Dun",
"auto_download": "chIch Qaw'",
"optimized_versions_server": "luj wej Ho'Do' veS",
"save_button": "yIqIp",
"optimized_server": "luj Ho'Do' veS",
"optimized": "luj",
"default": "wa'",
"optimized_version_hint": "luj Ho'Do' veS URL yI'el.",
"read_more_about_optimized_server": "luj Ho'Do' veS latlh yIlaD",
"url": "URL",
"server_url_placeholder": "http(s)://domajn.org:pord"
},
"plugins": {
"plugins_title": "mIwHom",
"jellyseerr": {
"jellyseerr_warning": "mIwHomvam chu'. ghoSlaH.",
"server_url": "Ho'Do' veS URL",
"server_url_hint": "ghu': http(s)://HoDo-veS.url\n(pord yIbel)",
"server_url_placeholder": "Jellyseerr URL...",
"password": "ngoq De'",
"password_placeholder": "tlhIngan {{username}} ngoq De' yI'el",
"save_button": "yIqIp",
"clear_button": "yIQaw'",
"login_button": "yI'el!",
"total_media_requests": "Hoch tlhol petlh",
"movie_quota_limit": "DIS petlh Dun",
"movie_quota_days": "DIS petlh jaj",
"tv_quota_limit": "TV petlh Dun",
"tv_quota_days": "TV petlh jaj",
"reset_jellyseerr_config_button": "Jellyseerr men yIQaw'qa'",
"unlimited": "Dun pagh",
"plus_n_more": "+{{n}} latlh",
"order_by": {
"DEFAULT": "wa'",
"VOTE_COUNT_AND_AVERAGE": "nem chIm 'ej mev",
"POPULARITY": "ruch"
}
},
"marlin_search": {
"enable_marlin_search": "Marlin Sam yIchu'",
"url": "URL",
"server_url_placeholder": "http(s)://domajn.org:pord",
"marlin_search_hint": "Marlin Ho'Do' veS URL yI'el.",
"read_more_about_marlin": "Marlin latlh yIlaD",
"save_button": "yIqIp",
"toasts": {
"saved": "qIp"
}
}
},
"storage": {
"storage_title": "ram",
"app_usage": "ghun {{usedSpace}}%",
"device_usage": "naDev {{availableSpace}}%",
"size_used": "{{used}} / {{total}} ram",
"delete_all_downloaded_files": "Hoch Qaw' Doch yIQaw'"
},
"intro": {
"show_intro": "chu' Doch yIHoch",
"reset_intro": "chu' Doch yIQaw'qa'"
},
"logs": {
"logs_title": "De' qon",
"export_logs": "De' qon yISamqa'",
"click_for_more_info": "latlh De' yIchIch",
"level": "quv",
"no_logs_available": "De' qon pagh",
"delete_all_logs": "Hoch De' qon yIQaw'"
},
"languages": {
"title": "Holmey",
"app_language": "ghun Hol",
"app_language_description": "ghun Hol yIwIv.",
"system": "mIw'a'"
},
"toasts": {
"error_deleting_files": "Qaw' ghIq",
"background_downloads_enabled": "tlhegh Qaw' chu'",
"background_downloads_disabled": "tlhegh Qaw' QIj",
"connected": "ngoQ",
"could_not_connect": "ngoQlaHbe'",
"invalid_url": "URL law'be'"
}
},
"sessions": {
"title": "tlholrap",
"no_active_sessions": "tlholrap pagh chu'"
},
"downloads": {
"downloads_title": "Qaw' Doch",
"tvseries": "TV Hem",
"movies": "DIS",
"queue": "ghom",
"queue_hint": "ghun ghImDI' ghom Qaw'laH.",
"no_items_in_queue": "ghom Doch pagh",
"no_downloaded_items": "Qaw' Doch pagh",
"delete_all_movies_button": "Hoch DIS yIQaw'",
"delete_all_tvseries_button": "Hoch TV Hem yIQaw'",
"delete_all_button": "Hoch yIQaw'",
"active_download": "chu' Qaw'",
"no_active_downloads": "chu' Qaw' pagh",
"active_downloads": "chu' Qaw'",
"new_app_version_requires_re_download": "ghun wej chu' Qaw'qa' DaneH",
"new_app_version_requires_re_download_description": "wej chu' Doch Qaw'qa' DaneH. Hoch Qaw' Doch yIQaw' 'ej yIHaDqa'.",
"back": "yIbav",
"delete": "yIQaw'",
"something_went_wrong": "Doch rurbe'",
"could_not_get_stream_url_from_jellyfin": "Jellyfin tlhol ret URL tu'laHbe'",
"eta": "ETA {{eta}}",
"methods": "mIw",
"toasts": {
"you_are_not_allowed_to_download_files": "Doch Qaw' je'laHbe'.",
"deleted_all_movies_successfully": "Hoch DIS Qaw' Qapla'!",
"failed_to_delete_all_movies": "Hoch DIS Qaw'laHbe'",
"deleted_all_tvseries_successfully": "Hoch TV Hem Qaw' Qapla'!",
"failed_to_delete_all_tvseries": "Hoch TV Hem Qaw'laHbe'",
"download_cancelled": "Qaw' ghIm",
"could_not_cancel_download": "Qaw' ghImlaHbe'",
"download_completed": "Qaw' Qapla'",
"download_started_for": "{{item}} Qaw' vIlchu'",
"item_is_ready_to_be_downloaded": "{{item}} Qaw'laHnIS",
"download_stated_for_item": "{{item}} Qaw' vIlchu'",
"download_failed_for_item": "{{item}} Qaw'laHbe' - {{error}}",
"download_completed_for_item": "{{item}} Qaw' Qapla'",
"queued_item_for_optimization": "{{item}} luj ghom",
"failed_to_start_download_for_item": "{{item}} Qaw' vIlchu'laHbe': {{message}}",
"server_responded_with_status_code": "Ho'Do' veS jachrup {{statusCode}}",
"no_response_received_from_server": "Ho'Do' veS jachbe'",
"error_setting_up_the_request": "petlh SeH ghIq",
"failed_to_start_download_for_item_unexpected_error": "{{item}} Qaw' vIlchu'laHbe': num ghIq",
"all_files_folders_and_jobs_deleted_successfully": "Hoch De', ram 'ej vum Qaw' Qapla'",
"an_error_occured_while_deleting_files_and_jobs": "De', ram 'ej vum Qaw'DI' ghIq",
"go_to_downloads": "Qaw' Doch yIghoS"
}
}
},
"search": {
"search_here": "DaH yISam...",
"search": "yISam...",
"x_items": "{{count}} Doch",
"library": "De'wI' bom",
"discover": "yISamqa'",
"no_results": "Doch pagh tu'",
"no_results_found_for": "Doch pagh tu' <...>",
"movies": "DIS",
"series": "Hem",
"episodes": "HemHom",
"collections": "ghom",
"actors": "tlholwI'",
"request_movies": "DIS yIpetlh",
"request_series": "Hem yIpetlh",
"recently_added": "num tu'",
"recent_requests": "num petlh",
"plex_watchlist": "Plex tlhol ghom",
"trending": "chu' ruch",
"popular_movies": "ruch DIS",
"movie_genres": "DIS qorDu'",
"upcoming_movies": "DIS wej",
"studios": "DIS qonwI'",
"popular_tv": "ruch TV",
"tv_genres": "TV qorDu'",
"upcoming_tv": "TV wej",
"networks": "ret",
"tmdb_movie_keyword": "TMDB DIS De'",
"tmdb_movie_genre": "TMDB DIS qorDu'",
"tmdb_tv_keyword": "TMDB TV De'",
"tmdb_tv_genre": "TMDB TV qorDu'",
"tmdb_search": "TMDB Sam",
"tmdb_studio": "TMDB qonwI'",
"tmdb_network": "TMDB ret",
"tmdb_movie_streaming_services": "TMDB DIS tlhol mIw",
"tmdb_tv_streaming_services": "TMDB TV tlhol mIw"
},
"library": {
"no_items_found": "Doch pagh tu'",
"no_results": "Doch pagh tu'",
"no_libraries_found": "De'wI' bom pagh tu'",
"item_types": {
"movies": "DIS",
"series": "Hem",
"boxsets": "Hem ghom",
"items": "Doch"
},
"options": {
"display": "yIHoch",
"row": "ret",
"list": "ghom",
"image_style": "nagh bep",
"poster": "nagh",
"cover": "nagh chop",
"show_titles": "pab HoS yIHoch",
"show_stats": "chIm De' yIHoch"
},
"filters": {
"genres": "qorDu'",
"years": "DIS",
"sort_by": "yIwIv",
"sort_order": "wIv mIw",
"asc": "Dung",
"desc": "nuq",
"tags": "De'Hom"
}
},
"favorites": {
"series": "Hem",
"movies": "DIS",
"episodes": "HemHom",
"videos": "mu'tlhegh",
"boxsets": "Hem ghom",
"playlists": "bom ghom",
"noDataTitle": "wIv Doch pagh",
"noData": "Doch wIv DaneH. DaH tu'laH."
},
"custom_links": {
"no_links": "ret pagh"
},
"player": {
"error": "ghIq",
"failed_to_get_stream_url": "tlhol ret URL tu'laHbe'",
"an_error_occured_while_playing_the_video": "mu'tlhegh tlholDI' ghIq. menDaq De' qon mej.",
"client_error": "lut 'el ghIq",
"could_not_create_stream_for_chromecast": "Chromecast tlhol ret qonlaHbe'",
"message_from_server": "Ho'Do' veS jach: {{message}}",
"video_has_finished_playing": "mu'tlhegh tlhol Qapla'!",
"no_video_source": "mu'tlhegh wang pagh",
"next_episode": "wej HemHom",
"refresh_tracks": "ret yIchu'qa'",
"subtitle_tracks": "De' chu' ret:",
"audio_tracks": "QoQ ret:",
"playback_state": "tlhol mIw:",
"no_data_available": "De' pagh tu'",
"index": "nem:"
},
"item_card": {
"next_up": "wej",
"no_items_to_display": "Doch pagh HochlaH",
"cast_and_crew": "tlholwI' 'ej qonwI'",
"series": "Hem",
"seasons": "muv",
"season": "muv",
"no_episodes_for_this_season": "muvvam HemHom pagh",
"overview": "Hoch Sov",
"more_with": "{{name}} latlh",
"similar_items": "Doch rur",
"no_similar_items_found": "Doch rur pagh tu'",
"video": "mu'tlhegh",
"more_details": "latlh De'",
"quality": "luj",
"audio": "QoQ",
"subtitles": "De' chu'",
"show_more": "latlh yIHoch",
"show_less": "Hom yIHoch",
"appeared_in": "tlholvam",
"could_not_load_item": "Doch tlha'laHbe'",
"none": "pagh",
"download": {
"download_season": "muv yIQaw'",
"download_series": "Hem yIQaw'",
"download_episode": "HemHom yIQaw'",
"download_movie": "DIS yIQaw'",
"download_x_item": "{{item_count}} Doch yIQaw'",
"download_button": "yIQaw'",
"using_optimized_server": "luj Ho'Do' veS tu'lu'",
"using_default_method": "wa' mIw tu'lu'"
}
},
"live_tv": {
"next": "wej",
"previous": "namen",
"live_tv": "chu' TV",
"coming_soon": "wej lup",
"on_now": "DaH",
"shows": "tlhol",
"movies": "DIS",
"sports": "QI'",
"for_kids": "puqbeq",
"news": "De'"
},
"jellyseerr": {
"confirm": "yInej",
"cancel": "yIQo'",
"yes": "HIja'",
"whats_wrong": "Doch rurbe' 'Iv?",
"issue_type": "ghIq bep",
"select_an_issue": "ghIq yIwIv",
"types": "bep",
"describe_the_issue": "(num) ghIq yIqon...",
"submit_button": "yInejqa'",
"report_issue_button": "ghIq yIqon",
"request_button": "yIpetlh",
"are_you_sure_you_want_to_request_all_seasons": "Hoch muv Danej petlh'a'?",
"failed_to_login": "'ellaHbe'",
"cast": "tlholwI'",
"details": "De'",
"status": "mIw",
"original_title": "wa'DIch pab HoS",
"series_type": "Hem bep",
"release_dates": "Qaw' jaj",
"first_air_date": "wa'DIch tlhol jaj",
"next_air_date": "wej tlhol jaj",
"revenue": "boj De'",
"budget": "boj nem",
"original_language": "wa'DIch Hol",
"production_country": "qonwI' qo'",
"studios": "qonwI'",
"network": "ret",
"currently_streaming_on": "DaH tlhol <...>",
"advanced": "SonchIy",
"request_as": "yIpetlh <...>",
"tags": "De'Hom",
"quality_profile": "luj wIvlaw'",
"root_folder": "wa'DIch ram",
"season_all": "muv (Hoch)",
"season_number": "muv {{season_number}}",
"number_episodes": "{{episode_number}} HemHom",
"born": "poS",
"appearances": "tlholvam",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr Ho'Do' veS wej law'be'! 2.0.0 yIchu'!",
"jellyseerr_test_failed": "Jellyseerr nejlaHbe'. yIHaDqa'.",
"failed_to_test_jellyseerr_server_url": "Jellyseerr Ho'Do' veS URL nejlaHbe'",
"issue_submitted": "ghIq nejqa'!",
"requested_item": "{{item}} petlh!",
"you_dont_have_permission_to_request": "petlh je'laHbe'!",
"something_went_wrong_requesting_media": "tlhol petlhDI' Doch rurbe'!"
}
},
"tabs": {
"home": "juH",
"search": "Sam",
"library": "De'wI' bom",
"custom_links": "teqlu' ret",
"favorites": "wIv Doch"
}
}

View File

@@ -137,7 +137,9 @@
"show_custom_menu_links": "Özel Menü Bağlantılarını Göster",
"hide_libraries": "Kütüphaneleri Gizle",
"select_liraries_you_want_to_hide": "Kütüphane sekmesinden ve ana sayfa bölümlerinden gizlemek istediğiniz kütüphaneleri seçin.",
"disable_haptic_feedback": "Dokunsal Geri Bildirimi Devre Dışı Bırak"
"disable_haptic_feedback": "Dokunsal Geri Bildirimi Devre Dışı Bırak",
"default_quality": "Varsayılan kalite",
"disabled": "Devre dışı"
},
"downloads": {
"downloads_title": "İndirmeler",
@@ -369,7 +371,9 @@
"audio_tracks": "Ses Parçaları:",
"playback_state": "Oynatma Durumu:",
"no_data_available": "Veri bulunamadı",
"index": "İndeks:"
"index": "İndeks:",
"continue_watching": "İzlemeye devam et",
"go_back": "Geri"
},
"item_card": {
"next_up": "Sıradaki",

View File

@@ -18,7 +18,7 @@
"invalid_username_or_password": "Неправильні імʼя користувача або пароль",
"user_does_not_have_permission_to_log_in": "Користувач не маю дозволу на вхід",
"server_is_taking_too_long_to_respond_try_again_later": "Сервер відповідає занадто довго, будь-ласка спробуйте пізніше",
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
"server_received_too_many_requests_try_again_later": "Сервер отримав забагато запитів, будь ласка спробуйте пізніше.",
"there_is_a_server_error": "Відбулася помилка на стороні сервера",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Відбулася несподівана помилка. Чи введений URL сервера правильний?"
},
@@ -41,7 +41,7 @@
"error_message": "Щось пішло не так.\nБудь ласка вийдіть і увійдіть знов.",
"continue_watching": "Продовжити перегляд",
"next_up": "Далі",
"recently_added_in": "Нещодавно додане до медіатеки {{libraryName}}",
"recently_added_in": "Нещодавно додане до \"{{libraryName}}\"",
"suggested_movies": "Рекомендовані Фільми",
"suggested_episodes": "Рекомендовані Епізоди",
"intro": {
@@ -52,7 +52,7 @@
"jellyseerr_feature_description": "Підключіться до вашого екземпляру Jellyseerr і запитуватуйте фільми безпосередньо в застосунку.",
"downloads_feature_title": "Завантаження",
"downloads_feature_description": "Завантажуйте фільми і серіали для перегляду офлайн. Використовуйте або метод за замовчуванням або встановіть оптимізований сервер для завантаження файлів у фоні.",
"chromecast_feature_description": "Транслюйте фільми і серіали но ваші Chromecast прилади.",
"chromecast_feature_description": "Транслюйте фільми і серіали на ваші Chromecast прилади.",
"centralised_settings_plugin_title": "Centralised Settings Plugin",
"centralised_settings_plugin_description": "Налаштуйте параметри з централізованої локації на вашому сервері Jellyfin. Всі налаштування клієнтів для всіх користувачів будуть синхронізовані автоматично.",
"done_button": "Готово",
@@ -80,14 +80,14 @@
"authorize": "Авторизувати"
},
"media_controls": {
"media_controls_title": "Керування Медія",
"forward_skip_length": "Тривалість перемотування вперед",
"media_controls_title": "Керування Медіа",
"forward_skip_length": "Довжина перемотування вперед",
"rewind_length": "Довжина перемотування назад",
"seconds_unit": "с"
},
"audio": {
"audio_title": "Аудіо",
"set_audio_track": "Виставити аудіо доріжку як в попередньому епізоду",
"set_audio_track": "Аудіо доріжка як в попередньому епізоді",
"audio_language": "Мова аудіо",
"audio_hint": "Вибрати мову аудіо за замовчуванням.",
"none": "Ніяка",
@@ -134,18 +134,19 @@
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "Показати посилання на користувацьке меню",
"show_custom_menu_links": "Показати користувацькі посилання меню",
"hide_libraries": "Сховати медіатеки",
"select_liraries_you_want_to_hide": "Виберіть медіатеки, що бажаєте приховати з вкладки Медіатека і з секції на головній сторінці.",
"disable_haptic_feedback": "Вимкнути тактильний зворотний зв'язок",
"default_quality": "Якість за замовченням"
"default_quality": "Якість за замовченням",
"disabled": "Вимкнено"
},
"downloads": {
"downloads_title": "Завантаження",
"download_method": "Метод завантаження",
"remux_max_download": "Remux max download",
"auto_download": "Авто-завантаження",
"optimized_versions_server": "Optimized versions server",
"optimized_versions_server": "Сервер оптимізованих версій",
"save_button": "Зберегти",
"optimized_server": "Оптимізований Сервер",
"optimized": "Оптимізований",
@@ -352,7 +353,9 @@
"episodes": "Епізоди",
"videos": "Відео",
"boxsets": "Бокс-сети",
"playlists": "Плейлісти"
"playlists": "Плейлісти",
"noDataTitle": "Поки що нема обраного",
"noData": "Відмітьте як улюблене що би побачити це тут в швидкому доступі."
},
"custom_links": {
"no_links": "Немає посилань"
@@ -360,7 +363,7 @@
"player": {
"error": "Помилка",
"failed_to_get_stream_url": "Не вдалося отримати URL-адресу потоку",
"an_error_occured_while_playing_the_video": "Під час відтворення відео сталася помилка. Перевірте журнали в налаштуваннях.",
"an_error_occured_while_playing_the_video": "Під час відтворення відео сталася помилка. Перевірте журнал в налаштуваннях.",
"client_error": "Помилка клієнту",
"could_not_create_stream_for_chromecast": "Не вдалося створити потік для Chromecast",
"message_from_server": "Повідомлення від серверу: {{message}}",
@@ -372,7 +375,9 @@
"audio_tracks": "Аудіо-доріжки:",
"playback_state": "Стан відтворення:",
"no_data_available": "Дані відсутні",
"index": "Індекс:"
"index": "Індекс:",
"continue_watching": "Продовжити перегляд",
"go_back": "Назад"
},
"item_card": {
"next_up": "Далі",
@@ -459,12 +464,12 @@
"born": "Дата народження",
"appearances": "Зовнішній вигляд",
"toasts": {
"jellyseer_does_not_meet_requirements": "Сервер Jellyseerr не відповідає мінімальним вимогам до версії! Будь ласка, оновіть версію принаймні до 2.0.0",
"jellyseer_does_not_meet_requirements": "Версія Jellyseerr не відповідає мінімальним вимогам! Будь ласка, оновіться принаймні до 2.0.0",
"jellyseerr_test_failed": "Тест Jellyseerr завершився невдало. Спробуйте ще раз.",
"failed_to_test_jellyseerr_server_url": "Не вдалося перевірити URL-адресу сервера jellyseerr",
"issue_submitted": "Звіт про проблему відправлено",
"requested_item": "Запитано {{item}}!",
"you_dont_have_permission_to_request": "У вас нема дозволу на запит медіа!",
"you_dont_have_permission_to_request": "Ви не маєте дозволу на запит медіа!",
"something_went_wrong_requesting_media": "Щось пішло не так під час запиту медіа!"
}
},

View File

@@ -369,7 +369,9 @@
"audio_tracks": "音频轨道:",
"playback_state": "播放状态:",
"no_data_available": "无可用数据",
"index": "索引:"
"index": "索引:",
"continue_watching": "继续观看",
"go_back": "返回"
},
"item_card": {
"next_up": "下一个",

View File

@@ -137,7 +137,9 @@
"show_custom_menu_links": "顯示自定義菜單鏈接",
"hide_libraries": "隱藏媒體庫",
"select_liraries_you_want_to_hide": "選擇您想從媒體庫頁面和主頁隱藏的媒體庫。",
"disable_haptic_feedback": "禁用觸覺回饋"
"disable_haptic_feedback": "禁用觸覺回饋",
"default_quality": "預設品質",
"disabled": "已停用"
},
"downloads": {
"downloads_title": "下載",
@@ -369,7 +371,9 @@
"audio_tracks": "音頻軌道:",
"playback_state": "播放狀態:",
"no_data_available": "無可用數據",
"index": "索引:"
"index": "索引:",
"continue_watching": "繼續觀看",
"go_back": "返回"
},
"item_card": {
"next_up": "下一個",

View File

@@ -8,6 +8,7 @@ export enum SortByOption {
CommunityRating = "CommunityRating",
CriticRating = "CriticRating",
DateCreated = "DateCreated",
DateLastContentAdded = "DateLastContentAdded",
DatePlayed = "DatePlayed",
PlayCount = "PlayCount",
ProductionYear = "ProductionYear",
@@ -37,6 +38,7 @@ export const sortOptions: {
{ key: SortByOption.CommunityRating, value: "Community Rating" },
{ key: SortByOption.CriticRating, value: "Critics Rating" },
{ key: SortByOption.DateCreated, value: "Date Added" },
{ key: SortByOption.DateLastContentAdded, value: "Date Episode Added" },
{ key: SortByOption.DatePlayed, value: "Date Played" },
{ key: SortByOption.PlayCount, value: "Play Count" },
{ key: SortByOption.ProductionYear, value: "Production Year" },

View File

@@ -114,6 +114,11 @@ export type HomeSectionNextUpResolver = {
enableRewatching?: boolean;
};
export interface MaxAutoPlayEpisodeCount {
key: string;
value: number;
}
export type HomeSectionLatestResolver = {
parentId?: string;
limit?: number;
@@ -163,6 +168,8 @@ export type Settings = {
hiddenLibraries?: string[];
enableH265ForChromecast: boolean;
defaultPlayer: VideoPlayer;
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
autoPlayEpisodeCount: number;
};
export interface Lockable<T> {
@@ -217,7 +224,9 @@ const defaultValues: Settings = {
jellyseerrServerUrl: undefined,
hiddenLibraries: [],
enableH265ForChromecast: false,
defaultPlayer: VideoPlayer.VLC_3, // ios only setting. does not matter what this is for android
defaultPlayer: VideoPlayer.VLC_3, // ios-only setting. does not matter what this is for android
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
autoPlayEpisodeCount: 0,
};
const loadSettings = (): Partial<Settings> => {
@@ -236,11 +245,11 @@ const loadSettings = (): Partial<Settings> => {
const EXCLUDE_FROM_SAVE = ["home"];
const saveSettings = (settings: Settings) => {
Object.keys(settings).forEach((key) => {
for (const key of Object.keys(settings)) {
if (EXCLUDE_FROM_SAVE.includes(key)) {
delete settings[key as keyof Settings];
}
});
}
const jsonValue = JSON.stringify(settings);
storage.set("settings", jsonValue);
};
@@ -271,7 +280,9 @@ export const useSettings = () => {
);
const refreshStreamyfinPluginSettings = useCallback(async () => {
if (!api) return;
if (!api) {
return;
}
const settings = await api.getStreamyfinPluginConfig().then(
({ data }) => {
writeInfoLog("Got plugin settings", data?.settings);
@@ -284,7 +295,9 @@ export const useSettings = () => {
}, [api]);
const updateSettings = (update: Partial<Settings>) => {
if (!_settings) return;
if (!_settings) {
return;
}
const hasChanges = Object.entries(update).some(
([key, value]) => _settings[key as keyof Settings] !== value,
);
@@ -305,34 +318,31 @@ export const useSettings = () => {
// If admin sets locked to false but provides a value,
// use user settings first and fallback on admin setting if required.
const settings: Settings = useMemo(() => {
let unlockedPluginDefaults = {} as Settings;
const overrideSettings = Object.entries(pluginSettings || {}).reduce(
(acc, [key, setting]) => {
if (setting) {
const { value, locked } = setting;
const unlockedPluginDefaults = {} as Settings;
const overrideSettings = Object.entries(pluginSettings ?? {}).reduce<
Partial<Settings>
>((acc, [key, setting]) => {
if (setting) {
const { value, locked } = setting;
const settingsKey = key as keyof Settings;
// Make sure we override default settings with plugin settings when they are not locked.
// Admin decided what users defaults should be and grants them the ability to change them too.
if (
locked === false &&
value &&
_settings?.[key as keyof Settings] !== value
) {
unlockedPluginDefaults = Object.assign(unlockedPluginDefaults, {
[key as keyof Settings]: value,
});
}
acc = Object.assign(acc, {
[key]: locked
? value
: (_settings?.[key as keyof Settings] ?? value),
// Make sure we override default settings with plugin settings when they are not locked.
if (
!locked &&
value !== undefined &&
_settings?.[settingsKey] !== value
) {
Object.assign(unlockedPluginDefaults, {
[settingsKey]: value,
});
}
return acc;
},
{} as Settings,
);
Object.assign(acc, {
[settingsKey]: locked ? value : (_settings?.[settingsKey] ?? value),
});
}
return acc;
}, {});
return {
...defaultValues,

View File

@@ -1,4 +1,4 @@
import native from "@/utils/profiles/native";
import generateDeviceProfile from "@/utils/profiles/native";
import type { Api } from "@jellyfin/sdk";
import type {
BaseItemDto,
@@ -14,23 +14,27 @@ export const getStreamUrl = async ({
userId,
startTimeTicks = 0,
maxStreamingBitrate,
sessionData,
deviceProfile = native,
playSessionId,
deviceProfile = generateDeviceProfile(),
audioStreamIndex = 0,
subtitleStreamIndex = undefined,
mediaSourceId,
download = false,
deviceId,
}: {
api: Api | null | undefined;
item: BaseItemDto | null | undefined;
userId: string | null | undefined;
startTimeTicks: number;
maxStreamingBitrate?: number;
sessionData?: PlaybackInfoResponse | null;
playSessionId?: string | null;
deviceProfile?: any;
audioStreamIndex?: number;
subtitleStreamIndex?: number;
height?: number;
mediaSourceId?: string | null;
download?: bool;
deviceId?: string | null;
}): Promise<{
url: string | null;
sessionId: string | null;
@@ -44,111 +48,84 @@ export const getStreamUrl = async ({
let mediaSource: MediaSourceInfo | undefined;
let sessionId: string | null | undefined;
if (item.Type === "Program") {
console.log("Item is of type program...");
const res0 = await getMediaInfoApi(api).getPlaybackInfo(
{
userId,
itemId: item.ChannelId!,
},
{
method: "POST",
params: {
startTimeTicks: 0,
isPlayback: true,
autoOpenLiveStream: true,
maxStreamingBitrate,
audioStreamIndex,
},
data: {
deviceProfile,
},
},
);
const transcodeUrl = res0.data.MediaSources?.[0].TranscodingUrl;
sessionId = res0.data.PlaySessionId || null;
if (transcodeUrl) {
return {
url: `${api.basePath}${transcodeUrl}`,
sessionId,
mediaSource: res0.data.MediaSources?.[0],
};
}
}
const itemId = item.Id;
const res2 = await getMediaInfoApi(api).getPlaybackInfo(
const res = await getMediaInfoApi(api).getPlaybackInfo(
{
itemId: item.Id!,
},
{
method: "POST",
data: {
deviceProfile,
userId,
maxStreamingBitrate,
startTimeTicks,
autoOpenLiveStream: true,
mediaSourceId,
audioStreamIndex,
deviceProfile,
subtitleStreamIndex,
startTimeTicks,
isPlayback: true,
autoOpenLiveStream: true,
maxStreamingBitrate,
audioStreamIndex,
mediaSourceId,
},
},
);
if (res2.status !== 200) {
console.error("Error getting playback info:", res2.status, res2.statusText);
if (res.status !== 200) {
console.error("Error getting playback info:", res.status, res.statusText);
}
sessionId = res2.data.PlaySessionId || null;
sessionId = res.data.PlaySessionId || null;
mediaSource = res.data.MediaSources[0];
let transcodeUrl = mediaSource.TranscodingUrl;
mediaSource = res2.data.MediaSources?.find(
(source: MediaSourceInfo) => source.Id === mediaSourceId,
);
if (item.MediaType === "Video") {
if (mediaSource?.TranscodingUrl) {
const urlObj = new URL(api.basePath + mediaSource?.TranscodingUrl); // Create a URL object
// Get the updated URL
const transcodeUrl = urlObj.toString();
console.log("Video has transcoding URL:", `${transcodeUrl}`);
return {
url: transcodeUrl,
sessionId: sessionId,
mediaSource,
};
if (transcodeUrl) {
if (download) {
transcodeUrl = transcodeUrl.replace("master.m3u8", "stream");
}
const searchParams = new URLSearchParams({
playSessionId: sessionData?.PlaySessionId || "",
mediaSourceId: mediaSource?.Id || "",
static: "true",
subtitleStreamIndex: subtitleStreamIndex?.toString() || "",
audioStreamIndex: audioStreamIndex?.toString() || "",
deviceId: api.deviceInfo.id,
api_key: api.accessToken,
startTimeTicks: startTimeTicks.toString(),
maxStreamingBitrate: maxStreamingBitrate?.toString() || "",
userId: userId || "",
});
const directPlayUrl = `${
api.basePath
}/Videos/${itemId}/stream.mp4?${searchParams.toString()}`;
console.log("Video is being direct played:", directPlayUrl);
console.log("Video is being transcoded:", transcodeUrl);
return {
url: directPlayUrl,
sessionId: sessionId,
url: `${api.basePath}${transcodeUrl}`,
sessionId,
mediaSource,
};
}
Alert.alert("Error", "Could not play this item");
let downloadParams = {};
return null;
if (download) {
// We need to disable static so we can have a remux with subtitle.
downloadParams = {
subtitleMethod: "Embed",
enableSubtitlesInManifest: true,
static: "false",
allowVideoStreamCopy: true,
allowAudioStreamCopy: true,
playSessionId: sessionId || "",
container: "ts",
};
}
const streamParams = new URLSearchParams({
static: "true",
container: "mp4",
mediaSourceId: mediaSource?.Id || "",
subtitleStreamIndex: subtitleStreamIndex?.toString() || "",
audioStreamIndex: audioStreamIndex?.toString() || "",
deviceId: deviceId || api.deviceInfo.id,
api_key: api.accessToken,
startTimeTicks: startTimeTicks.toString(),
maxStreamingBitrate: maxStreamingBitrate?.toString() || "",
userId: userId || "",
...downloadParams,
});
const directPlayUrl = `${
api.basePath
}/Videos/${item.Id}/stream?${streamParams.toString()}`;
console.log("Video is being direct played:", directPlayUrl);
return {
url: directPlayUrl,
sessionId: sessionId || playSessionId,
mediaSource,
};
};

View File

@@ -1,7 +1,5 @@
import { getOrSetDeviceId } from "@/providers/JellyfinProvider";
import type { Settings } from "@/utils/atoms/settings";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
import type { Api } from "@jellyfin/sdk";
import { DeviceProfile } from "@jellyfin/sdk/lib/generated-client";

View File

@@ -1,5 +1,5 @@
import type { Settings } from "@/utils/atoms/settings";
import native from "@/utils/profiles/native";
import generateDeviceProfile from "@/utils/profiles/native";
import type { Api } from "@jellyfin/sdk";
import type { AxiosResponse } from "axios";
import { getAuthHeaders } from "../jellyfin";
@@ -43,7 +43,7 @@ export const postCapabilities = async ({
],
supportsMediaControl: true,
id: sessionId,
DeviceProfile: native,
DeviceProfile: generateDeviceProfile(),
},
{
headers: getAuthHeaders(api),

View File

@@ -1,143 +0,0 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import MediaTypes from "../../constants/MediaTypes";
/**
* Device profile for Native video player
*/
export default {
Name: "1. Native iOS Video Profile",
MaxStaticBitrate: 100000000,
MaxStreamingBitrate: 120000000,
MusicStreamingTranscodingBitrate: 384000,
CodecProfiles: [
{
Type: MediaTypes.Video,
Codec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
},
{
Type: MediaTypes.Audio,
Codec: "aac,mp3,flac,alac,opus,vorbis,pcm,wma",
},
],
DirectPlayProfiles: [
{
Type: MediaTypes.Video,
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp",
VideoCodec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
AudioCodec: "aac,mp3,flac,alac,opus,vorbis,wma",
},
{
Type: MediaTypes.Audio,
Container: "mp3,aac,flac,alac,wav,ogg,wma",
AudioCodec: "mp3,aac,flac,alac,opus,vorbis,wma,pcm",
},
],
TranscodingProfiles: [
{
Type: MediaTypes.Video,
Context: "Streaming",
Protocol: "hls",
Container: "ts",
VideoCodec: "h264",
AudioCodec: "aac,mp3,ac3",
MaxAudioChannels: "8",
MinSegments: "2",
BreakOnNonKeyFrames: true,
},
{
Type: MediaTypes.Audio,
Context: "Streaming",
Protocol: "http",
Container: "mp3",
AudioCodec: "mp3",
MaxAudioChannels: "2",
},
],
ResponseProfiles: [
{
Container: "mkv",
MimeType: "video/x-matroska",
Type: MediaTypes.Video,
},
{
Container: "mp4",
MimeType: "video/mp4",
Type: MediaTypes.Video,
},
],
SubtitleProfiles: [
{ Format: "srt", Method: "Embed" },
{ Format: "srt", Method: "External" },
{ Format: "srt", Method: "Encode" },
{ Format: "ass", Method: "Embed" },
{ Format: "ass", Method: "External" },
{ Format: "ass", Method: "Encode" },
{ Format: "ssa", Method: "Embed" },
{ Format: "ssa", Method: "External" },
{ Format: "ssa", Method: "Encode" },
{ Format: "sub", Method: "Embed" },
{ Format: "sub", Method: "External" },
{ Format: "sub", Method: "Encode" },
{ Format: "vtt", Method: "Embed" },
{ Format: "vtt", Method: "External" },
{ Format: "vtt", Method: "Encode" },
{ Format: "ttml", Method: "Embed" },
{ Format: "ttml", Method: "External" },
{ Format: "ttml", Method: "Encode" },
{ Format: "pgs", Method: "Embed" },
{ Format: "pgs", Method: "External" },
{ Format: "pgs", Method: "Encode" },
{ Format: "dvdsub", Method: "Embed" },
{ Format: "dvdsub", Method: "External" },
{ Format: "dvdsub", Method: "Encode" },
{ Format: "dvbsub", Method: "Embed" },
{ Format: "dvbsub", Method: "External" },
{ Format: "dvbsub", Method: "Encode" },
{ Format: "xsub", Method: "Embed" },
{ Format: "xsub", Method: "External" },
{ Format: "xsub", Method: "Encode" },
{ Format: "mov_text", Method: "Embed" },
{ Format: "mov_text", Method: "External" },
{ Format: "mov_text", Method: "Encode" },
{ Format: "scc", Method: "Embed" },
{ Format: "scc", Method: "External" },
{ Format: "scc", Method: "Encode" },
{ Format: "smi", Method: "Embed" },
{ Format: "smi", Method: "External" },
{ Format: "smi", Method: "Encode" },
{ Format: "teletext", Method: "Embed" },
{ Format: "teletext", Method: "External" },
{ Format: "teletext", Method: "Encode" },
{ Format: "microdvd", Method: "Embed" },
{ Format: "microdvd", Method: "External" },
{ Format: "microdvd", Method: "Encode" },
{ Format: "mpl2", Method: "Embed" },
{ Format: "mpl2", Method: "External" },
{ Format: "mpl2", Method: "Encode" },
{ Format: "pjs", Method: "Embed" },
{ Format: "pjs", Method: "External" },
{ Format: "pjs", Method: "Encode" },
{ Format: "realtext", Method: "Embed" },
{ Format: "realtext", Method: "External" },
{ Format: "realtext", Method: "Encode" },
{ Format: "stl", Method: "Embed" },
{ Format: "stl", Method: "External" },
{ Format: "stl", Method: "Encode" },
{ Format: "subrip", Method: "Embed" },
{ Format: "subrip", Method: "External" },
{ Format: "subrip", Method: "Encode" },
{ Format: "subviewer", Method: "Embed" },
{ Format: "subviewer", Method: "External" },
{ Format: "subviewer", Method: "Encode" },
{ Format: "text", Method: "Embed" },
{ Format: "text", Method: "External" },
{ Format: "text", Method: "Encode" },
{ Format: "vplayer", Method: "Embed" },
{ Format: "vplayer", Method: "External" },
{ Format: "vplayer", Method: "Encode" },
],
};

View File

@@ -1,86 +0,0 @@
import MediaTypes from "../../constants/MediaTypes";
export default {
Name: "Expo Base Video Profile",
MaxStaticBitrate: 100000000,
MaxStreamingBitrate: 120000000,
MusicStreamingTranscodingBitrate: 384000,
CodecProfiles: [
{
Codec: "h264",
Conditions: [
{
Condition: "NotEquals",
IsRequired: false,
Property: "IsAnamorphic",
Value: "true",
},
{
Condition: "EqualsAny",
IsRequired: false,
Property: "VideoProfile",
Value: "high|main|baseline|constrained baseline",
},
{
Condition: "LessThanEqual",
IsRequired: false,
Property: "VideoLevel",
Value: "51",
},
{
Condition: "NotEquals",
IsRequired: false,
Property: "IsInterlaced",
Value: "true",
},
],
Type: MediaTypes.Video,
},
{
Codec: "hevc",
Conditions: [
{
Condition: "NotEquals",
IsRequired: false,
Property: "IsAnamorphic",
Value: "true",
},
{
Condition: "EqualsAny",
IsRequired: false,
Property: "VideoProfile",
Value: "main|main 10",
},
{
Condition: "LessThanEqual",
IsRequired: false,
Property: "VideoLevel",
Value: "183",
},
{
Condition: "NotEquals",
IsRequired: false,
Property: "IsInterlaced",
Value: "true",
},
],
Type: MediaTypes.Video,
},
],
ContainerProfiles: [],
DirectPlayProfiles: [],
ResponseProfiles: [
{
Container: "m4v",
MimeType: "video/mp4",
Type: MediaTypes.Video,
},
],
SubtitleProfiles: [
{
Format: "vtt",
Method: "Hls",
},
],
TranscodingProfiles: [],
};

View File

@@ -1,149 +0,0 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import MediaTypes from "../../constants/MediaTypes";
import BaseProfile from "./base";
/**
* Device profile for Expo Video player on iOS 13+
*/
export default {
...BaseProfile,
Name: "Expo iOS Video Profile",
DirectPlayProfiles: [
{
AudioCodec: "aac,mp3,ac3,eac3,flac,alac",
Container: "mp4,m4v",
Type: MediaTypes.Video,
VideoCodec: "hevc,h264",
},
{
AudioCodec: "aac,mp3,ac3,eac3,flac,alac",
Container: "mov",
Type: MediaTypes.Video,
VideoCodec: "hevc,h264",
},
{
Container: "mp3",
Type: MediaTypes.Audio,
},
{
Container: "aac",
Type: MediaTypes.Audio,
},
{
AudioCodec: "aac",
Container: "m4a",
Type: MediaTypes.Audio,
},
{
AudioCodec: "aac",
Container: "m4b",
Type: MediaTypes.Audio,
},
{
Container: "flac",
Type: MediaTypes.Audio,
},
{
Container: "alac",
Type: MediaTypes.Audio,
},
{
AudioCodec: "alac",
Container: "m4a",
Type: MediaTypes.Audio,
},
{
AudioCodec: "alac",
Container: "m4b",
Type: MediaTypes.Audio,
},
{
Container: "wav",
Type: MediaTypes.Audio,
},
],
TranscodingProfiles: [
{
AudioCodec: "aac",
BreakOnNonKeyFrames: true,
Container: "aac",
Context: "Streaming",
MaxAudioChannels: "6",
MinSegments: "2",
Protocol: "hls",
Type: MediaTypes.Audio,
},
{
AudioCodec: "aac",
Container: "aac",
Context: "Streaming",
MaxAudioChannels: "6",
Protocol: "http",
Type: MediaTypes.Audio,
},
{
AudioCodec: "mp3",
Container: "mp3",
Context: "Streaming",
MaxAudioChannels: "6",
Protocol: "http",
Type: MediaTypes.Audio,
},
{
AudioCodec: "wav",
Container: "wav",
Context: "Streaming",
MaxAudioChannels: "6",
Protocol: "http",
Type: MediaTypes.Audio,
},
{
AudioCodec: "mp3",
Container: "mp3",
Context: "Static",
MaxAudioChannels: "6",
Protocol: "http",
Type: MediaTypes.Audio,
},
{
AudioCodec: "aac",
Container: "aac",
Context: "Static",
MaxAudioChannels: "6",
Protocol: "http",
Type: MediaTypes.Audio,
},
{
AudioCodec: "wav",
Container: "wav",
Context: "Static",
MaxAudioChannels: "6",
Protocol: "http",
Type: MediaTypes.Audio,
},
{
AudioCodec: "aac,mp3",
BreakOnNonKeyFrames: true,
Container: "ts",
Context: "Streaming",
MaxAudioChannels: "6",
MinSegments: "2",
Protocol: "hls",
Type: MediaTypes.Video,
VideoCodec: "h264",
},
{
AudioCodec: "aac,mp3,ac3,eac3,flac,alac",
Container: "mp4",
Context: "Static",
Protocol: "http",
Type: MediaTypes.Video,
VideoCodec: "h264",
},
],
};

View File

@@ -1,3 +1,5 @@
import { Platform } from "react-native";
import DeviceInfo from "react-native-device-info";
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -5,132 +7,190 @@
*/
import MediaTypes from "../../constants/MediaTypes";
/**
* Device profile for Native video player
*/
export default {
Name: "1. Vlc Player",
MaxStaticBitrate: 999_999_999,
MaxStreamingBitrate: 999_999_999,
CodecProfiles: [
{
Type: MediaTypes.Video,
Codec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
},
{
Type: MediaTypes.Audio,
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,pcm,wma",
},
],
DirectPlayProfiles: [
{
Type: MediaTypes.Video,
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
VideoCodec:
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma,dts",
},
{
Type: MediaTypes.Audio,
Container: "mp3,aac,flac,alac,wav,ogg,wma",
AudioCodec:
"mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
},
],
TranscodingProfiles: [
{
Type: MediaTypes.Video,
Context: "Streaming",
Protocol: "hls",
Container: "fmp4",
VideoCodec: "h264, hevc",
AudioCodec: "aac,mp3,ac3,dts",
},
{
Type: MediaTypes.Audio,
Context: "Streaming",
Protocol: "http",
Container: "mp3",
AudioCodec: "mp3",
MaxAudioChannels: "2",
},
],
SubtitleProfiles: [
// Official formats
{ Format: "vtt", Method: "Embed" },
{ Format: "vtt", Method: "External" },
// Helper function to detect Dolby Vision support
const supportsDolbyVision = async () => {
if (Platform.OS === "ios") {
const deviceModel = await DeviceInfo.getModel();
// iPhone 12 and newer generally support Dolby Vision
const modelNumber = Number.parseInt(deviceModel.replace(/iPhone/, ""), 10);
return !Number.isNaN(modelNumber) && modelNumber >= 12;
}
{ Format: "webvtt", Method: "Embed" },
{ Format: "webvtt", Method: "External" },
if (Platform.OS === "android") {
const apiLevel = await DeviceInfo.getApiLevel();
const isHighEndDevice =
(await DeviceInfo.getTotalMemory()) > 4 * 1024 * 1024 * 1024; // >4GB RAM
// Very rough approximation - Android 10+ on higher-end devices may support it
return apiLevel >= 29 && isHighEndDevice;
}
{ Format: "srt", Method: "Embed" },
{ Format: "srt", Method: "External" },
{ Format: "subrip", Method: "Embed" },
{ Format: "subrip", Method: "External" },
{ Format: "ttml", Method: "Embed" },
{ Format: "ttml", Method: "External" },
{ Format: "dvbsub", Method: "Embed" },
{ Format: "dvdsub", Method: "Encode" },
{ Format: "ass", Method: "Embed" },
{ Format: "ass", Method: "External" },
{ Format: "idx", Method: "Embed" },
{ Format: "idx", Method: "Encode" },
{ Format: "pgs", Method: "Embed" },
{ Format: "pgs", Method: "Encode" },
{ Format: "pgssub", Method: "Embed" },
{ Format: "pgssub", Method: "Encode" },
{ Format: "ssa", Method: "Embed" },
{ Format: "ssa", Method: "External" },
// Other formats
{ Format: "microdvd", Method: "Embed" },
{ Format: "microdvd", Method: "External" },
{ Format: "mov_text", Method: "Embed" },
{ Format: "mov_text", Method: "External" },
{ Format: "mpl2", Method: "Embed" },
{ Format: "mpl2", Method: "External" },
{ Format: "pjs", Method: "Embed" },
{ Format: "pjs", Method: "External" },
{ Format: "realtext", Method: "Embed" },
{ Format: "realtext", Method: "External" },
{ Format: "scc", Method: "Embed" },
{ Format: "scc", Method: "External" },
{ Format: "smi", Method: "Embed" },
{ Format: "smi", Method: "External" },
{ Format: "stl", Method: "Embed" },
{ Format: "stl", Method: "External" },
{ Format: "sub", Method: "Embed" },
{ Format: "sub", Method: "External" },
{ Format: "subviewer", Method: "Embed" },
{ Format: "subviewer", Method: "External" },
{ Format: "teletext", Method: "Embed" },
{ Format: "teletext", Method: "Encode" },
{ Format: "text", Method: "Embed" },
{ Format: "text", Method: "External" },
{ Format: "vplayer", Method: "Embed" },
{ Format: "vplayer", Method: "External" },
{ Format: "xsub", Method: "Embed" },
{ Format: "xsub", Method: "External" },
],
return false;
};
export const generateDeviceProfile = async () => {
const dolbyVisionSupported = await supportsDolbyVision();
/**
* Device profile for Native video player
*/
const profile = {
Name: "1. Vlc Player",
MaxStaticBitrate: 999_999_999,
MaxStreamingBitrate: 999_999_999,
CodecProfiles: [
{
Type: MediaTypes.Video,
Codec: "h264,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
},
{
Type: MediaTypes.Video,
Codec: "hevc,h265",
Conditions: [
{
Condition: "LessThanEqual",
Property: "VideoLevel",
Value: "153",
IsRequired: false,
},
// We'll add Dolby Vision condition below if not supported
],
},
{
Type: MediaTypes.Audio,
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,pcm,wma",
},
],
DirectPlayProfiles: [
{
Type: MediaTypes.Video,
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
VideoCodec:
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma,dts",
},
{
Type: MediaTypes.Audio,
Container: "mp3,aac,flac,alac,wav,ogg,wma",
AudioCodec:
"mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
},
],
TranscodingProfiles: [
{
Type: MediaTypes.Video,
Context: "Streaming",
Protocol: "hls",
Container: "mp4",
VideoCodec: "h264, hevc",
AudioCodec: "aac,mp3,ac3,dts",
},
{
Type: MediaTypes.Audio,
Context: "Streaming",
Protocol: "http",
Container: "mp3",
AudioCodec: "mp3",
MaxAudioChannels: "2",
},
],
SubtitleProfiles: [
// Official formats
{ Format: "vtt", Method: "Embed" },
{ Format: "vtt", Method: "External" },
{ Format: "webvtt", Method: "Embed" },
{ Format: "webvtt", Method: "External" },
{ Format: "srt", Method: "Embed" },
{ Format: "srt", Method: "External" },
{ Format: "subrip", Method: "Embed" },
{ Format: "subrip", Method: "External" },
{ Format: "ttml", Method: "Embed" },
{ Format: "ttml", Method: "External" },
{ Format: "dvbsub", Method: "Embed" },
{ Format: "dvdsub", Method: "Encode" },
{ Format: "ass", Method: "Embed" },
{ Format: "ass", Method: "External" },
{ Format: "idx", Method: "Embed" },
{ Format: "idx", Method: "Encode" },
{ Format: "pgs", Method: "Embed" },
{ Format: "pgs", Method: "Encode" },
{ Format: "pgssub", Method: "Embed" },
{ Format: "pgssub", Method: "Encode" },
{ Format: "ssa", Method: "Embed" },
{ Format: "ssa", Method: "External" },
// Other formats
{ Format: "microdvd", Method: "Embed" },
{ Format: "microdvd", Method: "External" },
{ Format: "mov_text", Method: "Embed" },
{ Format: "mov_text", Method: "External" },
{ Format: "mpl2", Method: "Embed" },
{ Format: "mpl2", Method: "External" },
{ Format: "pjs", Method: "Embed" },
{ Format: "pjs", Method: "External" },
{ Format: "realtext", Method: "Embed" },
{ Format: "realtext", Method: "External" },
{ Format: "scc", Method: "Embed" },
{ Format: "scc", Method: "External" },
{ Format: "smi", Method: "Embed" },
{ Format: "smi", Method: "External" },
{ Format: "stl", Method: "Embed" },
{ Format: "stl", Method: "External" },
{ Format: "sub", Method: "Embed" },
{ Format: "sub", Method: "External" },
{ Format: "subviewer", Method: "Embed" },
{ Format: "subviewer", Method: "External" },
{ Format: "teletext", Method: "Embed" },
{ Format: "teletext", Method: "Encode" },
{ Format: "text", Method: "Embed" },
{ Format: "text", Method: "External" },
{ Format: "vplayer", Method: "Embed" },
{ Format: "vplayer", Method: "External" },
{ Format: "xsub", Method: "Embed" },
{ Format: "xsub", Method: "External" },
],
};
// Add Dolby Vision restriction if not supported
if (!dolbyVisionSupported) {
const hevcProfile = profile.CodecProfiles.find(
(p) => p.Type === MediaTypes.Video && p.Codec.includes("hevc"),
);
if (hevcProfile) {
hevcProfile.Conditions.push({
Condition: "NotEquals",
Property: "VideoRangeType",
Value: "DOVI", //no dolby vision at all
IsRequired: true,
});
}
}
return profile;
};
export default async () => {
return await generateDeviceProfile();
};